diff --git a/.vscode/launch.json b/.vscode/launch.json index cb7184a..89aec45 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,7 @@ "runtimeArgs": ["-r", "ts-eager/register"], "console": "integratedTerminal", "program": "${workspaceFolder}/spec/marktest/index.ts", - "cwd": "${workspaceFolder}/spec/marktest", + "cwd": "${workspaceFolder}", "args": ["${file}:${lineNumber}"] } ] diff --git a/index.ts b/index.ts index 75b1385..09f968f 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,7 @@ import transforms from './src/transforms'; import { parseTags, isPromise } from './src/utils'; import validator from './src/validator'; -import type { Node } from './src/types'; +import type { Node, ParserArgs } from './src/types'; import type Token from 'markdown-it/lib/token'; import type { Config, RenderableTreeNode, ValidateError } from './src/types'; @@ -39,9 +39,12 @@ function mergeConfig(config: Config = {}): Config { }; } -export function parse(content: string | Token[], file?: string): Node { +export function parse( + content: string | Token[], + args?: string | ParserArgs +): Node { if (typeof content === 'string') content = tokenizer.tokenize(content); - return parser(content, file); + return parser(content, args); } export function resolve( diff --git a/spec/marktest/index.ts b/spec/marktest/index.ts index 84e6fab..105cad7 100644 --- a/spec/marktest/index.ts +++ b/spec/marktest/index.ts @@ -23,9 +23,9 @@ const tokenizer = new markdoc.Tokenizer({ allowComments: true, }); -function parse(content: string, file?: string) { +function parse(content: string, slots?: boolean, file?: string) { const tokens = tokenizer.tokenize(content); - return markdoc.parse(tokens, file); + return markdoc.parse(tokens, { file, slots }); } function stripLines(object) { @@ -36,7 +36,7 @@ function stripLines(object) { function render(code, config, dynamic) { const partials = {}; for (const [file, content] of Object.entries(config.partials ?? {})) - partials[file] = parse(content as string, file); + partials[file] = parse(content as string, false, file); const { react, reactStatic } = markdoc.renderers; const transformed = markdoc.transform(code, { ...config, partials }); @@ -105,7 +105,7 @@ function formatValidation(filename, test, validation) { let exitCode = 0; for (const test of tests) { - const code = parse(test.code || ''); + const code = parse(test.code || '', test.slots); const { start, end } = test.$$lines; if (line && (Number(line) - 1 < start || Number(line) - 1 > end)) continue; diff --git a/spec/marktest/tests.yaml b/spec/marktest/tests.yaml index 4618816..54793c6 100644 --- a/spec/marktest/tests.yaml +++ b/spec/marktest/tests.yaml @@ -1592,3 +1592,197 @@ code: | {% foo bar="this is a test of \"quoted\" strings" /%} expected:
+ +- name: Basic slot + config: + tags: + foo: + render: foo + slots: + bar: {} + code: | + {% foo %} + {% slot "bar" %} + This is a test + {% /slot %} + {% /foo %} + slots: true + expected: + - tag: foo + attributes: + bar: + - tag: p + children: + - This is a test + +- name: Tag with multiple slots and additional content + config: + tags: + foo: + render: foo + attributes: + qux: + type: String + slots: + bar: {} + baz: {} + code: | + {% foo qux="test" %} + {% slot "bar" %} + This is a test + {% /slot %} + + {% slot "baz" %} + This is **another** test + {% /slot %} + + This is additional content + {% /foo %} + slots: true + expected: + - tag: foo + attributes: + qux: test + bar: + - tag: p + children: + - This is a test + baz: + - tag: p + children: + - 'This is ' + - tag: strong + children: [another] + - ' test' + children: + - tag: p + children: + - This is additional content + +- name: User slot tag when slots are disabled + config: + tags: + slot: + render: foo + code: | + {% slot %} + bar + {% /slot %} + expected: + - tag: foo + children: + - tag: p + children: [bar] + +- name: Handling slots that are missing a name + config: + tags: + foo: + render: foo + attributes: + bar: + type: Node + code: | + {% foo %} + {% slot %} + This is a test + {% /slot %} + {% /foo %} + slots: true + expectedError: "Missing required attribute: 'primary'" + expected: + - tag: foo + children: [null] + +- name: Handling slots with invalid name + config: + tags: + foo: + render: foo + attributes: + bar: + type: Node + code: | + {% foo %} + {% slot 1 %} + This is a test + {% /slot %} + {% /foo %} + slots: true + expectedError: "Attribute 'primary' must be type of 'String'" + expected: + - tag: foo + children: [null] + +- name: Validating required slot + config: + tags: + foo: + render: foo + slots: + bar: + required: true + code: | + {% foo %} + {% /foo %} + slots: true + expectedError: "Missing required slot: 'bar'" + expected: + - tag: foo + +- name: Handling invalid slot + config: + tags: + foo: + render: foo + code: | + {% foo %} + {% slot "bar" %} + This is a test + {% /slot %} + {% /foo %} + slots: true + expectedError: "Invalid slot: 'bar'" + expected: + - tag: foo + +- name: Handling overlapping slot and attribute + config: + tags: + foo: + render: foo + attributes: + bar: + type: String + slots: + bar: {} + code: | + {% foo bar="test" %} + {% /foo %} + + {% foo bar="test" %} + {% slot "bar" %} + test + {% /slot %} + {% /foo %} + + {% foo %} + {% slot "bar" %} + test + {% /slot %} + {% /foo %} + slots: true + expected: + - tag: foo + attributes: + bar: 'test' + - tag: foo + attributes: + bar: + - tag: p + children: [test] + - tag: foo + attributes: + bar: + - tag: p + children: [test] diff --git a/src/ast/node.ts b/src/ast/node.ts index 284bcf7..8a06087 100644 --- a/src/ast/node.ts +++ b/src/ast/node.ts @@ -17,6 +17,7 @@ export default class Node implements AstType { readonly $$mdtype = 'Node'; attributes: Record; + slots: Record; children: Node[]; errors: ValidationError[] = []; lines: number[] = []; @@ -38,9 +39,15 @@ export default class Node implements AstType { this.type = type; this.tag = tag; this.annotations = []; + this.slots = {}; } *walk(): Generator { + for (const slot of Object.values(this.slots)) { + yield slot; + yield* slot.walk(); + } + for (const child of this.children) { yield child; yield* child.walk(); diff --git a/src/parser.test.ts b/src/parser.test.ts index 7df4f9d..e3077be 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -9,12 +9,50 @@ describe('Markdown parser', function () { const fence = '```'; const tokenizer = new Tokenizer({ allowComments: true }); - function convert(example) { + function convert(example, options?) { const content = example.replace(/\n\s+/gm, '\n').trim(); const tokens = tokenizer.tokenize(content); - return parser(tokens); + return parser(tokens, options); } + describe('handling options', function () { + it('no args', function () { + const example = convert(`# This is a test`); + expect(example.children[0]).toDeepEqual({ + ...any(), + type: 'heading', + location: { + ...any(), + file: undefined, + }, + }); + }); + + it('filename as string', function () { + const example = convert(`# This is a test`, 'foo.md'); + expect(example.children[0]).toDeepEqual({ + ...any(), + type: 'heading', + location: { + ...any(), + file: 'foo.md', + }, + }); + }); + + it('filename as property', function () { + const example = convert(`# This is a test`, { file: 'foo.md' }); + expect(example.children[0]).toDeepEqual({ + ...any(), + type: 'heading', + location: { + ...any(), + file: 'foo.md', + }, + }); + }); + }); + describe('handling frontmatter', function () { it('simple frontmatter', function () { const example = convert(` diff --git a/src/parser.ts b/src/parser.ts index c71257d..f88cf7c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,7 +2,7 @@ import Node from './ast/node'; import transforms from './transforms/index'; import { OPEN } from './utils'; -import type { AttributeValue } from './types'; +import type { AttributeValue, ParserArgs } from './types'; import type Token from 'markdown-it/lib/token'; @@ -85,6 +85,7 @@ function handleToken( token: Token, nodes: Node[], file?: string, + handleSlots?: boolean, inlineParent?: Node ) { if (token.type === 'frontmatter') { @@ -152,7 +153,14 @@ function handleToken( if (attributes && ['tag', 'fence', 'image'].includes(typeName)) annotate(node, attributes); - parent.push(node); + if ( + handleSlots && + tag === 'slot' && + typeof node.attributes.primary === 'string' + ) + parent.slots[node.attributes.primary] = node; + else parent.push(node); + if (token.nesting > 0) nodes.push(node); if (!Array.isArray(token.children)) return; @@ -164,17 +172,20 @@ function handleToken( const isLeafNode = typeName === 'image'; if (!isLeafNode) { for (const child of token.children) - handleToken(child, nodes, file, inlineParent); + handleToken(child, nodes, file, handleSlots, inlineParent); } nodes.pop(); } -export default function parser(tokens: Token[], file?: string) { +export default function parser(tokens: Token[], args?: string | ParserArgs) { const doc = new Node('document'); const nodes = [doc]; - for (const token of tokens) handleToken(token, nodes, file); + if (typeof args === 'string') args = { file: args }; + + for (const token of tokens) + handleToken(token, nodes, args?.file, args?.slots); if (nodes.length > 1) for (const node of nodes.slice(1)) diff --git a/src/tags/index.ts b/src/tags/index.ts index 9069730..739a52e 100644 --- a/src/tags/index.ts +++ b/src/tags/index.ts @@ -1,10 +1,12 @@ import { tagIf, tagElse } from './conditional'; import { partial } from './partial'; import { table } from './table'; +import { slot } from './slot'; export default { if: tagIf, else: tagElse, partial, table, + slot, }; diff --git a/src/tags/slot.ts b/src/tags/slot.ts new file mode 100644 index 0000000..d06ad35 --- /dev/null +++ b/src/tags/slot.ts @@ -0,0 +1,7 @@ +import type { Schema } from '../types'; + +export const slot: Schema = { + attributes: { + primary: { type: String, required: true }, + }, +}; diff --git a/src/transformer.ts b/src/transformer.ts index c8d1a6e..13bf55e 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -47,6 +47,13 @@ export default { output[name] = value; } + if (schema.slots) { + for (const [key, slot] of Object.entries(schema.slots)) { + const name = typeof slot.render === 'string' ? slot.render : key; + if (node.slots[key]) output[name] = this.node(node.slots[key], config); + } + } + return output; }, diff --git a/src/types.ts b/src/types.ts index 0c367b7..564cfac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export type Schema = { render?: R; children?: string[]; attributes?: Record; + slots?: Record; selfClosing?: boolean; inline?: boolean; transform?(node: Node, config: C): MaybePromise; @@ -119,6 +120,11 @@ export type SchemaAttribute = { export type SchemaMatches = RegExp | string[] | null; +export type SchemaSlot = { + render?: boolean | string; + required?: boolean; +}; + export interface Transformer { findSchema(node: Node, config: Config): Schema | undefined; node(node: Node, config: Config): MaybePromise; @@ -154,3 +160,8 @@ export type ValidationType = | 'Array'; export type Value = AstType | Scalar; + +export type ParserArgs = { + file?: string; + slots?: boolean; +}; diff --git a/src/validator.ts b/src/validator.ts index 441187d..9cebeea 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -158,6 +158,16 @@ export default function validator(node: Node, config: Config) { ...schema.attributes, }; + for (const key of Object.keys(node.slots)) { + const slot = schema.slots?.[key]; + if (!slot) + errors.push({ + id: 'slot-undefined', + level: 'error', + message: `Invalid slot: '${key}'`, + }); + } + for (let [key, value] of Object.entries(node.attributes)) { const attrib = attributes[key]; @@ -241,6 +251,15 @@ export default function validator(node: Node, config: Config) { message: `Missing required attribute: '${key}'`, }); + if (schema.slots) + for (const [key, { required }] of Object.entries(schema.slots)) + if (required && node.slots[key] === undefined) + errors.push({ + id: 'slot-missing-required', + level: 'error', + message: `Missing required slot: '${key}'`, + }); + for (const { type } of node.children) { if (schema.children && type !== 'error' && !schema.children.includes(type)) errors.push({