diff --git a/src/plugins/remark-file-trees/plugin.ts b/src/plugins/remark-file-trees/plugin.ts new file mode 100644 index 000000000..6fe71de36 --- /dev/null +++ b/src/plugins/remark-file-trees/plugin.ts @@ -0,0 +1,179 @@ +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'; +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 +}); + +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 importStatement: () => 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 fail = (file: VFile, node: ContainerDirective, message: string) => { + file.fail(message, node, 'remark-file-trees:invalid-tree'); +}; + +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') { + 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) { + fail(file, node, 'A tree list must contain at least one item.'); + } + 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 openLevel = attributes.openLevel; + if ( + openLevel !== undefined && + (typeof openLevel !== 'number' || !Number.isInteger(openLevel) || openLevel < 0) + ) { + fail(file, node, 'The tree openLevel attribute must be a nonnegative integer.'); + } + const tree: MdxJsxFlowElement = { + type: 'mdxJsxFlowElement', + name: 'Dir', + attributes: [ + toMdxJsxExpressionAttribute('dir', dir, dirExpression(dir)), + toJsxAttribute('open', openLevel ?? true) + ], + children: [] + }; + if (attributes.maxHeight !== undefined) { + const maxHeight = attributes.maxHeight; + if ( + (typeof maxHeight !== 'string' && maxHeight !== 0) || + (typeof maxHeight === 'string' && !maxHeight.trim()) + ) { + fail( + file, + node, + 'The tree maxHeight attribute must be a CSS height (e.g. 240px, 20rem, 50vh, none or 0).' + ); + } + 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(importStatement()); + } + } + }; +}; + +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..7a2b7e88d --- /dev/null +++ b/src/plugins/remark-file-trees/tests/plugin.test.ts @@ -0,0 +1,725 @@ +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; +}; + +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('opens all directories by default and converts openLevel to a number', async () => { + const input = alignLeft(` + :::tree + - src + ::: + + :::tree{openLevel=0} + - src + ::: + + :::tree{openLevel="1"} + - src + ::: + + :::tree{openLevel=2} + - src + ::: + + :::tree{openLevel="2"} + - src + ::: + `); + expect(await transform(input)).toMatchInlineSnapshot(` + "import Dir from '@tdev-components/FileSystem/Dir'; + + + + + + + + + + + " + `); + }); + + it('passes the opening depth to a named root with several nested directory levels', async () => { + const input = alignLeft(` + :::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'; + + + " + `); + }); + + 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'; + + + " + `); + }); + + it('combines openLevel and maxHeight', async () => { + const input = alignLeft(` + :::tree{openLevel=2 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 invalidOpenLevel = 'The tree openLevel attribute must be a nonnegative integer.'; + 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 + ], + [ + '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 + ], + [ + '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" open="false"} + - 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..5db9ad323 --- /dev/null +++ b/tdev-website/docs/gallery/markdown/remark-file-trees/index.mdx @@ -0,0 +1,146 @@ +--- +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{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. | +| `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 Höhe ist begrenzt: + + +:::tree{openLevel=2 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 +::: + + +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. + +## 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. + +## 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 */ +]; +``` +:::