From 2d4f83dfba1e6f397db79adb55c07a06cbf640a5 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 13 Oct 2026 12:34:57 +0200 Subject: [PATCH 1/4] feature: remark file tree (AI assisted) --- src/plugins/remark-file-trees/plugin.ts | 184 +++++ .../remark-file-trees/tests/plugin.test.ts | 677 ++++++++++++++++++ src/siteConfig/markdownPluginConfigs.ts | 4 + .../markdown/remark-file-trees/index.mdx | 134 ++++ 4 files changed, 999 insertions(+) create mode 100644 src/plugins/remark-file-trees/plugin.ts create mode 100644 src/plugins/remark-file-trees/tests/plugin.test.ts create mode 100644 tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx diff --git a/src/plugins/remark-file-trees/plugin.ts b/src/plugins/remark-file-trees/plugin.ts new file mode 100644 index 000000000..0c9c6bef7 --- /dev/null +++ b/src/plugins/remark-file-trees/plugin.ts @@ -0,0 +1,184 @@ +import type { Expression, ObjectExpression, Property } from 'estree'; +import type { List, ListItem, Root } from 'mdast'; +import type { MdxjsEsm, MdxJsxFlowElement } from 'mdast-util-mdx'; +import type { Plugin, Transformer } from 'unified'; +import { SKIP, visit } from 'unist-util-visit'; +import type { VFile } from 'vfile'; +import type { DirType } from '../../components/FileSystem/Dir'; +import { toJsxAttribute, toMdxJsxExpressionAttribute, transformAttributes } from '../helpers'; + +const DIR_SOURCE = '@tdev-components/FileSystem/Dir'; + +const literal = (value: string): Expression => ({ type: 'Literal', value, raw: JSON.stringify(value) }); + +const property = (name: string, value: Expression): Property => ({ + type: 'Property', + key: { type: 'Identifier', name }, + value, + kind: 'init', + method: false, + shorthand: false, + computed: false +}); + +// The shared object attribute helper handles flat objects. Dir needs recursive expressions. +const dirExpression = (dir: DirType): ObjectExpression => ({ + type: 'ObjectExpression', + properties: [ + property('name', literal(dir.name)), + property('children', { + type: 'ArrayExpression', + elements: dir.children.map((child) => + typeof child === 'string' ? literal(child) : dirExpression(child) + ) + }) + ] +}); + +const listItem = (item: ListItem, file: VFile): DirType | string => { + const [label, children, ...extra] = item.children; + if ( + item.checked != null || + label?.type !== 'paragraph' || + (children && children.type !== 'list') || + extra.length > 0 + ) { + file.fail( + 'Tree items must contain a name and at most one nested list.', + item, + 'remark-file-trees:invalid-tree' + ); + } + const name = label.children + .map((child) => { + if (child.type !== 'text' && child.type !== 'inlineCode') { + file.fail( + 'Tree names support only plain text and inline code.', + child, + 'remark-file-trees:invalid-tree' + ); + } + return child.value; + }) + .join(''); + if (!name.trim()) { + file.fail('Tree items must have a nonempty name.', item, 'remark-file-trees:invalid-tree'); + } + return children ? { name, children: listChildren(children as List, file) } : name; +}; + +const listChildren = (list: List, file: VFile): DirType['children'] => + list.children.map((item) => listItem(item, file)); + +// Keep the source and ESTree import in sync, as in remark-mdi. +const IMPORT_DIR_NODE: MdxjsEsm = { + type: 'mdxjsEsm', + value: `import Dir from '${DIR_SOURCE}';`, + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + attributes: [], + specifiers: [ + { type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name: 'Dir' } } + ], + source: { type: 'Literal', value: DIR_SOURCE, raw: `'${DIR_SOURCE}'` } + } + ] + } + } +}; + +const plugin: Plugin<[], Root> = function plugin(): Transformer { + return (root, file) => { + let hasTree = false; + visit(root, 'containerDirective', (node, index, parent) => { + if (node.name !== 'tree' || !parent || index === undefined) { + return; + } + if (node.children.length !== 1 || node.children[0].type !== 'list') { + file.fail( + 'A tree directive must contain exactly one Markdown list.', + node, + 'remark-file-trees:invalid-tree' + ); + } + const children = listChildren(node.children[0] as List, file); + if (children.length === 0) { + file.fail( + 'A tree list must contain at least one item.', + node, + 'remark-file-trees:invalid-tree' + ); + } + const first = children[0]; + const dir: DirType = + children.length === 1 + ? typeof first === 'string' + ? { name: first, children: [] } + : first + : { name: '/', children }; + const attributes = transformAttributes(node.attributes, {}).attributes; + const open = attributes.open ?? true; + if (typeof open !== 'boolean') { + file.fail( + 'The tree open attribute must be true or false.', + node, + 'remark-file-trees:invalid-tree' + ); + } + const tree: MdxJsxFlowElement = { + type: 'mdxJsxFlowElement', + name: 'Dir', + attributes: [ + toMdxJsxExpressionAttribute('dir', dir, dirExpression(dir)), + toJsxAttribute('open', open) + ], + children: [] + }; + if (attributes.maxHeight !== undefined) { + const maxHeight = attributes.maxHeight; + if ( + (typeof maxHeight !== 'string' && maxHeight !== 0) || + (typeof maxHeight === 'string' && !maxHeight.trim()) + ) { + file.fail( + 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).', + node, + 'remark-file-trees:invalid-tree' + ); + } + tree.attributes.push(toJsxAttribute('maxHeight', String(maxHeight))); + } + hasTree = true; + parent.children.splice(index, 1, tree); + return SKIP; + }); + if (hasTree) { + let includesDir = false; + visit(root, 'mdxjsEsm', (node) => { + includesDir ||= !!node.data?.estree?.body.some( + (statement) => + statement.type === 'ImportDeclaration' && + statement.source.value === DIR_SOURCE && + statement.specifiers.some( + (specifier) => + specifier.local.name === 'Dir' && + (specifier.type === 'ImportDefaultSpecifier' || + (specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'default')) + ) + ); + }); + if (!includesDir) { + root.children.unshift(IMPORT_DIR_NODE); + } + } + }; +}; + +export default plugin; diff --git a/src/plugins/remark-file-trees/tests/plugin.test.ts b/src/plugins/remark-file-trees/tests/plugin.test.ts new file mode 100644 index 000000000..d1c6bc22f --- /dev/null +++ b/src/plugins/remark-file-trees/tests/plugin.test.ts @@ -0,0 +1,677 @@ +import type { Root } from 'mdast'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx'; +import { format } from 'prettier'; +import { remark } from 'remark'; +import remarkDirective from 'remark-directive'; +import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; +import { visit } from 'unist-util-visit'; +import { VFile } from 'vfile'; +import { describe, expect, it } from 'vitest'; +import plugin from '../plugin'; + +const DIR_SOURCE = '@tdev-components/FileSystem/Dir'; +const alignLeft = (content: string) => { + const lines = content.split('\n'); + // Remove shared indentation while preserving the nesting of Markdown lists. + const indent = Math.min( + ...lines.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length) + ); + return lines.map((line) => line.slice(indent)).join('\n'); +}; + +// Format generated JSX for readable snapshots; preserve raw output when testing other directives or code. +const formatMdx = (content: string) => + format(content, { + parser: 'mdx', + tabWidth: 4, + printWidth: 110, + singleQuote: true, + trailingComma: 'none' + }); +const transform = async (content: string, pretty = true) => { + const file = new VFile({ value: content, path: '/docs/tree.mdx' }); + const result = await remark() + .use(remarkMdx) + .use(remarkDirective) + .use(remarkGfm) + .use(plugin) + .process(file); + return pretty ? formatMdx(String(result.value)) : String(result.value); +}; +const parseMdx = (content: string) => remark().use(remarkMdx).parse(content); +const dirImports = (root: Root) => + root.children + .filter((node) => node.type === 'mdxjsEsm') + .flatMap((node) => node.data?.estree?.body || []) + .filter((node) => node.type === 'ImportDeclaration' && node.source.value === DIR_SOURCE); +const treeNodes = (root: Root) => { + const nodes: MdxJsxFlowElement[] = []; + visit(root, 'mdxJsxFlowElement', (node) => { + if ( + node.attributes.some( + (attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'dir' + ) + ) { + nodes.push(node); + } + }); + return nodes; +}; + +// Extract the live gallery JSX and its import so the snapshot is a complete, valid MDX document. +const galleryTrees = (content: string) => { + const root = parseMdx(content); + const imports = root.children.filter( + (node) => node.type === 'mdxjsEsm' && dirImports({ type: 'root', children: [node] }).length > 0 + ); + return formatMdx( + remark() + .use(remarkMdx) + .stringify({ type: 'root', children: [...imports, ...treeNodes(root)] }) + ); +}; + +describe('remark-file-trees', () => { + it('uses a single named root with nested directories and files', async () => { + const input = alignLeft(` + :::tree + - src + - components + - Button.tsx + - index.ts + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('uses a lone item as an empty root, even if its name has a file extension', async () => { + const input = alignLeft(` + :::tree + - src + ::: + + :::tree + - README.md + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + + + " + `); + }); + + it('wraps multiple top-level items in / and preserves every sibling order', async () => { + const input = alignLeft(` + :::tree + - src + - components + - foo.docx + - bar.js + - utils + - helper.ts + - dir + - index.ts + - public + - index.html + - favicon.ico + - README.md + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('uses nested lists, rather than extensions or trailing slashes, to identify directories', async () => { + const input = alignLeft(` + :::tree + - root + - folder/ + - no-extension + - archive.zip + - README.md + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('supports ordered lists, inline code and escaped characters without changing names', async () => { + const input = alignLeft(` + :::tree + 1. \`project & notes\` + 1. \`a "quote".txt\` + 2. \\*literal\\*.md + 3. 日本語.txt + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('inserts one import for multiple directives, including trees nested in JSX', async () => { + const input = alignLeft(` + :::tree + - src + ::: + +
+ + :::tree + - public + ::: + +
+ `); + const output = await transform(input); + const root = parseMdx(output); + expect(dirImports(root)).toHaveLength(1); + expect(treeNodes(root)).toHaveLength(2); + expect(output).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + +
+ +
+ " + `); + }); + + it('reuses an existing Dir import even when it follows the directive', async () => { + const input = alignLeft(` + :::tree + - src + ::: + + import Dir from "${DIR_SOURCE}"; + `); + const output = await transform(input); + expect(dirImports(parseMdx(output))).toHaveLength(1); + expect(output).toMatchInlineSnapshot(` + " + + import Dir from '@tdev-components/FileSystem/Dir'; + " + `); + }); + + it('reuses an explicit named default import of Dir', async () => { + const input = alignLeft(` + import { default as Dir } from '${DIR_SOURCE}'; + + :::tree + - src + ::: + `); + const output = await transform(input); + expect(dirImports(parseMdx(output))).toHaveLength(1); + expect(output).toMatchInlineSnapshot(` + "import { default as Dir } from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('defaults open to true and converts explicit boolean values and the bare attribute', async () => { + const input = alignLeft(` + :::tree + - src + ::: + + :::tree{open} + - src + ::: + + :::tree{open="true"} + - src + ::: + + :::tree{open="false"} + - src + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + + + + + + + " + `); + }); + + it('preserves CSS heights as strings, converts zero, and omits maxHeight when absent', async () => { + const input = alignLeft(` + :::tree + - src + ::: + + :::tree{open="false" maxHeight="240px"} + - src + ::: + + :::tree{open="false" maxHeight="20rem"} + - src + ::: + + :::tree{open="false" maxHeight="50vh"} + - src + ::: + + :::tree{open="false" maxHeight="100%"} + - src + ::: + + :::tree{open="false" maxHeight="none"} + - src + ::: + + :::tree{open="false" maxHeight="calc(100vh - 2rem)"} + - src + ::: + + :::tree{open="false" maxHeight="var(--tree-height)"} + - src + ::: + + :::tree{open="false" maxHeight="0"} + - src + ::: + `); + const output = await transform(input); + const nodes = treeNodes(parseMdx(output)); + expect( + nodes[0].attributes.some( + (attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'maxHeight' + ) + ).toBe(false); + expect( + nodes + .slice(1) + .every((node) => + node.attributes.some( + (attribute) => + attribute.type === 'mdxJsxAttribute' && + attribute.name === 'maxHeight' && + typeof attribute.value === 'string' + ) + ) + ).toBe(true); + expect(output).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + + + + + + + + + + + + + + + + + " + `); + }); + + const invalidList = 'A tree directive must contain exactly one Markdown list.'; + const invalidItem = 'Tree items must contain a name and at most one nested list.'; + const invalidName = 'Tree names support only plain text and inline code.'; + const invalidOpen = 'The tree open attribute must be true or false.'; + const invalidHeight = + 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).'; + it.each([ + [ + 'empty directive', + ` + :::tree + ::: + `, + invalidList + ], + [ + 'non-list content', + ` + :::tree + Not a list + ::: + `, + invalidList + ], + [ + 'extra content', + ` + :::tree + - src + + Extra paragraph + ::: + `, + invalidList + ], + [ + 'multiple lists', + ` + :::tree + - src + + 1. public + ::: + `, + invalidList + ], + [ + 'directive label', + ` + :::tree[Title] + - src + ::: + `, + invalidList + ], + [ + 'extra paragraph', + ` + :::tree + - src + + Description + ::: + `, + invalidItem + ], + [ + 'code block', + ` + :::tree + - src + + \`\`\`js + code + \`\`\` + ::: + `, + invalidItem + ], + [ + 'blockquote', + ` + :::tree + - src + > quote + ::: + `, + invalidItem + ], + [ + 'task list', + ` + :::tree + - [ ] src + ::: + `, + invalidItem + ], + [ + 'emphasis', + ` + :::tree + - **src** + ::: + `, + invalidName + ], + [ + 'link', + ` + :::tree + - [src](./src) + ::: + `, + invalidName + ], + [ + 'image', + ` + :::tree + - ![src](./src.png) + ::: + `, + invalidName + ], + [ + 'JSX', + ` + :::tree + - src + ::: + `, + invalidName + ], + [ + 'MDX expression', + ` + :::tree + - {name} + ::: + `, + invalidItem + ], + [ + 'hard break', + ` + :::tree + - src\\ + name + ::: + `, + invalidName + ], + [ + 'empty name', + ` + :::tree + - + ::: + `, + invalidItem + ], + [ + 'invalid open', + ` + :::tree{open="yes"} + - src + ::: + `, + invalidOpen + ], + [ + 'numeric open', + ` + :::tree{open="1"} + - src + ::: + `, + invalidOpen + ], + [ + 'unitless height', + ` + :::tree{maxHeight="240"} + - src + ::: + `, + invalidHeight + ], + [ + 'empty height', + ` + :::tree{maxHeight} + - src + ::: + `, + invalidHeight + ], + [ + 'boolean height', + ` + :::tree{maxHeight="false"} + - src + ::: + `, + invalidHeight + ] + ])('rejects %s with a source location', async (_name, input, reason) => { + await expect(transform(alignLeft(input))).rejects.toMatchObject({ + source: 'remark-file-trees', + file: '/docs/tree.mdx', + reason, + line: expect.any(Number) + }); + }); + + it('ignores unrecognized tree attributes', async () => { + const input = alignLeft(` + :::tree{.custom #example other="value"} + - src + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + " + `); + }); + + it('preserves unrelated Markdown, directives and code, without inserting an unused import', async () => { + const input = alignLeft(` + # Heading + + Paragraph with **bold** text. + + :::other + - item + ::: + + ::tree[label] + + :tree[label] + + \`\`\`md + :::tree + - src + ::: + \`\`\` + `); + const output = await transform(input, false); + expect(dirImports(parseMdx(output))).toHaveLength(0); + expect(output).toMatchInlineSnapshot(` + "# Heading + + Paragraph with **bold** text. + + :::other + * item + ::: + + ::tree[label] + + :tree[label] + + \`\`\`md + :::tree + - src + ::: + \`\`\` + " + `); + }); + + it('preserves content surrounding a transformed tree and other container directives', async () => { + const input = alignLeft(` + # Heading + + Before. + + :::tree + - src + ::: + + :::other + After. + ::: + `); + expect(await transform(input, false)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + # Heading + + Before. + + + + :::other + After. + ::: + " + `); + }); +}); diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index 653a5222f..9f11963a8 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -16,6 +16,7 @@ import commentPlugin from '../plugins/remark-comments/plugin'; import deflistPlugin from '../plugins/remark-deflist/plugin'; import detailsPlugin from '../plugins/remark-details/plugin'; import enumerateAnswersPlugin from '../plugins/remark-enumerate-components/plugin'; +import fileTreesPlugin from '../plugins/remark-file-trees/plugin'; import flexCardsPlugin from '../plugins/remark-flex-cards/plugin'; import imagePlugin, { CaptionVisitor } from '../plugins/remark-images/plugin'; import kbdPlugin from '../plugins/remark-kbd/plugin'; @@ -145,6 +146,8 @@ export const imagePluginConfig = [ export const detailsPluginConfig = detailsPlugin; +export const fileTreesPluginConfig = fileTreesPlugin; + export const defboxPluginConfig = defboxPlugin; export const strongPluginConfig = [strongPlugin, { className: 'boxed' }]; @@ -306,6 +309,7 @@ export const transformAssessablePluginConfig = [ export const rehypeKatexPluginConfig = rehypeKatex; export const recommendedBeforeDefaultRemarkPlugins = [ + fileTreesPluginConfig, graphvizPluginConfig, deflistPluginConfig, flexCardsPluginConfig, diff --git a/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx new file mode 100644 index 000000000..75d12b050 --- /dev/null +++ b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx @@ -0,0 +1,134 @@ +--- +page_id: 4f7ac221-f971-4f68-a558-476706bd8fc9 +tags: [remark] +--- +import CodeShowcase from '@tdev-components/CodeShowcase'; + +# File Trees + +Mit `remark-file-trees` lässt sich eine Verzeichnisstruktur als verschachtelte Markdown-Liste innerhalb von `:::tree` beschreiben. Das Plugin erzeugt daraus die bestehende `Dir`-Komponente mit derselben Darstellung und denselben Interaktionen wie bei der manuellen Verwendung. Verzeichnisse lassen sich durch Anklicken öffnen und schliessen. + +## Syntax und Wurzelverzeichnis + +Jeder Baum hat genau **ein Wurzelverzeichnis**. Bei einem einzigen Eintrag auf der obersten Ebene wird dessen Name zur Wurzel und seine Unterliste zu deren Inhalt: + + +:::tree +- src + - components + - Button.tsx + - index.ts +::: + + +Ein einzelner Eintrag ohne Unterliste wird zu einem leeren Wurzelverzeichnis: + + +:::tree +- src +::: + + +Bei mehreren Einträgen auf der obersten Ebene ergänzt das Plugin ein Wurzelverzeichnis namens `/`: + + +:::tree +- src + - components + - foo.docx + - bar.js + - utils + - helper.ts + - dir + - index.ts +- public + - index.html + - favicon.ico +- README.md +::: + + +Innerhalb der Wurzel ist ein Eintrag mit Unterliste ein Verzeichnis, ein Eintrag ohne Unterliste eine Datei. Dateiendungen und abschliessende `/` ändern diese Regel nicht. Namen, Verschachtelung und Reihenfolge bleiben erhalten; es wird nichts sortiert. + +Geordnete und ungeordnete Listen sind möglich. Jeder Eintrag enthält einen Absatz mit seinem Namen und optional eine Unterliste. Namen dürfen normalen Text und Inline-Code enthalten. Inline-Code eignet sich beispielsweise für Zeichen, die Markdown sonst als Formatierung interpretieren würde: + + +:::tree +- project + - `*notes*.md` + - `data & images` + - `a "quote".txt` +::: + + +## Attribute + +Die Attribute stehen direkt hinter `tree`, beispielsweise `:::tree{open="false" maxHeight="12rem"}`. + +| Attribut | Standard | Verhalten | +| --- | --- | --- | +| `open` | `true` | Öffnet anfangs alle Verzeichnisse. `open="false"` schliesst sie anfangs; sie bleiben durch Anklicken aufklappbar. `open="true"` und das kurze `{open}` setzen ausdrücklich `true`. | +| `maxHeight` | Nicht gesetzt | Begrenzt die Höhe des gesamten Baums. Ohne Attribut gilt das bestehende Verhalten von `Dir`: keine zusätzliche Höhenbegrenzung. Bei Überlauf wird der Baum scrollbar. | + +`maxHeight` wird als CSS-Text an die Wurzelkomponente übergeben. Unterstützt werden beispielsweise `240px`, `20rem`, `50vh`, `100%`, `none`, `calc(100vh - 2rem)` und `var(--tree-height)`. Prozentwerte benötigen eine passende Höhe des übergeordneten Elements; CSS-Variablen müssen definiert sein. `maxHeight="0"` wird nach der Attributkonvertierung wieder zur Zeichenfolge `"0"`. Andere Zahlen ohne Einheit wie `maxHeight="240"`, leere Werte und boolesche Werte führen zu einem Fehler. Die Gültigkeit anderer CSS-Ausdrücke prüft der Browser. + +Dieses Beispiel setzt beide Attribute. Öffne das Wurzelverzeichnis, um seinen Inhalt zu sehen: + + +:::tree{open="false" maxHeight="12rem"} +- project + - src + - components + - Button.tsx + - Header.tsx + - Footer.tsx + - utils + - helper.ts + - index.ts + - public + - index.html + - favicon.ico + - README.md + - package.json +::: + + +## Automatischer Import + +Das Plugin fügt bei Bedarf diesen Import hinzu: + +```tsx +import Dir from '@tdev-components/FileSystem/Dir'; +``` + +Mehrere Bäume auf derselben Seite teilen sich den Import. Ist `Dir` bereits ausdrücklich aus diesem Modul importiert, wird dieser Import wiederverwendet. Auch `import { default as Dir } from '@tdev-components/FileSystem/Dir';` wird erkannt. + +Für einen Baum sind weder manuelles JSX noch eigene `dir`-Props oder Komponentenimports nötig. + +## Grenzen und Fehler + +Ein `:::tree`-Block muss genau eine nichtleere Markdown-Liste enthalten. Leere Blöcke, zusätzlicher Text, mehrere separate Listen und Directive-Labels wie `:::tree[Titel]` führen zu einem Kompilierungsfehler mit der betroffenen Quellposition. + +Auch leere Namen, zusätzliche Absätze oder Blöcke innerhalb eines Eintrags, Aufgabenlisten, Links, Bilder, Hervorhebungen, explizite Zeilenumbrüche, JSX und MDX-Ausdrücke in Namen werden abgewiesen. Für solche Zeichen im Dateinamen eignet sich Inline-Code. Ein leeres Unterverzeichnis lässt sich innerhalb der Wurzel mit dieser Listensyntax nicht ausdrücken: Ein Eintrag ohne Unterliste ist dort immer eine Datei. + +`open` akzeptiert nur boolesche Werte; Werte wie `open="yes"` oder `open="1"` führen zu einem Fehler. Weitere Directive-Attribute werden ignoriert. Andere Markdown-Inhalte und Directives bleiben unverändert. + +## Installation + +:::info[Code] +- `src/plugins/remark-file-trees` +- `src/components/FileSystem/Dir.tsx` +::: + +Das Plugin ist bereits in `recommendedBeforeDefaultRemarkPlugins` unter `src/siteConfig/markdownPluginConfigs.ts` registriert. Bei einer eigenen Plugin-Konfiguration gehört es vor die Standard-Remark-Plugins von Docusaurus. Die bestehende Directive-Parsing-Pipeline wird weiterverwendet: + +:::info[`docusaurus.config.ts`] +```ts +import fileTreesPlugin from './src/plugins/remark-file-trees/plugin'; + +const BEFORE_DEFAULT_REMARK_PLUGINS = [ + fileTreesPlugin + /* weitere Plugins */ +]; +``` +::: From 7e5c7d30a56700ac0546f3774a1a635e0e3a77f7 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 13 Oct 2026 13:44:20 +0200 Subject: [PATCH 2/4] feat: update remark-file-trees to support openLevel and noSelect attributes --- src/plugins/remark-file-trees/plugin.ts | 21 +- .../remark-file-trees/tests/plugin.test.ts | 252 +++++++++++++----- .../markdown/remark-file-trees/index.mdx | 37 ++- 3 files changed, 221 insertions(+), 89 deletions(-) diff --git a/src/plugins/remark-file-trees/plugin.ts b/src/plugins/remark-file-trees/plugin.ts index 0c9c6bef7..eda83f659 100644 --- a/src/plugins/remark-file-trees/plugin.ts +++ b/src/plugins/remark-file-trees/plugin.ts @@ -122,10 +122,13 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { : first : { name: '/', children }; const attributes = transformAttributes(node.attributes, {}).attributes; - const open = attributes.open ?? true; - if (typeof open !== 'boolean') { + const openLevel = attributes.openLevel; + if ( + openLevel !== undefined && + (typeof openLevel !== 'number' || !Number.isInteger(openLevel) || openLevel < 0) + ) { file.fail( - 'The tree open attribute must be true or false.', + 'The tree openLevel attribute must be a nonnegative integer.', node, 'remark-file-trees:invalid-tree' ); @@ -135,10 +138,20 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { name: 'Dir', attributes: [ toMdxJsxExpressionAttribute('dir', dir, dirExpression(dir)), - toJsxAttribute('open', open) + toJsxAttribute('open', openLevel ?? true) ], children: [] }; + if (attributes.noSelect !== undefined) { + if (typeof attributes.noSelect !== 'boolean') { + file.fail( + 'The tree noSelect attribute must be true or false.', + node, + 'remark-file-trees:invalid-tree' + ); + } + tree.attributes.push(toJsxAttribute('noSelect', attributes.noSelect)); + } if (attributes.maxHeight !== undefined) { const maxHeight = attributes.maxHeight; if ( diff --git a/src/plugins/remark-file-trees/tests/plugin.test.ts b/src/plugins/remark-file-trees/tests/plugin.test.ts index d1c6bc22f..520197e01 100644 --- a/src/plugins/remark-file-trees/tests/plugin.test.ts +++ b/src/plugins/remark-file-trees/tests/plugin.test.ts @@ -59,19 +59,6 @@ const treeNodes = (root: Root) => { return nodes; }; -// Extract the live gallery JSX and its import so the snapshot is a complete, valid MDX document. -const galleryTrees = (content: string) => { - const root = parseMdx(content); - const imports = root.children.filter( - (node) => node.type === 'mdxjsEsm' && dirImports({ type: 'root', children: [node] }).length > 0 - ); - return formatMdx( - remark() - .use(remarkMdx) - .stringify({ type: 'root', children: [...imports, ...treeNodes(root)] }) - ); -}; - describe('remark-file-trees', () => { it('uses a single named root with nested directories and files', async () => { const input = alignLeft(` @@ -258,21 +245,25 @@ describe('remark-file-trees', () => { `); }); - it('defaults open to true and converts explicit boolean values and the bare attribute', async () => { + it('opens all directories by default and converts openLevel to a number', async () => { const input = alignLeft(` :::tree - src ::: - :::tree{open} + :::tree{openLevel=0} - src ::: - :::tree{open="true"} + :::tree{openLevel="1"} - src ::: - :::tree{open="false"} + :::tree{openLevel=2} + - src + ::: + + :::tree{openLevel="2"} - src ::: `); @@ -281,100 +272,161 @@ describe('remark-file-trees', () => { - + - + + + - + " `); }); - it('preserves CSS heights as strings, converts zero, and omits maxHeight when absent', async () => { + it('passes the opening depth to a named root with several nested directory levels', async () => { const input = alignLeft(` - :::tree - - src - ::: - - :::tree{open="false" maxHeight="240px"} - - src + :::tree{openLevel=2} + - project + - src + - components + - icons + - Folder.tsx + - index.ts + - README.md ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; - :::tree{open="false" maxHeight="20rem"} - - src - ::: + + " + `); + }); - :::tree{open="false" maxHeight="50vh"} + it('counts the generated / root as the first opening level', async () => { + const input = alignLeft(` + :::tree{openLevel=2} - src + - components + - icons + - Folder.tsx + - index.ts + - public + - images + - logo.svg + - index.html ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; - :::tree{open="false" maxHeight="100%"} - - src - ::: + + " + `); + }); - :::tree{open="false" maxHeight="none"} + it('converts bare and explicit noSelect values to booleans and omits the prop by default', async () => { + const input = alignLeft(` + :::tree - src ::: - :::tree{open="false" maxHeight="calc(100vh - 2rem)"} + :::tree{noSelect} - src ::: - :::tree{open="false" maxHeight="var(--tree-height)"} + :::tree{noSelect="true"} - src ::: - :::tree{open="false" maxHeight="0"} + :::tree{noSelect="false"} - src ::: `); const output = await transform(input); - const nodes = treeNodes(parseMdx(output)); expect( - nodes[0].attributes.some( - (attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'maxHeight' + treeNodes(parseMdx(output))[0].attributes.some( + (attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'noSelect' ) ).toBe(false); - expect( - nodes - .slice(1) - .every((node) => - node.attributes.some( - (attribute) => - attribute.type === 'mdxJsxAttribute' && - attribute.name === 'maxHeight' && - typeof attribute.value === 'string' - ) - ) - ).toBe(true); expect(output).toMatchInlineSnapshot(` "import Dir from '@tdev-components/FileSystem/Dir'; - + - + - - - - - - - + + " + `); + }); - + it('combines openLevel, noSelect and maxHeight', async () => { + const input = alignLeft(` + :::tree{openLevel=2 noSelect maxHeight="12rem"} + - project + - src + - components + - Button.tsx + - index.ts + - README.md + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; - + " `); }); - const invalidList = 'A tree directive must contain exactly one Markdown list.'; const invalidItem = 'Tree items must contain a name and at most one nested list.'; const invalidName = 'Tree names support only plain text and inline code.'; - const invalidOpen = 'The tree open attribute must be true or false.'; + const invalidOpenLevel = 'The tree openLevel attribute must be a nonnegative integer.'; + const invalidNoSelect = 'The tree noSelect attribute must be true or false.'; const invalidHeight = 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).'; it.each([ @@ -534,22 +586,76 @@ describe('remark-file-trees', () => { invalidItem ], [ - 'invalid open', + 'nonnumeric opening level', + ` + :::tree{openLevel="yes"} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'bare opening level', + ` + :::tree{openLevel} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'boolean opening level', + ` + :::tree{openLevel="false"} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'negative opening level', + ` + :::tree{openLevel=-1} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'fractional opening level', + ` + :::tree{openLevel=1.5} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'infinite opening level', + ` + :::tree{openLevel="Infinity"} + - src + ::: + `, + invalidOpenLevel + ], + [ + 'invalid noSelect', ` - :::tree{open="yes"} + :::tree{noSelect="yes"} - src ::: `, - invalidOpen + invalidNoSelect ], [ - 'numeric open', + 'numeric noSelect', ` - :::tree{open="1"} + :::tree{noSelect="1"} - src ::: `, - invalidOpen + invalidNoSelect ], [ 'unitless height', @@ -589,7 +695,7 @@ describe('remark-file-trees', () => { it('ignores unrecognized tree attributes', async () => { const input = alignLeft(` - :::tree{.custom #example other="value"} + :::tree{.custom #example other="value" open="false"} - src ::: `); diff --git a/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx index 75d12b050..eb9270647 100644 --- a/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx +++ b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx @@ -63,19 +63,22 @@ Geordnete und ungeordnete Listen sind möglich. Jeder Eintrag enthält einen Abs ## Attribute -Die Attribute stehen direkt hinter `tree`, beispielsweise `:::tree{open="false" maxHeight="12rem"}`. +Die Attribute stehen direkt hinter `tree`, beispielsweise `:::tree{openLevel=2 noSelect maxHeight="12rem"}`. | Attribut | Standard | Verhalten | | --- | --- | --- | -| `open` | `true` | Öffnet anfangs alle Verzeichnisse. `open="false"` schliesst sie anfangs; sie bleiben durch Anklicken aufklappbar. `open="true"` und das kurze `{open}` setzen ausdrücklich `true`. | +| `openLevel` | Nicht gesetzt: alle Ebenen offen | Gibt die Anzahl der anfangs geöffneten Verzeichnisebenen an. `0` schliesst die Wurzel, `1` öffnet nur die Wurzel, `2` öffnet zusätzlich deren direkte Unterverzeichnisse. | +| `noSelect` | `false` | Verhindert die Auswahl und Hervorhebung von Dateien und Verzeichnissen. Verzeichnisse lassen sich weiterhin durch Anklicken öffnen und schliessen. | | `maxHeight` | Nicht gesetzt | Begrenzt die Höhe des gesamten Baums. Ohne Attribut gilt das bestehende Verhalten von `Dir`: keine zusätzliche Höhenbegrenzung. Bei Überlauf wird der Baum scrollbar. | `maxHeight` wird als CSS-Text an die Wurzelkomponente übergeben. Unterstützt werden beispielsweise `240px`, `20rem`, `50vh`, `100%`, `none`, `calc(100vh - 2rem)` und `var(--tree-height)`. Prozentwerte benötigen eine passende Höhe des übergeordneten Elements; CSS-Variablen müssen definiert sein. `maxHeight="0"` wird nach der Attributkonvertierung wieder zur Zeichenfolge `"0"`. Andere Zahlen ohne Einheit wie `maxHeight="240"`, leere Werte und boolesche Werte führen zu einem Fehler. Die Gültigkeit anderer CSS-Ausdrücke prüft der Browser. -Dieses Beispiel setzt beide Attribute. Öffne das Wurzelverzeichnis, um seinen Inhalt zu sehen: +Die Wurzel zählt immer als **erste Ebene**, auch ein automatisch erzeugtes `/`. `openLevel=2` und `openLevel="2"` sind gleichwertig: Das Plugin übergibt der bestehenden Komponente `open={2}`. Ohne `openLevel` übergibt es `open`, sodass alle Verzeichnisse anfangs offen sind. Die Verschachtelung der Verzeichnisdaten bleibt gleich. + +Dieses Beispiel öffnet das Wurzelverzeichnis `project` und seine direkten Unterverzeichnisse `src` und `public`. `components` und `utils` starten geschlossen. Die Auswahl ist deaktiviert, und die Höhe ist begrenzt: -:::tree{open="false" maxHeight="12rem"} +:::tree{openLevel=2 noSelect maxHeight="12rem"} - project - src - components @@ -93,6 +96,24 @@ Dieses Beispiel setzt beide Attribute. Öffne das Wurzelverzeichnis, um seinen I ::: +Bei mehreren Einträgen zählt das ergänzte `/` mit: Hier sind `/`, `src` und `public` anfangs offen; `components` und `images` bleiben zunächst geschlossen. + + +:::tree{openLevel="2"} +- src + - components + - icons + - Folder.tsx + - index.ts +- public + - images + - logo.svg + - index.html +::: + + +Mit `openLevel=0` startet der Baum ganz geschlossen. `{noSelect}` und `noSelect="true"` deaktivieren die Auswahl; `noSelect="false"` erlaubt sie ausdrücklich. Die Zeichenfolge `"false"` wird als boolescher Wert erkannt. + ## Automatischer Import Das Plugin fügt bei Bedarf diesen Import hinzu: @@ -105,14 +126,6 @@ Mehrere Bäume auf derselben Seite teilen sich den Import. Ist `Dir` bereits aus Für einen Baum sind weder manuelles JSX noch eigene `dir`-Props oder Komponentenimports nötig. -## Grenzen und Fehler - -Ein `:::tree`-Block muss genau eine nichtleere Markdown-Liste enthalten. Leere Blöcke, zusätzlicher Text, mehrere separate Listen und Directive-Labels wie `:::tree[Titel]` führen zu einem Kompilierungsfehler mit der betroffenen Quellposition. - -Auch leere Namen, zusätzliche Absätze oder Blöcke innerhalb eines Eintrags, Aufgabenlisten, Links, Bilder, Hervorhebungen, explizite Zeilenumbrüche, JSX und MDX-Ausdrücke in Namen werden abgewiesen. Für solche Zeichen im Dateinamen eignet sich Inline-Code. Ein leeres Unterverzeichnis lässt sich innerhalb der Wurzel mit dieser Listensyntax nicht ausdrücken: Ein Eintrag ohne Unterliste ist dort immer eine Datei. - -`open` akzeptiert nur boolesche Werte; Werte wie `open="yes"` oder `open="1"` führen zu einem Fehler. Weitere Directive-Attribute werden ignoriert. Andere Markdown-Inhalte und Directives bleiben unverändert. - ## Installation :::info[Code] From d2a9a7097632b1693cd6bc8f927596093adcaed9 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 13 Oct 2026 14:24:51 +0200 Subject: [PATCH 3/4] refactor: remove noSelect attribute handling and update documentation --- src/plugins/remark-file-trees/plugin.ts | 10 --- .../remark-file-trees/tests/plugin.test.ts | 62 +------------------ .../markdown/remark-file-trees/index.mdx | 9 ++- 3 files changed, 6 insertions(+), 75 deletions(-) diff --git a/src/plugins/remark-file-trees/plugin.ts b/src/plugins/remark-file-trees/plugin.ts index eda83f659..68a9f5417 100644 --- a/src/plugins/remark-file-trees/plugin.ts +++ b/src/plugins/remark-file-trees/plugin.ts @@ -142,16 +142,6 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { ], children: [] }; - if (attributes.noSelect !== undefined) { - if (typeof attributes.noSelect !== 'boolean') { - file.fail( - 'The tree noSelect attribute must be true or false.', - node, - 'remark-file-trees:invalid-tree' - ); - } - tree.attributes.push(toJsxAttribute('noSelect', attributes.noSelect)); - } if (attributes.maxHeight !== undefined) { const maxHeight = attributes.maxHeight; if ( diff --git a/src/plugins/remark-file-trees/tests/plugin.test.ts b/src/plugins/remark-file-trees/tests/plugin.test.ts index 520197e01..7a2b7e88d 100644 --- a/src/plugins/remark-file-trees/tests/plugin.test.ts +++ b/src/plugins/remark-file-trees/tests/plugin.test.ts @@ -355,47 +355,9 @@ describe('remark-file-trees', () => { `); }); - it('converts bare and explicit noSelect values to booleans and omits the prop by default', async () => { + it('combines openLevel and maxHeight', async () => { const input = alignLeft(` - :::tree - - src - ::: - - :::tree{noSelect} - - src - ::: - - :::tree{noSelect="true"} - - src - ::: - - :::tree{noSelect="false"} - - src - ::: - `); - const output = await transform(input); - expect( - treeNodes(parseMdx(output))[0].attributes.some( - (attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'noSelect' - ) - ).toBe(false); - expect(output).toMatchInlineSnapshot(` - "import Dir from '@tdev-components/FileSystem/Dir'; - - - - - - - - - " - `); - }); - - it('combines openLevel, noSelect and maxHeight', async () => { - const input = alignLeft(` - :::tree{openLevel=2 noSelect maxHeight="12rem"} + :::tree{openLevel=2 maxHeight="12rem"} - project - src - components @@ -416,7 +378,6 @@ describe('remark-file-trees', () => { ] }} open={2} - noSelect maxHeight="12rem" /> " @@ -426,7 +387,6 @@ describe('remark-file-trees', () => { const invalidItem = 'Tree items must contain a name and at most one nested list.'; const invalidName = 'Tree names support only plain text and inline code.'; const invalidOpenLevel = 'The tree openLevel attribute must be a nonnegative integer.'; - const invalidNoSelect = 'The tree noSelect attribute must be true or false.'; const invalidHeight = 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).'; it.each([ @@ -639,24 +599,6 @@ describe('remark-file-trees', () => { `, invalidOpenLevel ], - [ - 'invalid noSelect', - ` - :::tree{noSelect="yes"} - - src - ::: - `, - invalidNoSelect - ], - [ - 'numeric noSelect', - ` - :::tree{noSelect="1"} - - src - ::: - `, - invalidNoSelect - ], [ 'unitless height', ` diff --git a/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx index eb9270647..5db9ad323 100644 --- a/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx +++ b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx @@ -63,22 +63,21 @@ Geordnete und ungeordnete Listen sind möglich. Jeder Eintrag enthält einen Abs ## Attribute -Die Attribute stehen direkt hinter `tree`, beispielsweise `:::tree{openLevel=2 noSelect maxHeight="12rem"}`. +Die Attribute stehen direkt hinter `tree`, beispielsweise `:::tree{openLevel=2 maxHeight="12rem"}`. | Attribut | Standard | Verhalten | | --- | --- | --- | | `openLevel` | Nicht gesetzt: alle Ebenen offen | Gibt die Anzahl der anfangs geöffneten Verzeichnisebenen an. `0` schliesst die Wurzel, `1` öffnet nur die Wurzel, `2` öffnet zusätzlich deren direkte Unterverzeichnisse. | -| `noSelect` | `false` | Verhindert die Auswahl und Hervorhebung von Dateien und Verzeichnissen. Verzeichnisse lassen sich weiterhin durch Anklicken öffnen und schliessen. | | `maxHeight` | Nicht gesetzt | Begrenzt die Höhe des gesamten Baums. Ohne Attribut gilt das bestehende Verhalten von `Dir`: keine zusätzliche Höhenbegrenzung. Bei Überlauf wird der Baum scrollbar. | `maxHeight` wird als CSS-Text an die Wurzelkomponente übergeben. Unterstützt werden beispielsweise `240px`, `20rem`, `50vh`, `100%`, `none`, `calc(100vh - 2rem)` und `var(--tree-height)`. Prozentwerte benötigen eine passende Höhe des übergeordneten Elements; CSS-Variablen müssen definiert sein. `maxHeight="0"` wird nach der Attributkonvertierung wieder zur Zeichenfolge `"0"`. Andere Zahlen ohne Einheit wie `maxHeight="240"`, leere Werte und boolesche Werte führen zu einem Fehler. Die Gültigkeit anderer CSS-Ausdrücke prüft der Browser. Die Wurzel zählt immer als **erste Ebene**, auch ein automatisch erzeugtes `/`. `openLevel=2` und `openLevel="2"` sind gleichwertig: Das Plugin übergibt der bestehenden Komponente `open={2}`. Ohne `openLevel` übergibt es `open`, sodass alle Verzeichnisse anfangs offen sind. Die Verschachtelung der Verzeichnisdaten bleibt gleich. -Dieses Beispiel öffnet das Wurzelverzeichnis `project` und seine direkten Unterverzeichnisse `src` und `public`. `components` und `utils` starten geschlossen. Die Auswahl ist deaktiviert, und die Höhe ist begrenzt: +Dieses Beispiel öffnet das Wurzelverzeichnis `project` und seine direkten Unterverzeichnisse `src` und `public`. `components` und `utils` starten geschlossen. Die Höhe ist begrenzt: -:::tree{openLevel=2 noSelect maxHeight="12rem"} +:::tree{openLevel=2 maxHeight="12rem"} - project - src - components @@ -112,7 +111,7 @@ Bei mehreren Einträgen zählt das ergänzte `/` mit: Hier sind `/`, `src` und ` ::: -Mit `openLevel=0` startet der Baum ganz geschlossen. `{noSelect}` und `noSelect="true"` deaktivieren die Auswahl; `noSelect="false"` erlaubt sie ausdrücklich. Die Zeichenfolge `"false"` wird als boolescher Wert erkannt. +Mit `openLevel=0` startet der Baum ganz geschlossen. ## Automatischer Import From 78ecb471f4985ecc83da447e51d72ec0588de526 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Tue, 13 Oct 2026 14:51:06 +0200 Subject: [PATCH 4/4] feat: refactor import handling and improve error reporting in remark-file-trees plugin --- src/plugins/remark-file-trees/plugin.ts | 34 ++++++++++--------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/plugins/remark-file-trees/plugin.ts b/src/plugins/remark-file-trees/plugin.ts index 68a9f5417..6fe71de36 100644 --- a/src/plugins/remark-file-trees/plugin.ts +++ b/src/plugins/remark-file-trees/plugin.ts @@ -1,5 +1,6 @@ import type { Expression, ObjectExpression, Property } from 'estree'; import type { List, ListItem, Root } from 'mdast'; +import { type ContainerDirective } from 'mdast-util-directive'; import type { MdxjsEsm, MdxJsxFlowElement } from 'mdast-util-mdx'; import type { Plugin, Transformer } from 'unified'; import { SKIP, visit } from 'unist-util-visit'; @@ -21,7 +22,6 @@ const property = (name: string, value: Expression): Property => ({ computed: false }); -// The shared object attribute helper handles flat objects. Dir needs recursive expressions. const dirExpression = (dir: DirType): ObjectExpression => ({ type: 'ObjectExpression', properties: [ @@ -71,7 +71,7 @@ const listChildren = (list: List, file: VFile): DirType['children'] => list.children.map((item) => listItem(item, file)); // Keep the source and ESTree import in sync, as in remark-mdi. -const IMPORT_DIR_NODE: MdxjsEsm = { +const importStatement: () => MdxjsEsm = () => ({ type: 'mdxjsEsm', value: `import Dir from '${DIR_SOURCE}';`, data: { @@ -90,6 +90,10 @@ const IMPORT_DIR_NODE: MdxjsEsm = { ] } } +}); + +const fail = (file: VFile, node: ContainerDirective, message: string) => { + file.fail(message, node, 'remark-file-trees:invalid-tree'); }; const plugin: Plugin<[], Root> = function plugin(): Transformer { @@ -100,19 +104,11 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { return; } if (node.children.length !== 1 || node.children[0].type !== 'list') { - file.fail( - 'A tree directive must contain exactly one Markdown list.', - node, - 'remark-file-trees:invalid-tree' - ); + fail(file, node, 'A tree directive must contain exactly one Markdown list.'); } const children = listChildren(node.children[0] as List, file); if (children.length === 0) { - file.fail( - 'A tree list must contain at least one item.', - node, - 'remark-file-trees:invalid-tree' - ); + fail(file, node, 'A tree list must contain at least one item.'); } const first = children[0]; const dir: DirType = @@ -127,11 +123,7 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { openLevel !== undefined && (typeof openLevel !== 'number' || !Number.isInteger(openLevel) || openLevel < 0) ) { - file.fail( - 'The tree openLevel attribute must be a nonnegative integer.', - node, - 'remark-file-trees:invalid-tree' - ); + fail(file, node, 'The tree openLevel attribute must be a nonnegative integer.'); } const tree: MdxJsxFlowElement = { type: 'mdxJsxFlowElement', @@ -148,10 +140,10 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { (typeof maxHeight !== 'string' && maxHeight !== 0) || (typeof maxHeight === 'string' && !maxHeight.trim()) ) { - file.fail( - 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).', + fail( + file, node, - 'remark-file-trees:invalid-tree' + 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).' ); } tree.attributes.push(toJsxAttribute('maxHeight', String(maxHeight))); @@ -178,7 +170,7 @@ const plugin: Plugin<[], Root> = function plugin(): Transformer { ); }); if (!includesDir) { - root.children.unshift(IMPORT_DIR_NODE); + root.children.unshift(importStatement()); } } };