diff --git a/packages/core/package.json b/packages/core/package.json index 2bc65ad..159caa1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,7 +48,7 @@ "@types/hosted-git-info": "^3.0.5", "@types/mdast": "^4.0.4", "@types/node": "^24.0.14", - "@types/unist": "^2.0.0", + "@types/unist": "^3.0.0", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.18.0", diff --git a/packages/core/src/-private/utils.ts b/packages/core/src/-private/utils.ts index a3a9d1a..60bd1fe 100644 --- a/packages/core/src/-private/utils.ts +++ b/packages/core/src/-private/utils.ts @@ -1,6 +1,7 @@ import path from 'path'; import { visit } from 'unist-util-visit'; -import { Node } from 'unist'; +import type { Node } from 'unist'; +import type { Root as MdastRoot } from 'mdast'; import { toString } from 'mdast-util-to-string'; import { slug } from 'github-slugger'; import url from 'url'; @@ -73,11 +74,10 @@ export function generateAutoUrl(source: string, prefix?: string, suffix?: string return clearURL(parts, ignoreSuffix ? '' : suffix || ''); } -export function inferTitle(ast: Node): string | undefined { +export function inferTitle(ast: MdastRoot): string | undefined { let docTitle: string | undefined; visit(ast, 'heading', node => { - const { depth } = node as never; - if (depth !== 1) return; + if (node.depth !== 1) return; docTitle = toString(node); }); return docTitle; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 692ab13..dcf0abe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,7 @@ import { getRepoEditUrl } from './-private/repo-info.js'; import { transformToNestedPageMetadata } from './-private/nested-page-metadata.js'; import debugFactory from 'debug'; import type { Root as MdastRoot } from 'mdast'; +import type { Root as HastRoot } from 'hast'; const debug = debugFactory('@docfy/core'); class Docfy { @@ -87,7 +88,10 @@ class Docfy { reject(err); } else { resolve({ - content: ctx.pages, + // The pipeline holds `Context` because `page.ast` changes + // shape half-way through it. By the time it resolves, + // `transformerMdastToHast` has replaced every tree with hast. + content: ctx.pages as PageContent[], staticAssets: ctx.staticAssets, nestedPageMetadata: transformToNestedPageMetadata( ctx.pages.map(p => p.meta), @@ -101,6 +105,11 @@ class Docfy { }); } + /* + * Turns every page's mdast tree into a hast tree. This is the point where + * `PageContent.ast` switches from `mdast.Root` to `hast.Root`, which is why + * the pipeline is typed with the union of both and narrows here. + */ private transformerMdastToHast(ctx: Context): void { ctx.pages.forEach(page => { page.ast = ctx.rehype.runSync(page.ast as MdastRoot, page.vFile); diff --git a/packages/core/src/plugins/render-markdown.ts b/packages/core/src/plugins/render-markdown.ts index f74613c..994e0aa 100644 --- a/packages/core/src/plugins/render-markdown.ts +++ b/packages/core/src/plugins/render-markdown.ts @@ -1,6 +1,5 @@ import plugin from '../plugin.js'; import stringify from 'rehype-stringify'; -import type { Root as HastRoot } from 'hast'; export default plugin({ runAfter(context): void { @@ -9,9 +8,9 @@ export default plugin({ }); context.pages.forEach(page => { - page.rendered = rehype.stringify(page.ast as HastRoot); + page.rendered = rehype.stringify(page.ast); page.demos?.forEach(demo => { - demo.rendered = rehype.stringify(demo.ast as HastRoot); + demo.rendered = rehype.stringify(demo.ast); }); }); }, diff --git a/packages/core/src/plugins/replace-internal-links.ts b/packages/core/src/plugins/replace-internal-links.ts index 9b656f7..e1baab1 100644 --- a/packages/core/src/plugins/replace-internal-links.ts +++ b/packages/core/src/plugins/replace-internal-links.ts @@ -1,32 +1,15 @@ import path from 'path'; import plugin from '../plugin.js'; -import { Node } from 'unist'; import { visit } from 'unist-util-visit'; import { isValidUrl, isAnchorUrl } from '../-private/utils.js'; import { PageContent, Context } from '../types.js'; +import type { Root as MdastRoot, Definition, Link } from 'mdast'; -interface Resource { - url: string; - title?: string; -} -interface Association { - identifier: string; - label?: string; -} - -interface LinkNode extends Node, Resource { - type: 'link'; -} - -interface LinkReferenceNode extends Node, Association { - type: 'linkReference'; -} - -interface DefinitionNode extends Node, Resource, Association { - type: 'definition'; -} - -function replaceURL(ctx: Context, page: PageContent, node: LinkNode | DefinitionNode): void { +function replaceURL( + ctx: Context, + page: PageContent, + node: Link | Definition +): void { if (isValidUrl(node.url) || isAnchorUrl(node.url)) { return; } @@ -51,25 +34,19 @@ function replaceURL(ctx: Context, page: PageContent, node: LinkNode | Definition } } -function isReferenceLink(node: LinkNode | LinkReferenceNode): node is LinkReferenceNode { - return node.type === 'linkReference'; -} - -function visitor(ctx: Context, page: PageContent): void { - const definitions: Record = {}; +function visitor(ctx: Context, page: PageContent): void { + const definitions: Record = {}; - visit(page.ast, 'definition', (node: DefinitionNode) => { + visit(page.ast, 'definition', node => { definitions[node.identifier] = node; }); - visit(page.ast, ['link', 'linkReference'], visited => { - const node = visited as unknown as LinkNode | LinkReferenceNode; - - if (isReferenceLink(node)) { + visit(page.ast, ['link', 'linkReference'], node => { + if (node.type === 'linkReference') { if (definitions[node.identifier]) { replaceURL(ctx, page, definitions[node.identifier]); } - } else { + } else if (node.type === 'link') { replaceURL(ctx, page, node); } }); diff --git a/packages/core/src/plugins/static-assets.ts b/packages/core/src/plugins/static-assets.ts index 93df0c2..855e45e 100644 --- a/packages/core/src/plugins/static-assets.ts +++ b/packages/core/src/plugins/static-assets.ts @@ -2,33 +2,8 @@ import { visit } from 'unist-util-visit'; import plugin from '../plugin.js'; import { PageContent } from '../types.js'; import { isValidUrl } from '../-private/utils.js'; -import { Node } from 'unist'; import path from 'path'; - -interface Resource { - url: string; - title?: string; -} -interface Association { - identifier: string; - label?: string; -} - -interface ImageReferenceNode extends Node, Association { - type: 'imageReference'; -} - -interface DefinitionNode extends Node, Resource, Association { - type: 'definition'; -} - -interface ImageNode extends Node, Resource { - type: 'image'; -} - -function isImageReference(node: ImageNode | ImageReferenceNode): node is ImageReferenceNode { - return node.type === 'imageReference'; -} +import type { Root as MdastRoot, Definition, Image } from 'mdast'; function generateUniqueFileName(seen: string[], name: string, count?: number): string { if (seen.indexOf(name) == -1) { @@ -56,7 +31,7 @@ export default plugin({ const assets: Record = {}; - function transform(page: PageContent, node: DefinitionNode | ImageNode): void { + function transform(page: PageContent, node: Definition | Image): void { if (!isValidUrl(node.url) && !path.isAbsolute(node.url)) { const absolutePath = path.resolve( path.join(page.sourceConfig.root, path.dirname(page.source)), @@ -79,20 +54,18 @@ export default plugin({ } ctx.pages.forEach(page => { - const definitions: Record = {}; + const definitions: Record = {}; - visit(page.ast, 'definition', (node: DefinitionNode) => { + visit(page.ast, 'definition', node => { definitions[node.identifier] = node; }); - visit(page.ast, ['image', 'imageReference'], visited => { - const node = visited as unknown as ImageNode | ImageReferenceNode; - - if (isImageReference(node)) { + visit(page.ast, ['image', 'imageReference'], node => { + if (node.type === 'imageReference') { if (definitions[node.identifier]) { transform(page, definitions[node.identifier]); } - } else { + } else if (node.type === 'image') { transform(page, node); } }); diff --git a/packages/core/src/plugins/toc.ts b/packages/core/src/plugins/toc.ts index 8b7b6d9..018aaf4 100644 --- a/packages/core/src/plugins/toc.ts +++ b/packages/core/src/plugins/toc.ts @@ -1,22 +1,17 @@ import plugin from '../plugin.js'; import { Heading } from '../types.js'; import { visit } from 'unist-util-visit'; -import { Node, Parent } from 'unist'; import { toString } from 'mdast-util-to-string'; import { deleteNode } from '../-private/utils.js'; - -interface HeadingNode extends Node { - depth: number; - data: { - id: string; - docfyDelete?: boolean; - }; -} +import type { Heading as HeadingNode } from 'mdast'; function getHeading(node: HeadingNode): Heading { return { title: toString(node), - id: node.data.id, + // `mdastSlug` runs on every tree before any plugin does, so `data.id` is + // always set by the time we get here. It is optional in the type because + // it is a Docfy augmentation of mdast's `HeadingData`. + id: node.data?.id as string, depth: node.depth, }; } @@ -38,31 +33,25 @@ function findParentOfDepth(headings: Heading[], depth: number): Heading[] { } } -function isHeading(node: Node): node is HeadingNode { - return node.type === 'heading'; -} - export default plugin({ runWithMdast(ctx): void { ctx.pages.forEach((page): void => { const headings: Heading[] = []; - visit(page.ast, (node: Node, _, parentNode: Parent | undefined) => { - if (isHeading(node)) { - if (node.depth === 1) { - return; - } + visit(page.ast, 'heading', (node, _, parentNode) => { + if (node.depth === 1) { + return; + } - if (node.depth > ctx.options.tocMaxDepth) { - return; - } - const parent = findParentOfDepth(headings, node.depth); + if (node.depth > ctx.options.tocMaxDepth) { + return; + } + const parent = findParentOfDepth(headings, node.depth); - parent.push(getHeading(node)); + parent.push(getHeading(node)); - if (node.data.docfyDelete && parentNode) { - deleteNode(parentNode.children, node); - } + if (node.data?.docfyDelete && parentNode) { + deleteNode(parentNode.children, node); } }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 62022de..4a44ac5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Node as MarkdownAST } from 'unist'; import { Processor, Plugin as UnifiedPlugin, Settings as UnifiedSettings } from 'unified'; import { VFile } from 'vfile'; import type { Root as MdastRoot } from 'mdast'; @@ -16,6 +15,30 @@ export type RemarkProcessor = Processor; +/** + * The tree a page holds. Docfy parses markdown into mdast, then transforms it + * to hast half-way through the pipeline, so which one `PageContent.ast` holds + * depends on when you look at it. See `Plugin` for the per-hook types. + */ +export type PageAST = MdastRoot | HastRoot; + +declare module 'mdast' { + interface HeadingData { + /** + * The slug of the heading, added by Docfy's `mdastSlug` transformer and + * read by the `toc` plugin. + */ + id?: string | undefined; + + /** + * Marks a heading to be removed from the tree by the `toc` plugin once it + * has been collected. The Ember integrations use it for demo titles, which + * belong in the table of contents but are rendered by `DocfyDemo` instead. + */ + docfyDelete?: boolean | undefined; + } +} + export interface Heading { title: string; id: string; @@ -35,15 +58,15 @@ export interface PageMetadata { parentLabel: undefined | string; } -export interface PageContent { +export interface PageContent { meta: PageMetadata; sourceConfig: SourceConfig; source: string; vFile: VFile; - ast: MarkdownAST; + ast: AST; markdown: string; rendered: string; - demos?: PageContent[]; + demos?: PageContent[]; pluginData: Record; } @@ -59,10 +82,10 @@ export interface StaticAssetDefinition { toPath: string; } -export interface Context { +export interface Context { remark: RemarkProcessor; rehype: RehypeProcessor; - pages: PageContent[]; + pages: PageContent[]; staticAssets: StaticAssetDefinition[]; options: ContextOptions; } @@ -75,7 +98,7 @@ export interface NestedPageMetadata { } export interface DocfyResult { - content: PageContent[]; + content: PageContent[]; staticAssets: StaticAssetDefinition[]; nestedPageMetadata: NestedPageMetadata; } @@ -168,16 +191,22 @@ export interface PluginOptions { [key: string]: unknown; } -export type PluginHandler = ( - ctx: Context, +export type PluginHandler = ( + ctx: Context, options: T ) => void; +/** + * Each hook is typed with the tree it actually receives: `runBefore` and + * `runWithMdast` run before Docfy transforms mdast to hast, `runWithHast` and + * `runAfter` run once the tree is hast. This means `page.ast` is a real + * `mdast.Root` or `hast.Root` inside a handler — no narrowing needed. + */ export interface Plugin { - runBefore?: PluginHandler; - runWithMdast?: PluginHandler; - runWithHast?: PluginHandler; - runAfter?: PluginHandler; + runBefore?: PluginHandler; + runWithMdast?: PluginHandler; + runWithHast?: PluginHandler; + runAfter?: PluginHandler; } export interface PluginWithOptions extends Plugin { diff --git a/packages/ember-cli/package.json b/packages/ember-cli/package.json index 9ac30d1..7caf16d 100644 --- a/packages/ember-cli/package.json +++ b/packages/ember-cli/package.json @@ -49,8 +49,6 @@ "ember-cli-htmlbars": "^6.3.0", "mdast-util-to-string": "^4.0.0", "remark-hbs": "^0.4.1", - "unist-builder": "^4.0.0", - "unist-util-find": "^3.0.0", "unist-util-visit": "^5.1.0" }, "devDependencies": { @@ -64,7 +62,7 @@ "@mapbox/rehype-prism": "^0.5.0", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", - "@types/unist": "^2.0.0", + "@types/unist": "^3.0.0", "autoprefixer": "^10.4.21", "broccoli-asset-rev": "^3.0.0", "concurrently": "^9.1.2", diff --git a/packages/ember-cli/src/plugins/escape-curlies-in-code.ts b/packages/ember-cli/src/plugins/escape-curlies-in-code.ts index ae75bb9..99630d5 100644 --- a/packages/ember-cli/src/plugins/escape-curlies-in-code.ts +++ b/packages/ember-cli/src/plugins/escape-curlies-in-code.ts @@ -1,7 +1,6 @@ import plugin from '@docfy/core/lib/plugin.js'; import { visit } from 'unist-util-visit'; -import type { Node } from 'unist'; -import type { Element, Text } from 'hast'; +import type { Root } from 'hast'; import type { PageContent } from '@docfy/core/lib/types.js'; /** @@ -15,13 +14,13 @@ import type { PageContent } from '@docfy/core/lib/types.js'; * bare `{{` into the output. Running at the hast stage (`runWithHast`, which * Docfy invokes after all rehype plugins) escapes the final text instead. */ -function escapeCurliesInCode(ast: Node): void { - visit(ast, 'element', (node: Element) => { +function escapeCurliesInCode(ast: Root): void { + visit(ast, 'element', node => { if (node.tagName !== 'code') { return; } - visit(node, 'text', (textNode: Text) => { + visit(node, 'text', textNode => { textNode.value = textNode.value.replace(/\{\{/g, '\\{{'); }); @@ -32,7 +31,7 @@ function escapeCurliesInCode(ast: Node): void { export default plugin({ runWithHast(ctx): void { - const escape = (page: PageContent): void => { + const escape = (page: PageContent): void => { escapeCurliesInCode(page.ast); page.demos?.forEach(escape); }; diff --git a/packages/ember-cli/src/plugins/extract-demos-to-components.ts b/packages/ember-cli/src/plugins/extract-demos-to-components.ts index e38c5a8..90847df 100644 --- a/packages/ember-cli/src/plugins/extract-demos-to-components.ts +++ b/packages/ember-cli/src/plugins/extract-demos-to-components.ts @@ -1,18 +1,18 @@ import { visit } from 'unist-util-visit'; import { Context, PageContent } from '@docfy/core/lib/types.js'; import plugin from '@docfy/core/lib/plugin.js'; -import { Node, Parent } from 'unist'; -import { find as findNode } from 'unist-util-find'; import { toString } from 'mdast-util-to-string'; -import { DemoComponent, DemoComponentChunk, CodeNode } from './types'; +import { DemoComponent, DemoComponentChunk } from './types'; import { generateDemoComponentName, getExt, createDemoNodes, deleteNode, + findHeading, isDemoComponents, } from './utils'; import path from 'path'; +import type { Heading, Paragraph, Root, RootContent } from 'mdast'; /* * Create the heading for the examples section of the page. @@ -22,85 +22,97 @@ import path from 'path'; * * This is necessary for apps using remark-autolink-headings, for example. */ -function createHeading(ctx: Context): Node { - const heading = (ctx.remark.runSync(ctx.remark.parse('## Examples')).children as Node[])[0]; +function createHeading(ctx: Context): Heading { + // `## Examples` always parses to a single heading. Remark plugins in the + // stack can annotate it, but none of them replace it with another node type. + const heading = ctx.remark.runSync(ctx.remark.parse('## Examples')).children[0] as Heading; heading.depth = 2; return heading; } -const isTextMarker = (node: Parent): boolean => - node.type === 'paragraph' && node.children.length === 1 && node.children[0].type === 'text'; +/* + * Returns the text of a paragraph made up of a single text node, which is the + * shape every demo marker has. + */ +function markerText(node: Paragraph): string { + const child = node.children[0]; + return node.children.length === 1 && child.type === 'text' ? child.value : ''; +} const demoMarkerRegex = /^\[\[demo:(.+?)\]\]$/; -const demoMarker = (node: Parent): boolean => - isTextMarker(node) && demoMarkerRegex.test(node.children[0].value as string); +const demoMarker = (node: Paragraph): boolean => demoMarkerRegex.test(markerText(node)); const demosAllMarkerRegex = /^\[\[demos-all\]\]$/; -const demosAllMarker = (node: Parent): boolean => - isTextMarker(node) && demosAllMarkerRegex.test(node.children[0].value as string); +const demosAllMarker = (node: Paragraph): boolean => demosAllMarkerRegex.test(markerText(node)); + +/* + * Replaces a demo marker paragraph with the given demo nodes. + * + * The paragraph is retyped to `div` so that `mdast-util-to-hast` falls back to + * its unknown-node handler and emits a plain `
`: a `

` cannot legally + * wrap the block-level markup being spliced in. Neither retyping a node nor + * putting block content inside a paragraph is expressible in mdast, so the + * marker is widened here. + */ +function replaceMarkerWithDemoNodes(marker: Paragraph, nodes: RootContent[]): void { + const container = marker as unknown as { type: string; children: RootContent[] }; + + container.type = 'div'; + container.children.splice(0, 1, ...nodes); +} /* * Insert Demo nodes into the page. */ -function insertDemoNodesIntoPage(page: PageContent, toInsert: Node[]): void { - if (Array.isArray(page.ast.children)) { - const secondHeading = findNode( - page.ast, - (node: Node) => node.type === 'heading' && node.depth !== 1 - ); - - if (secondHeading) { - const index = page.ast.children.findIndex(el => el === secondHeading); - page.ast.children.splice(index, 0, ...toInsert); - } else { - page.ast.children.push(...toInsert); - } +function insertDemoNodesIntoPage(page: PageContent, toInsert: RootContent[]): void { + const secondHeading = findHeading(page.ast, node => node.depth !== 1); + + if (secondHeading) { + const index = page.ast.children.findIndex(el => el === secondHeading); + page.ast.children.splice(index, 0, ...toInsert); + } else { + page.ast.children.push(...toInsert); } } -function replaceDemoMarkers(page: PageContent, demos: DemoComponent[]): void { - if (Array.isArray(page.ast.children)) { - const markers: Parent[] = []; - const allMarkers: Parent[] = []; +function replaceDemoMarkers(page: PageContent, demos: DemoComponent[]): void { + const markers: Paragraph[] = []; + const allMarkers: Paragraph[] = []; + + visit(page.ast, 'paragraph', node => { + if (demoMarker(node)) markers.push(node); + if (demosAllMarker(node)) allMarkers.push(node); + }); + + markers.forEach(marker => { + const matches = markerText(marker).match(demoMarkerRegex); + if (!matches) return; + + // TODO: This is an inner loop and can cause perf issues if someone + // out there has many demos on a single page. It would be better to + // create a demo component hash that can be looked up by demo name. + const demoName = matches[1]; + const demo = demos.find(d => d.name.dashCase.endsWith(demoName)); + + if (!demo) { + console.warn( + `Found demo marker "${demoName}" with no matching demo component in ${page.source}` + ); + return; + } - visit(page.ast, 'paragraph', (node: Parent) => { - if (demoMarker(node)) markers.push(node); - if (demosAllMarker(node)) allMarkers.push(node); - }); + replaceMarkerWithDemoNodes(marker, createDemoNodes(demo)); + }); - markers.forEach(marker => { - const child = marker.children[0]; - const matches = (child.value as string).match(demoMarkerRegex); - if (!matches) return; - - // TODO: This is an inner loop and can cause perf issues if someone - // out there has many demos on a single page. It would be better to - // create a demo component hash that can be looked up by demo name. - const demoName = matches[1]; - const demo = demos.find(d => d.name.dashCase.endsWith(demoName)); - - if (!demo) { - console.warn( - `Found demo marker "${demoName}" with no matching demo component in ${page.source}` - ); - return; - } - - marker.type = 'div'; - marker.children.splice(0, 1, ...createDemoNodes(demo)); - }); + allMarkers.forEach(marker => { + const demoNodes = demos.map(component => createDemoNodes(component)).flat(); - allMarkers.forEach(marker => { - const demoNodes = demos.map(component => createDemoNodes(component)).flat(); - - marker.type = 'div'; - marker.children.splice(0, 1, ...demoNodes); - }); - } + replaceMarkerWithDemoNodes(marker, demoNodes); + }); } export default plugin({ - runWithMdast(ctx: Context): void { + runWithMdast(ctx): void { const seenNames: Set = new Set(); ctx.pages.forEach(page => { @@ -110,7 +122,7 @@ export default plugin({ page.demos.forEach(demo => { const chunks: DemoComponentChunk[] = []; - visit(demo.ast, 'code', (node: CodeNode) => { + visit(demo.ast, 'code', node => { if (['component', 'template', 'styles'].includes(node.meta || '')) { chunks.push({ snippet: node, @@ -130,10 +142,7 @@ export default plugin({ seenNames ); - const demoTitle = findNode( - demo.ast, - (node: Node) => node.type === 'heading' && node.depth === 1 - ); + const demoTitle = findHeading(demo.ast, node => node.depth === 1); if (demoTitle) { demoTitle.depth = 3; @@ -167,7 +176,7 @@ export default plugin({ } else { // Automatic demo insertion creates an Example block after // the first heading. - const toInsert: Node[] = [createHeading(ctx)]; + const toInsert: RootContent[] = [createHeading(ctx)]; demoComponents.forEach(component => { toInsert.push(...createDemoNodes(component)); }); diff --git a/packages/ember-cli/src/plugins/preview-template.ts b/packages/ember-cli/src/plugins/preview-template.ts index fc5f0a0..438f8b6 100644 --- a/packages/ember-cli/src/plugins/preview-template.ts +++ b/packages/ember-cli/src/plugins/preview-template.ts @@ -1,6 +1,6 @@ import { visit } from 'unist-util-visit'; import plugin from '@docfy/core/lib/plugin.js'; -import { DemoComponent, CodeNode } from './types'; +import { DemoComponent } from './types'; import { generateDemoComponentName, getExt, @@ -17,7 +17,7 @@ export default plugin({ ctx.pages.forEach(page => { const demoComponents: DemoComponent[] = []; - visit(page.ast, 'code', (node: CodeNode) => { + visit(page.ast, 'code', node => { if (['preview-template', 'preview'].includes(node.meta || '')) { demoComponents.push({ name: generateDemoComponentName( diff --git a/packages/ember-cli/src/plugins/replace-internal-links-with-docfy-link.ts b/packages/ember-cli/src/plugins/replace-internal-links-with-docfy-link.ts index cb58e25..c62853c 100644 --- a/packages/ember-cli/src/plugins/replace-internal-links-with-docfy-link.ts +++ b/packages/ember-cli/src/plugins/replace-internal-links-with-docfy-link.ts @@ -1,23 +1,14 @@ import plugin from '@docfy/core/lib/plugin.js'; import { visit } from 'unist-util-visit'; import { PageContent } from '@docfy/core/lib/types.js'; -import { Node, Parent } from 'unist'; -import { u } from 'unist-builder'; - -interface LinkNode extends Node { - title: string | null; - url: string; - children: Node[]; -} - -function visitor(page: PageContent): void { - visit(page.ast, 'link', (visited, index, visitedParent) => { - const node = visited as unknown as LinkNode; - const parent = visitedParent as unknown as Parent | undefined; +import { html } from './utils'; +import type { Root, RootContent } from 'mdast'; +function visitor(page: PageContent): void { + visit(page.ast, 'link', (node, index, parent) => { if (node.url[0] === '/') { const data = node.data || (node.data = {}); - const props = (data.hProperties || (data.hProperties = {})) as Record; + const props = data.hProperties || (data.hProperties = {}); const urlParts = node.url.split('#'); const attributes = Object.keys(props) @@ -26,18 +17,26 @@ function visitor(page: PageContent): void { }) .join(' '); - const toInsert: Node[] = [ - u( - 'html', + const toInsert: RootContent[] = [ + html( `` ), ...node.children, - u('html', ``), + html(``), ]; - parent?.children.splice(index, 1, ...toInsert); + if (parent && typeof index === 'number') { + // `visit` types `parent` as the union of every mdast parent, whose + // `children` arrays hold different node types, so `splice` is not + // callable on the union. The raw `html` nodes also sit where mdast only + // allows phrasing content; `mdast-util-to-hast` passes them through + // untouched, which is what makes the surrounding tags work. + const children = (parent as unknown as { children: RootContent[] }).children; + + children.splice(index, 1, ...toInsert); + } } }); } diff --git a/packages/ember-cli/src/plugins/types.ts b/packages/ember-cli/src/plugins/types.ts index e45792e..e0fc71c 100644 --- a/packages/ember-cli/src/plugins/types.ts +++ b/packages/ember-cli/src/plugins/types.ts @@ -1,21 +1,13 @@ -import { Node } from 'unist'; +import type { Code, Root } from 'mdast'; -interface Literal { - value: string; -} - -export interface CodeNode extends Node, Literal { - type: 'code'; - lang?: string; - meta?: string; -} +export type CodeNode = Code; export interface DemoComponent { name: DemoComponentName; chunks: DemoComponentChunk[]; description?: { title?: string; - ast: Node; + ast: Root; editUrl?: string; }; } @@ -29,5 +21,5 @@ export interface DemoComponentChunk { type: string; code: string; ext: string; - snippet: Node; + snippet: Code; } diff --git a/packages/ember-cli/src/plugins/utils.ts b/packages/ember-cli/src/plugins/utils.ts index 664494f..c627fe6 100644 --- a/packages/ember-cli/src/plugins/utils.ts +++ b/packages/ember-cli/src/plugins/utils.ts @@ -1,7 +1,37 @@ -import { Node } from 'unist'; -import { u } from 'unist-builder'; +import { visit, EXIT } from 'unist-util-visit'; +import type { Heading, Html, Root, RootContent } from 'mdast'; import { DemoComponent, DemoComponentName } from './types'; +/* + * Builds a raw `html` mdast node. + * + * This replaces `unist-builder`'s `u('html', value)`, which returned an untyped + * node. `html` is a real mdast node type, so the literal is all we need. + */ +export function html(value: string): Html { + return { type: 'html', value }; +} + +/* + * Finds the first heading in the tree matching a predicate. + * + * This replaces `unist-util-find`, which is unmaintained and returns a bare + * unist `Node` — losing `depth` and `data` and forcing a cast at every call + * site. + */ +export function findHeading(tree: Root, test: (node: Heading) => boolean): Heading | undefined { + let found: Heading | undefined; + + visit(tree, 'heading', node => { + if (test(node)) { + found = node; + return EXIT; + } + }); + + return found; +} + /** * Creates all the Nodes necessary to render a Demo Component. * @@ -22,50 +52,48 @@ import { DemoComponent, DemoComponentName } from './types'; * * ``` */ -export function createDemoNodes(component: DemoComponent): Node[] { - const nodes: Node[] = [u('html', ``)]; +export function createDemoNodes(component: DemoComponent): RootContent[] { + const nodes: RootContent[] = [html(``)]; if (component.description) { nodes.push( - u( - 'html', + html( `` ), - component.description.ast, - u('html', '') + // The demo's description is a whole `Root`. mdast has no node type for a + // nested tree, but `mdast-util-to-hast` has a `root` handler, so it is + // rendered inline where it sits. + component.description.ast as unknown as RootContent, + html('') ); } nodes.push( - u('html', ''), - u('html', `<${component.name.pascalCase} />`), - u('html', '') + html(''), + html(`<${component.name.pascalCase} />`), + html('') ); if (component.chunks.length > 1) { - nodes.push(u('html', '')); + nodes.push(html('')); component.chunks.forEach(chunk => { - nodes.push( - u('html', ``), - chunk.snippet, - u('html', '') - ); + nodes.push(html(``), chunk.snippet, html('')); }); - nodes.push(u('html', '')); + nodes.push(html('')); } else { component.chunks.forEach(chunk => { nodes.push( - u('html', ``), + html(``), chunk.snippet, - u('html', '') + html('') ); }); } - nodes.push(u('html', '')); + nodes.push(html('')); return nodes; } @@ -89,30 +117,30 @@ export function getExt(lang: string): string { /* * Delete a node from a list of nodes */ -export function deleteNode(nodes: unknown, nodeToDelete: Node | undefined): void { +export function deleteNode(nodes: RootContent[], nodeToDelete: RootContent | undefined): void { if (!nodeToDelete) { return; } - if (Array.isArray(nodes)) { - const index = nodes.findIndex(item => item === nodeToDelete); + const index = nodes.findIndex(item => item === nodeToDelete); - if (index !== -1) { - nodes.splice(index, 1); - } + if (index !== -1) { + nodes.splice(index, 1); } } /* * Replace a node from a list of nodes */ -export function replaceNode(nodes: unknown, nodeToDelete: Node, ...newNodes: Node[]): void { - if (Array.isArray(nodes)) { - const index = nodes.findIndex(item => item === nodeToDelete); - - if (index !== -1) { - nodes.splice(index, 1, ...newNodes); - } +export function replaceNode( + nodes: RootContent[], + nodeToReplace: RootContent, + ...newNodes: RootContent[] +): void { + const index = nodes.findIndex(item => item === nodeToReplace); + + if (index !== -1) { + nodes.splice(index, 1, ...newNodes); } } diff --git a/packages/ember-vite/package.json b/packages/ember-vite/package.json index 743bc09..25a3680 100644 --- a/packages/ember-vite/package.json +++ b/packages/ember-vite/package.json @@ -46,8 +46,6 @@ "fast-glob": "^3.2.0", "mdast-util-to-string": "^4.0.0", "remark-hbs": "^0.4.1", - "unist-builder": "^4.0.0", - "unist-util-find": "^3.0.0", "unist-util-visit": "^5.1.0" }, "devDependencies": { @@ -57,7 +55,7 @@ "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/node": "^24.0.14", - "@types/unist": "^2.0.0", + "@types/unist": "^3.0.0", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.18.0", diff --git a/packages/ember-vite/src/docfy-plugins/demo-components.ts b/packages/ember-vite/src/docfy-plugins/demo-components.ts index e828c0c..23a2eec 100644 --- a/packages/ember-vite/src/docfy-plugins/demo-components.ts +++ b/packages/ember-vite/src/docfy-plugins/demo-components.ts @@ -1,6 +1,5 @@ import plugin from '@docfy/core/lib/plugin.js'; import { visit } from 'unist-util-visit'; -import { find as findNode } from 'unist-util-find'; import { toString } from 'mdast-util-to-string'; import path from 'path'; import { @@ -8,12 +7,13 @@ import { getExt, createDemoNodes, deleteNode, + findHeading, isDemoComponents, } from './utils.js'; import type { Context, PageContent } from '@docfy/core/lib/types.js'; -import type { DemoComponent, DemoComponentChunk, CodeNode, PluginData } from '../types.js'; -import type { Node, Parent } from 'unist'; +import type { DemoComponent, DemoComponentChunk, PluginData } from '../types.js'; +import type { Heading, Paragraph, Root, RootContent } from 'mdast'; /* * Create the heading for the examples section of the page. @@ -23,85 +23,97 @@ import type { Node, Parent } from 'unist'; * * This is necessary for apps using remark-autolink-headings, for example. */ -function createHeading(ctx: Context): Node { - const heading = (ctx.remark.runSync(ctx.remark.parse('## Examples')).children as Node[])[0]; +function createHeading(ctx: Context): Heading { + // `## Examples` always parses to a single heading. Remark plugins in the + // stack can annotate it, but none of them replace it with another node type. + const heading = ctx.remark.runSync(ctx.remark.parse('## Examples')).children[0] as Heading; heading.depth = 2; return heading; } -const isTextMarker = (node: Parent): boolean => - node.type === 'paragraph' && node.children.length === 1 && node.children[0].type === 'text'; +/* + * Returns the text of a paragraph made up of a single text node, which is the + * shape every demo marker has. + */ +function markerText(node: Paragraph): string { + const child = node.children[0]; + return node.children.length === 1 && child.type === 'text' ? child.value : ''; +} const demoMarkerRegex = /^\[\[demo:(.+?)\]\]$/; -const demoMarker = (node: Parent): boolean => - isTextMarker(node) && demoMarkerRegex.test(node.children[0].value as string); +const demoMarker = (node: Paragraph): boolean => demoMarkerRegex.test(markerText(node)); const demosAllMarkerRegex = /^\[\[demos-all\]\]$/; -const demosAllMarker = (node: Parent): boolean => - isTextMarker(node) && demosAllMarkerRegex.test(node.children[0].value as string); +const demosAllMarker = (node: Paragraph): boolean => demosAllMarkerRegex.test(markerText(node)); + +/* + * Replaces a demo marker paragraph with the given demo nodes. + * + * The paragraph is retyped to `div` so that `mdast-util-to-hast` falls back to + * its unknown-node handler and emits a plain `

`: a `

` cannot legally + * wrap the block-level markup being spliced in. Neither retyping a node nor + * putting block content inside a paragraph is expressible in mdast, so the + * marker is widened here. + */ +function replaceMarkerWithDemoNodes(marker: Paragraph, nodes: RootContent[]): void { + const container = marker as unknown as { type: string; children: RootContent[] }; + + container.type = 'div'; + container.children.splice(0, 1, ...nodes); +} /* * Insert Demo nodes into the page. */ -function insertDemoNodesIntoPage(page: PageContent, toInsert: Node[]): void { - if (Array.isArray(page.ast.children)) { - const secondHeading = findNode( - page.ast, - (node: Node) => node.type === 'heading' && node.depth !== 1 - ); - - if (secondHeading) { - const index = page.ast.children.findIndex(el => el === secondHeading); - page.ast.children.splice(index, 0, ...toInsert); - } else { - page.ast.children.push(...toInsert); - } +function insertDemoNodesIntoPage(page: PageContent, toInsert: RootContent[]): void { + const secondHeading = findHeading(page.ast, node => node.depth !== 1); + + if (secondHeading) { + const index = page.ast.children.findIndex(el => el === secondHeading); + page.ast.children.splice(index, 0, ...toInsert); + } else { + page.ast.children.push(...toInsert); } } -function replaceDemoMarkers(page: PageContent, demos: DemoComponent[]): void { - if (Array.isArray(page.ast.children)) { - const markers: Parent[] = []; - const allMarkers: Parent[] = []; +function replaceDemoMarkers(page: PageContent, demos: DemoComponent[]): void { + const markers: Paragraph[] = []; + const allMarkers: Paragraph[] = []; + + visit(page.ast, 'paragraph', node => { + if (demoMarker(node)) markers.push(node); + if (demosAllMarker(node)) allMarkers.push(node); + }); + + markers.forEach(marker => { + const matches = markerText(marker).match(demoMarkerRegex); + if (!matches) return; + + // TODO: This is an inner loop and can cause perf issues if someone + // out there has many demos on a single page. It would be better to + // create a demo component hash that can be looked up by demo name. + const demoName = matches[1]; + const demo = demos.find(d => d.name.dashCase.endsWith(demoName)); + + if (!demo) { + console.warn( + `Found demo marker "${demoName}" with no matching demo component in ${page.source}` + ); + return; + } - visit(page.ast, 'paragraph', (node: Parent) => { - if (demoMarker(node)) markers.push(node); - if (demosAllMarker(node)) allMarkers.push(node); - }); + replaceMarkerWithDemoNodes(marker, createDemoNodes(demo)); + }); - markers.forEach(marker => { - const child = marker.children[0]; - const matches = (child.value as string).match(demoMarkerRegex); - if (!matches) return; - - // TODO: This is an inner loop and can cause perf issues if someone - // out there has many demos on a single page. It would be better to - // create a demo component hash that can be looked up by demo name. - const demoName = matches[1]; - const demo = demos.find(d => d.name.dashCase.endsWith(demoName)); - - if (!demo) { - console.warn( - `Found demo marker "${demoName}" with no matching demo component in ${page.source}` - ); - return; - } - - marker.type = 'div'; - marker.children.splice(0, 1, ...createDemoNodes(demo)); - }); + allMarkers.forEach(marker => { + const demoNodes = demos.map(component => createDemoNodes(component)).flat(); - allMarkers.forEach(marker => { - const demoNodes = demos.map(component => createDemoNodes(component)).flat(); - - marker.type = 'div'; - marker.children.splice(0, 1, ...demoNodes); - }); - } + replaceMarkerWithDemoNodes(marker, demoNodes); + }); } export default plugin({ - runWithMdast(ctx: Context): void { + runWithMdast(ctx): void { const seenNames: Set = new Set(); ctx.pages.forEach(page => { @@ -111,7 +123,7 @@ export default plugin({ page.demos.forEach(demo => { const chunks: DemoComponentChunk[] = []; - visit(demo.ast, 'code', (node: CodeNode) => { + visit(demo.ast, 'code', node => { if (['component', 'template', 'styles'].includes(node.meta || '')) { chunks.push({ snippet: node, @@ -131,10 +143,7 @@ export default plugin({ seenNames ); - const demoTitle = findNode( - demo.ast, - (node: Node) => node.type === 'heading' && node.depth === 1 - ); + const demoTitle = findHeading(demo.ast, node => node.depth === 1); if (demoTitle) { demoTitle.depth = 3; @@ -168,7 +177,7 @@ export default plugin({ } else { // Automatic demo insertion creates an Example block after // the first heading. - const toInsert: Node[] = [createHeading(ctx)]; + const toInsert: RootContent[] = [createHeading(ctx)]; demoComponents.forEach(component => { toInsert.push(...createDemoNodes(component)); }); diff --git a/packages/ember-vite/src/docfy-plugins/docfy-link-conversion.ts b/packages/ember-vite/src/docfy-plugins/docfy-link-conversion.ts index e80ecfe..35614ee 100644 --- a/packages/ember-vite/src/docfy-plugins/docfy-link-conversion.ts +++ b/packages/ember-vite/src/docfy-plugins/docfy-link-conversion.ts @@ -1,27 +1,21 @@ import plugin from '@docfy/core/lib/plugin.js'; import { visit } from 'unist-util-visit'; -import { u } from 'unist-builder'; -import type { Context, PageContent } from '@docfy/core/lib/types.js'; -import type { Node, Parent } from 'unist'; +import { html } from './utils.js'; +import type { PageContent } from '@docfy/core/lib/types.js'; +import type { Root, RootContent } from 'mdast'; import type { PluginData } from '../types.js'; import { getComponentImport } from '../import-map.js'; -interface LinkNode extends Node { - title: string | null; - url: string; - children: Node[]; -} - -function processPageForInternalLinks(page: PageContent): boolean { +function processPageForInternalLinks(page: PageContent): boolean { let hasInternalLinks = false; - visit(page.ast, 'link', (node: LinkNode, index: number, parent: Parent | undefined) => { + visit(page.ast, 'link', (node, index, parent) => { // Only process internal links that start with '/' if (node.url && node.url[0] === '/') { hasInternalLinks = true; const data = node.data || (node.data = {}); - const props = (data.hProperties || (data.hProperties = {})) as Record; + const props = data.hProperties || (data.hProperties = {}); const urlParts = node.url.split('#'); const attributes = Object.keys(props) @@ -30,19 +24,25 @@ function processPageForInternalLinks(page: PageContent): boolean { }) .join(' '); - const toInsert: Node[] = [ - u( - 'html', + const toInsert: RootContent[] = [ + html( `` ), ...node.children, - u('html', ``), + html(``), ]; - if (parent && parent.children) { - parent.children.splice(index, 1, ...toInsert); + if (parent && typeof index === 'number') { + // `visit` types `parent` as the union of every mdast parent, whose + // `children` arrays hold different node types, so `splice` is not + // callable on the union. The raw `html` nodes also sit where mdast only + // allows phrasing content; `mdast-util-to-hast` passes them through + // untouched, which is what makes the surrounding tags work. + const children = (parent as unknown as { children: RootContent[] }).children; + + children.splice(index, 1, ...toInsert); } } }); @@ -60,7 +60,7 @@ function processPageForInternalLinks(page: PageContent): boolean { * [API Reference](/docs/api#configuration) -> API Reference */ export default plugin({ - runWithMdast(ctx: Context): void { + runWithMdast(ctx): void { ctx.pages.forEach(page => { // Process page content and check for internal links in one pass const pageHasInternalLinks = processPageForInternalLinks(page); diff --git a/packages/ember-vite/src/docfy-plugins/escape-curlies-in-code.ts b/packages/ember-vite/src/docfy-plugins/escape-curlies-in-code.ts index ae75bb9..99630d5 100644 --- a/packages/ember-vite/src/docfy-plugins/escape-curlies-in-code.ts +++ b/packages/ember-vite/src/docfy-plugins/escape-curlies-in-code.ts @@ -1,7 +1,6 @@ import plugin from '@docfy/core/lib/plugin.js'; import { visit } from 'unist-util-visit'; -import type { Node } from 'unist'; -import type { Element, Text } from 'hast'; +import type { Root } from 'hast'; import type { PageContent } from '@docfy/core/lib/types.js'; /** @@ -15,13 +14,13 @@ import type { PageContent } from '@docfy/core/lib/types.js'; * bare `{{` into the output. Running at the hast stage (`runWithHast`, which * Docfy invokes after all rehype plugins) escapes the final text instead. */ -function escapeCurliesInCode(ast: Node): void { - visit(ast, 'element', (node: Element) => { +function escapeCurliesInCode(ast: Root): void { + visit(ast, 'element', node => { if (node.tagName !== 'code') { return; } - visit(node, 'text', (textNode: Text) => { + visit(node, 'text', textNode => { textNode.value = textNode.value.replace(/\{\{/g, '\\{{'); }); @@ -32,7 +31,7 @@ function escapeCurliesInCode(ast: Node): void { export default plugin({ runWithHast(ctx): void { - const escape = (page: PageContent): void => { + const escape = (page: PageContent): void => { escapeCurliesInCode(page.ast); page.demos?.forEach(escape); }; diff --git a/packages/ember-vite/src/docfy-plugins/preview-templates.ts b/packages/ember-vite/src/docfy-plugins/preview-templates.ts index 5e456ad..ff180af 100644 --- a/packages/ember-vite/src/docfy-plugins/preview-templates.ts +++ b/packages/ember-vite/src/docfy-plugins/preview-templates.ts @@ -9,7 +9,7 @@ import { } from './utils.js'; import path from 'path'; -import type { DemoComponent, CodeNode } from '../types.js'; +import type { DemoComponent } from '../types.js'; export default plugin({ runWithMdast(ctx): void { @@ -18,7 +18,7 @@ export default plugin({ ctx.pages.forEach(page => { const demoComponents: DemoComponent[] = []; - visit(page.ast, 'code', (node: CodeNode) => { + visit(page.ast, 'code', node => { if (['preview-template', 'preview'].includes(node.meta || '')) { demoComponents.push({ name: generateDemoComponentName( diff --git a/packages/ember-vite/src/docfy-plugins/utils.ts b/packages/ember-vite/src/docfy-plugins/utils.ts index 3c7d4fc..95b5017 100644 --- a/packages/ember-vite/src/docfy-plugins/utils.ts +++ b/packages/ember-vite/src/docfy-plugins/utils.ts @@ -1,5 +1,5 @@ -import { u } from 'unist-builder'; -import { Node } from 'unist'; +import { visit, EXIT } from 'unist-util-visit'; +import type { Heading, Html, Root, RootContent } from 'mdast'; import type { DemoComponent, DemoComponentName } from '../types.js'; // Map language names to file extensions (same as original ember implementation) @@ -31,77 +31,106 @@ export function isDemoComponents(components: unknown): components is DemoCompone return false; } -// Utility functions (same as original ember implementation) -export function replaceNode(nodes: unknown, nodeToDelete: any, ...newNodes: any[]): void { - if (Array.isArray(nodes)) { - const index = nodes.findIndex(item => item === nodeToDelete); - if (index !== -1) { - nodes.splice(index, 1, ...newNodes); +/* + * Builds a raw `html` mdast node. + * + * This replaces `unist-builder`'s `u('html', value)`, which returned an untyped + * node. `html` is a real mdast node type, so the literal is all we need. + */ +export function html(value: string): Html { + return { type: 'html', value }; +} + +/* + * Finds the first heading in the tree matching a predicate. + * + * This replaces `unist-util-find`, which is unmaintained and returns a bare + * unist `Node` — losing `depth` and `data` and forcing a cast at every call + * site. + */ +export function findHeading(tree: Root, test: (node: Heading) => boolean): Heading | undefined { + let found: Heading | undefined; + + visit(tree, 'heading', node => { + if (test(node)) { + found = node; + return EXIT; } + }); + + return found; +} + +// Utility functions (same as original ember implementation) +export function replaceNode( + nodes: RootContent[], + nodeToReplace: RootContent, + ...newNodes: RootContent[] +): void { + const index = nodes.findIndex(item => item === nodeToReplace); + + if (index !== -1) { + nodes.splice(index, 1, ...newNodes); } } -export function createDemoNodes(component: DemoComponent): Node[] { - const nodes: Node[] = [u('html', ``)]; +export function createDemoNodes(component: DemoComponent): RootContent[] { + const nodes: RootContent[] = [html(``)]; if (component.description) { nodes.push( - u( - 'html', + html( `` ), - component.description.ast, - u('html', '') + // The demo's description is a whole `Root`. mdast has no node type for a + // nested tree, but `mdast-util-to-hast` has a `root` handler, so it is + // rendered inline where it sits. + component.description.ast as unknown as RootContent, + html('') ); } nodes.push( - u('html', ''), - u('html', `<${component.name.pascalCase} />`), - u('html', '') + html(''), + html(`<${component.name.pascalCase} />`), + html('') ); if (component.chunks.length > 1) { - nodes.push(u('html', '')); + nodes.push(html('')); component.chunks.forEach(chunk => { - nodes.push( - u('html', ``), - chunk.snippet, - u('html', '') - ); + nodes.push(html(``), chunk.snippet, html('')); }); - nodes.push(u('html', '')); + nodes.push(html('')); } else { component.chunks.forEach(chunk => { nodes.push( - u('html', ``), + html(``), chunk.snippet, - u('html', '') + html('') ); }); } - nodes.push(u('html', '')); + nodes.push(html('')); return nodes; } /* * Delete a node from a list of nodes */ -export function deleteNode(nodes: unknown, nodeToDelete: Node | undefined): void { +export function deleteNode(nodes: RootContent[], nodeToDelete: RootContent | undefined): void { if (!nodeToDelete) { return; } - if (Array.isArray(nodes)) { - const index = nodes.findIndex(item => item === nodeToDelete); + const index = nodes.findIndex(item => item === nodeToDelete); - if (index !== -1) { - nodes.splice(index, 1); - } + if (index !== -1) { + nodes.splice(index, 1); } } diff --git a/packages/ember-vite/src/types.ts b/packages/ember-vite/src/types.ts index 97b6d6e..35f9a18 100644 --- a/packages/ember-vite/src/types.ts +++ b/packages/ember-vite/src/types.ts @@ -1,13 +1,7 @@ -import type { Node } from 'unist'; +import type { Code, Root } from 'mdast'; + +export type CodeNode = Code; -interface Literal { - value: string; -} -export interface CodeNode extends Node, Literal { - type: 'code'; - lang?: string; - meta?: string; -} export interface ImportStatement { name: string; path: string; @@ -25,7 +19,7 @@ export interface DemoComponentChunk { type: string; code: string; ext: string; - snippet: Node; // AST node reference + snippet: Code; // AST node reference } export interface DemoComponent { @@ -33,7 +27,7 @@ export interface DemoComponent { chunks: DemoComponentChunk[]; description?: { title?: string; - ast: Node; // AST node reference + ast: Root; // AST node reference editUrl?: string; }; } diff --git a/packages/plugin-with-prose/package.json b/packages/plugin-with-prose/package.json index 83f0184..8b6ec66 100644 --- a/packages/plugin-with-prose/package.json +++ b/packages/plugin-with-prose/package.json @@ -25,8 +25,8 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@types/mdast": "^4.0.4", "@types/node": "^22.10.5", - "@types/unist": "^2.0.0", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.18.0", diff --git a/packages/plugin-with-prose/src/index.ts b/packages/plugin-with-prose/src/index.ts index 0cff5ec..8836226 100644 --- a/packages/plugin-with-prose/src/index.ts +++ b/packages/plugin-with-prose/src/index.ts @@ -1,15 +1,10 @@ import plugin from '@docfy/core/lib/plugin.js'; -import { PageContent } from '@docfy/core/lib/types'; -import type { Node, Parent } from 'unist'; - -interface NodeWithMeta extends Node { - meta?: string; -} +import type { Html, Root, RootContent } from 'mdast'; // This plugin was inpired by TailwindCSS's code: // https://github.com/tailwindlabs/tailwindcss.com/blob/1234b4faded6c7a06b734c49c61257137b4acc9b/remark/withProse.js -function shouldUnproseNode(node: NodeWithMeta): boolean { +function shouldUnproseNode(node: RootContent): boolean { return Boolean( node.type === 'code' && node.meta && @@ -17,16 +12,16 @@ function shouldUnproseNode(node: NodeWithMeta): boolean { ); } -function withProse(tree: Parent, className = 'prose', notClassName = 'not-prose'): void { - const openProse = () => ({ +function withProse(tree: Root, className = 'prose', notClassName = 'not-prose'): void { + const openProse = (): Html => ({ type: 'html', value: `

`, }); - const openNotProse = () => ({ + const openNotProse = (): Html => ({ type: 'html', value: `
`, }); - const close = () => ({ type: 'html', value: '
' }); + const close = (): Html => ({ type: 'html', value: '
' }); tree.children = [ openProse(), @@ -49,17 +44,9 @@ interface WithProseOptions { className?: string; } -interface Page { - ast: Parent; - demos?: Page[]; -} - const DocfyPluginWithProse = plugin.withOptions({ runWithMdast(ctx, options) { - ctx.pages.forEach((pageContent: PageContent) => { - // PageContent may not have children, which is required for withProse - // TODO: pageContent may need to be a union type - const page = pageContent as unknown as Page; + ctx.pages.forEach(page => { withProse(page.ast, options?.className); page.demos?.forEach(demo => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2153a..80c2e00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@types/unist': 2.0.3 - importers: .: @@ -94,8 +91,8 @@ importers: specifier: ^24.0.14 version: 24.13.3 '@types/unist': - specifier: 2.0.3 - version: 2.0.3 + specifier: ^3.0.0 + version: 3.0.3 eslint: specifier: ^9.32.0 version: 9.39.5(jiti@2.7.0) @@ -291,12 +288,6 @@ importers: remark-hbs: specifier: ^0.4.1 version: 0.4.1 - unist-builder: - specifier: ^4.0.0 - version: 4.0.0 - unist-util-find: - specifier: ^3.0.0 - version: 3.0.0 unist-util-visit: specifier: ^5.1.0 version: 5.1.0 @@ -332,8 +323,8 @@ importers: specifier: ^4.0.4 version: 4.0.4 '@types/unist': - specifier: 2.0.3 - version: 2.0.3 + specifier: ^3.0.0 + version: 3.0.3 autoprefixer: specifier: ^10.4.21 version: 10.5.4(postcss@8.5.25) @@ -478,12 +469,6 @@ importers: remark-hbs: specifier: ^0.4.1 version: 0.4.1 - unist-builder: - specifier: ^4.0.0 - version: 4.0.0 - unist-util-find: - specifier: ^3.0.0 - version: 3.0.0 unist-util-visit: specifier: ^5.1.0 version: 5.1.0 @@ -510,8 +495,8 @@ importers: specifier: ^24.0.14 version: 24.13.3 '@types/unist': - specifier: 2.0.3 - version: 2.0.3 + specifier: ^3.0.0 + version: 3.0.3 eslint: specifier: ^9.32.0 version: 9.39.5(jiti@2.7.0) @@ -549,12 +534,12 @@ importers: '@eslint/js': specifier: ^9.32.0 version: 9.39.5 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: ^22.10.5 version: 22.20.1 - '@types/unist': - specifier: 2.0.3 - version: 2.0.3 eslint: specifier: ^9.32.0 version: 9.39.5(jiti@2.7.0) @@ -3046,6 +3031,9 @@ packages: '@types/unist@2.0.3': resolution: {integrity: sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6984,9 +6972,6 @@ packages: lodash.ismatch@4.4.0: resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} - lodash.iteratee@4.7.0: - resolution: {integrity: sha512-yv3cSQZmfpbIKo4Yo45B1taEvxjNvcpF1CEOc0Y6dEyvhPIfEJE3twDwPgWTPQubcSgXyBwBKG6wpQvWMDOf6Q==} - lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} @@ -9810,15 +9795,9 @@ packages: unist-builder@2.0.3: resolution: {integrity: sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==} - unist-builder@4.0.0: - resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==} - unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - unist-util-find@3.0.0: - resolution: {integrity: sha512-T7ZqS7immLjYyC4FCp2hDo3ksZ1v+qcbb+e5+iWxc2jONgHOLXPCpms1L8VV4hVxCXgWTxmBHDztuEZFVwC+Gg==} - unist-util-is@4.1.0: resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} @@ -12703,6 +12682,8 @@ snapshots: '@types/unist@2.0.3': {} + '@types/unist@3.0.3': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 @@ -17367,7 +17348,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.5 - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 property-information: 7.2.0 @@ -17392,7 +17373,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 hast-util-whitespace: 3.0.0 @@ -17408,7 +17389,7 @@ snapshots: hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.5 - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 @@ -18436,8 +18417,6 @@ snapshots: lodash.ismatch@4.4.0: {} - lodash.iteratee@4.7.0: {} - lodash.kebabcase@4.1.1: {} lodash.memoize@4.1.2: {} @@ -18618,7 +18597,7 @@ snapshots: mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 @@ -18732,7 +18711,7 @@ snapshots: mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 longest-streak: 3.1.0 mdast-util-phrasing: 4.1.0 mdast-util-to-string: 4.0.0 @@ -22155,7 +22134,7 @@ snapshots: unified@11.0.5: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 bail: 2.0.2 devlop: 1.1.0 extend: 3.0.2 @@ -22186,21 +22165,11 @@ snapshots: unist-builder@2.0.3: {} - unist-builder@4.0.0: - dependencies: - '@types/unist': 2.0.3 - unist-util-find-after@5.0.0: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-is: 6.0.1 - unist-util-find@3.0.0: - dependencies: - '@types/unist': 2.0.3 - lodash.iteratee: 4.7.0 - unist-util-visit: 5.1.0 - unist-util-is@4.1.0: {} unist-util-is@5.2.1: @@ -22209,15 +22178,15 @@ snapshots: unist-util-is@6.0.1: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-position@5.0.0: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-remove-position@5.0.0: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-visit: 5.1.0 unist-util-stringify-position@2.0.3: @@ -22226,7 +22195,7 @@ snapshots: unist-util-stringify-position@4.0.0: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-visit-parents@3.1.1: dependencies: @@ -22240,7 +22209,7 @@ snapshots: unist-util-visit-parents@6.0.2: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-is: 6.0.1 unist-util-visit@2.0.3: @@ -22257,7 +22226,7 @@ snapshots: unist-util-visit@5.1.0: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 @@ -22322,7 +22291,7 @@ snapshots: vfile-location@5.0.3: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 vfile: 6.0.3 vfile-message@2.0.4: @@ -22332,7 +22301,7 @@ snapshots: vfile-message@4.0.3: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 vfile@4.2.1: @@ -22344,7 +22313,7 @@ snapshots: vfile@6.0.3: dependencies: - '@types/unist': 2.0.3 + '@types/unist': 3.0.3 vfile-message: 4.0.3 vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(yaml@2.9.0): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e3ad30b..076c8af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,19 +3,6 @@ packages: - 'test-app-vite' - 'test-app-classic' -# Replaces yarn's `resolutions`. pnpm 11 reads overrides from this file, not -# from the root package.json. -# -# The unist v2 types are still pinned because the Ember integrations' Docfy -# plugins are written against v2's loose `Node` (which carried an index -# signature, so `node.value` / `node.depth` type-check on a bare node). They -# also intentionally build synthetic nodes and reassign `node.type`, which -# strict mdast `Root`/`RootContent` types cannot express. Runtime is unaffected -# — this is types-only. Migrating those plugins to real mdast types (and then -# dropping this pin) is tracked separately. -overrides: - '@types/unist': 2.0.3 - # pnpm blocks dependency build scripts unless explicitly allowed here. # esbuild's postinstall installs its platform binary, without which vite cannot # build; nx is lerna's task engine. core-js 2.x's postinstall only prints a