From a790beecc9ffb0cdaa72de2d6a49b6836d097111 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:09:58 -0400 Subject: [PATCH 1/2] Add docs authoring components --- .../docs/guides/components-fixture.mdx | 77 +++++++++- .../content/docs/guides/quickstart.mdx | 4 +- apps/docs-smoke/content/docs/index.mdx | 2 +- .../src/generated/docs-search-content.json | 2 +- .../src/generated/docs-search-index.json | 2 +- apps/docs-smoke/src/lib/docs.ts | 15 ++ apps/docs-smoke/src/styles.css | 87 +++++++++++ apps/docs-smoke/tests/e2e/smoke.e2e.ts | 5 +- .../docs/agent-docs-src/docs/components.mdx | 69 +++++++++ packages/docs/agent-docs/docs/components.md | 69 +++++++++ .../docs/llms-full/authoring/components.txt | 69 +++++++++ packages/docs/src/components/accordion.tsx | 33 ++++ .../docs/src/components/components.test.tsx | 58 +++++++ packages/docs/src/components/example.tsx | 66 ++++++++ packages/docs/src/components/index.ts | 12 ++ .../docs/src/components/mdx-components.ts | 7 + .../docs/src/components/topic-switcher.tsx | 60 ++++++++ packages/docs/src/remark/index.ts | 9 ++ .../src/remark/plugins/accordion.remark.ts | 79 ++++++++++ .../docs/src/remark/plugins/example.remark.ts | 144 ++++++++++++++++++ .../remark/plugins/topic-switcher.remark.ts | 134 ++++++++++++++++ .../docs/src/remark/remark-output.test.ts | 117 ++++++++++++++ 22 files changed, 1114 insertions(+), 6 deletions(-) create mode 100644 packages/docs/src/components/accordion.tsx create mode 100644 packages/docs/src/components/example.tsx create mode 100644 packages/docs/src/components/topic-switcher.tsx create mode 100644 packages/docs/src/remark/plugins/accordion.remark.ts create mode 100644 packages/docs/src/remark/plugins/example.remark.ts create mode 100644 packages/docs/src/remark/plugins/topic-switcher.remark.ts diff --git a/apps/docs-smoke/content/docs/guides/components-fixture.mdx b/apps/docs-smoke/content/docs/guides/components-fixture.mdx index 3fac356..ea120a5 100644 --- a/apps/docs-smoke/content/docs/guides/components-fixture.mdx +++ b/apps/docs-smoke/content/docs/guides/components-fixture.mdx @@ -20,11 +20,36 @@ description: "Render the browser-facing @inth/docs adapters through authored MDX + + + Accordion content is collapsible in the browser and still available after MDX-to-markdown conversion. + + + Tabs hydrate in the browser. Use `TypeTable` when type data already exists in MDX. + + B[mdxComponents] B --> C[Rendered route] @@ -42,7 +67,7 @@ description: "Render the browser-facing @inth/docs adapters through authored MDX - Use semantic components such as `Callout`, `Tabs`, `Cards`, `Steps`, `CommandTabs`, and `TypeTable`. + Use semantic components such as `Callout`, `Tabs`, `Cards`, `Steps`, `CommandTabs`, `Accordion`, `Example`, `TopicSwitcher`, and `TypeTable`. Import the `.mdx` file directly and provide `mdxComponents` through the shared runtime map. @@ -54,6 +79,15 @@ description: "Render the browser-facing @inth/docs adapters through authored MDX + + + Prefer semantic components whose content can flatten cleanly into markdown for agents, search indexes, and LLM bundles. + + + Use `TopicSwitcher` for equivalent docs slices. Frameworks are one topic category, not the whole abstraction. + + + This tabset proves the package adapters hydrate correctly inside the demo app. @@ -66,6 +100,47 @@ description: "Render the browser-facing @inth/docs adapters through authored MDX + + + + + The host app owns styling while `@inth/docs` owns the MDX component contract. + + + B[mdxComponents] B --> C[TanStack Start route] diff --git a/apps/docs-smoke/content/docs/guides/quickstart.mdx b/apps/docs-smoke/content/docs/guides/quickstart.mdx index 53ae3f8..91bf36f 100644 --- a/apps/docs-smoke/content/docs/guides/quickstart.mdx +++ b/apps/docs-smoke/content/docs/guides/quickstart.mdx @@ -68,12 +68,14 @@ import { mdxComponents } from "@inth/docs"; export const components = { ...mdxComponents, // Add or override project-specific MDX components here. - // Example: Icon, APIExample, FrameworkTabs, or branded Callout. + // Example: Icon, APIExample, TopicSwitcher, or branded Callout. }; ``` `@inth/docs` does not own your routing, sidebar, layout, hosting, or framework. A Next.js, TanStack Start, Vite, Fumadocs, or Astro-backed docs app can still own those pieces. +Framework docs can be exposed through `TopicSwitcher` while the conversion pipeline still resolves `{framework}` placeholders from `/docs/frameworks/:framework` routes. + ## 3. Convert Authored MDX To Markdown Create a docs conversion script in the consuming repo. diff --git a/apps/docs-smoke/content/docs/index.mdx b/apps/docs-smoke/content/docs/index.mdx index 0f6801a..b10ebae 100644 --- a/apps/docs-smoke/content/docs/index.mdx +++ b/apps/docs-smoke/content/docs/index.mdx @@ -119,7 +119,7 @@ After conversion, use `@inth/docs/llm` to write `llms.txt` and topic-scoped full /> Render exported adapters through your shared `mdxComponents` map. Tabs hydrate in the browser. Use `TypeTable` when type data already exists in MDX. B[mdxComponents] B --> C[Rendered route] `} /> ```","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nNavigation Cards\n\nQuickstart route External reference","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nBrowser Flow\n\n1. Author MDX Use semantic components such as Callout , Tabs , Cards , Steps , CommandTabs , and TypeTable . 2. Render in the app Import the .mdx file directly and provide mdxComponents through the shared runtime map. 3. Validate the pipeline separately Keep ExtractedTypeTable coverage in the conversion pipeline where source extraction has a stable file-system base path. Package manager Command -- -- npm npm install @inth/docs pnpm pnpm add @inth/docs yarn yarn add @inth/docs bun bun add @inth/docs Overview This tabset proves the package adapters hydrate correctly inside the demo app. Tables TypeTable is safe to render live because all of its data is already present in the MDX payload. Pipeline note ExtractedTypeTable is rendered on /docs with extracted type data and verified in content/docs/guides/extracted-type-table-fixture.mdx . Property Type Description Default Required -- -- -- -- -- command string Package name, CLI name, or custom command template with a \\ pm placeholder. - āœ… Required mode \"install\" \\ \"run\" \\ \"create\" Optional expansion mode for package names, CLI names, or project starters such as \\ pnpm create next-app\\ .\n\n```mermaid `flowchart LR A[Authored MDX] --> B[mdxComponents] B --> C[TanStack Start route] C --> D[Playwright coverage] ```","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nBrowser Flow\n\nlder. - āœ… Required mode \"install\" \\ \"run\" \\ \"create\" Optional expansion mode for package names, CLI names, or project starters such as \\ pnpm create next-app\\ . - Optional commands Partial\\ Render exported adapters through your shared `mdxComponents` map. Accordion content is collapsible in the browser and still available after MDX-to-markdown conversion. Tabs hydrate in the browser. Use `TypeTable` when type data already exists in MDX. B[mdxComponents] B --> C[Rendered route] `} /> ```","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nNavigation Cards\n\nQuickstart route External reference","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nBrowser Flow\n\n1. Author MDX Use semantic components such as Callout , Tabs , Cards , Steps , CommandTabs , Accordion , Example , TopicSwitcher , and TypeTable . 2. Render in the app Import the .mdx file directly and provide mdxComponents through the shared runtime map. 3. Validate the pipeline separately Keep ExtractedTypeTable coverage in the conversion pipeline where source extraction has a stable file-system base path. Package manager Command -- -- npm npm install @inth/docs pnpm pnpm add @inth/docs yarn yarn add @inth/docs bun bun add @inth/docs Authoring rule Prefer semantic components whose content can flatten cleanly into markdown for agents, search indexes, and LLM bundles. Naming rule Use TopicSwitcher for equivalent docs slices. Frameworks are one topic category, not the whole abstraction. Overview This tabset proves the package adapters hydrate correctly inside the demo app. Tables TypeTable is safe to render live because all of its data is already present in the MDX payload. Pipeline note ExtractedTypeTable is rendered on /docs with extracted type data and verified in content/docs/guides/extracted-type-table-fixture.mdx .\n\n```tsx import { mdxComponents } from \"@inth/docs\"; export const components = { ...mdxComponents, }; ``` ```mermaid `flowchart LR A[Authored MDX] --> B[mdxComponents] B --> C[TanStack Start route] C --> D[Playwright coverage] ```","Runtime Components\n\nRender the browser-facing @inth/docs adapters through authored MDX.\n\nRuntime Components\n\nBrowser Flow\n\nX payload. Pipeline note ExtractedTypeTable is rendered on /docs with extracted type data and verified in content/docs/guides/extracted-type-table-fixture.mdx . Framework React — React integration Next.js — Next.js integration Svelte — Svelte integration Render MDX Preview the output and inspect the source. āœ… Success Runtime adapter The host app owns styling while @inth/docs owns the MDX component contract. mdx-components.tsx Property Type Description Default Required -- -- -- -- -- command string Package name, CLI name, or custom command template with a \\ pm placeholder. - āœ… Required mode \"install\" \\ \"run\" \\ \"create\" Optional expansion mode for package names, CLI names, or project starters such as \\ pnpm create next-app\\ . - Optional commands Partial\\ + + Use accordions for supporting details, troubleshooting notes, and optional reference material. + + +``` + +Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs. + ### `CommandTabs` Use for package-manager-specific install or run commands. @@ -64,6 +82,30 @@ Use for package-manager-specific install or run commands. Use `mode="install"` when `command` is a package name, `mode="run"` when `command` is a CLI name, and `mode="create"` for starter commands such as `pnpm create next-app`. `command` can also include a `{pm}` placeholder for custom templates. Use `commands` for exact per-manager overrides and `defaultManager` to choose the initial tab. +### `Example` + +Use for data-driven preview and source examples. The package component receives code as data; host apps can add filesystem loaders or dynamic imports outside `@inth/docs` when they need app-specific behavior. + +```tsx + + + The host app owns styling while `@inth/docs` owns the MDX component contract. + + +``` + +Use `sourceFiles` when an example needs to show supporting files in addition to the primary snippet. + ### `TypeTable` and `ExtractedTypeTable` Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedTypeTable` when the docs should extract types from source files. @@ -74,6 +116,33 @@ Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedT These components are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers. +### `TopicSwitcher` + +Use for reader-facing navigation across equivalent documentation topics. + +```tsx + +``` + +`TopicSwitcher` is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config. + ## Guidance - Prefer the package root export for React doc-site rendering. diff --git a/packages/docs/agent-docs/docs/components.md b/packages/docs/agent-docs/docs/components.md index 1564e02..b86cf32 100644 --- a/packages/docs/agent-docs/docs/components.md +++ b/packages/docs/agent-docs/docs/components.md @@ -16,10 +16,13 @@ The root export is intentionally small. It gives consumers a ready-to-spread MDX `mdxComponents` includes: +* `Accordion` +* `AccordionItem` * `ExtractedTypeTable` * `Callout` * `Card` * `Cards` +* `Example` * `Mermaid` * `CommandTabs` * `Selector` @@ -27,6 +30,7 @@ The root export is intentionally small. It gives consumers a ready-to-spread MDX * `Steps` * `Tab` * `Tabs` +* `TopicSwitcher` * `TypeTable` Use it like this: @@ -44,6 +48,20 @@ Override individual entries rather than replacing the full map unless you want t ## Important Components +### `Accordion` and `AccordionItem` + +Use for secondary details that should be collapsible in the browser but still available to markdown conversion, search, and LLM bundles. + +```tsx + + + Use accordions for supporting details, troubleshooting notes, and optional reference material. + + +``` + +Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs. + ### `CommandTabs` Use for package-manager-specific install or run commands. @@ -63,6 +81,30 @@ Use for package-manager-specific install or run commands. Use `mode="install"` when `command` is a package name, `mode="run"` when `command` is a CLI name, and `mode="create"` for starter commands such as `pnpm create next-app`. `command` can also include a `{pm}` placeholder for custom templates. Use `commands` for exact per-manager overrides and `defaultManager` to choose the initial tab. +### `Example` + +Use for data-driven preview and source examples. The package component receives code as data; host apps can add filesystem loaders or dynamic imports outside `@inth/docs` when they need app-specific behavior. + +```tsx + + + The host app owns styling while `@inth/docs` owns the MDX component contract. + + +``` + +Use `sourceFiles` when an example needs to show supporting files in addition to the primary snippet. + ### `TypeTable` and `ExtractedTypeTable` Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedTypeTable` when the docs should extract types from source files. @@ -73,6 +115,33 @@ Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedT These components are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers. +### `TopicSwitcher` + +Use for reader-facing navigation across equivalent documentation topics. + +```tsx + +``` + +`TopicSwitcher` is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config. + ## Guidance * Prefer the package root export for React doc-site rendering. diff --git a/packages/docs/agent-docs/docs/llms-full/authoring/components.txt b/packages/docs/agent-docs/docs/llms-full/authoring/components.txt index 3f991b9..9a9bd86 100644 --- a/packages/docs/agent-docs/docs/llms-full/authoring/components.txt +++ b/packages/docs/agent-docs/docs/llms-full/authoring/components.txt @@ -26,10 +26,13 @@ The root export is intentionally small. It gives consumers a ready-to-spread MDX `mdxComponents` includes: +* `Accordion` +* `AccordionItem` * `ExtractedTypeTable` * `Callout` * `Card` * `Cards` +* `Example` * `Mermaid` * `CommandTabs` * `Selector` @@ -37,6 +40,7 @@ The root export is intentionally small. It gives consumers a ready-to-spread MDX * `Steps` * `Tab` * `Tabs` +* `TopicSwitcher` * `TypeTable` Use it like this: @@ -54,6 +58,20 @@ Override individual entries rather than replacing the full map unless you want t ## Important Components +### `Accordion` and `AccordionItem` + +Use for secondary details that should be collapsible in the browser but still available to markdown conversion, search, and LLM bundles. + +```tsx + + + Use accordions for supporting details, troubleshooting notes, and optional reference material. + + +``` + +Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs. + ### `CommandTabs` Use for package-manager-specific install or run commands. @@ -73,6 +91,30 @@ Use for package-manager-specific install or run commands. Use `mode="install"` when `command` is a package name, `mode="run"` when `command` is a CLI name, and `mode="create"` for starter commands such as `pnpm create next-app`. `command` can also include a `{pm}` placeholder for custom templates. Use `commands` for exact per-manager overrides and `defaultManager` to choose the initial tab. +### `Example` + +Use for data-driven preview and source examples. The package component receives code as data; host apps can add filesystem loaders or dynamic imports outside `@inth/docs` when they need app-specific behavior. + +```tsx + + + The host app owns styling while `@inth/docs` owns the MDX component contract. + + +``` + +Use `sourceFiles` when an example needs to show supporting files in addition to the primary snippet. + ### `TypeTable` and `ExtractedTypeTable` Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedTypeTable` when the docs should extract types from source files. @@ -83,6 +125,33 @@ Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedT These components are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers. +### `TopicSwitcher` + +Use for reader-facing navigation across equivalent documentation topics. + +```tsx + +``` + +`TopicSwitcher` is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config. + ## Guidance * Prefer the package root export for React doc-site rendering. diff --git a/packages/docs/src/components/accordion.tsx b/packages/docs/src/components/accordion.tsx new file mode 100644 index 0000000..2c6f31c --- /dev/null +++ b/packages/docs/src/components/accordion.tsx @@ -0,0 +1,33 @@ +import type { DetailsHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +export type AccordionProps = HTMLAttributes & { + children?: ReactNode; +}; + +export function Accordion({ children, ...rest }: AccordionProps) { + return ( +
+ {children} +
+ ); +} + +export type AccordionItemProps = DetailsHTMLAttributes & { + title: string; + defaultOpen?: boolean; + children?: ReactNode; +}; + +export function AccordionItem({ + title, + defaultOpen, + children, + ...rest +}: AccordionItemProps) { + return ( +
+ {title} +
{children}
+
+ ); +} diff --git a/packages/docs/src/components/components.test.tsx b/packages/docs/src/components/components.test.tsx index 1ad4cff..eb10427 100644 --- a/packages/docs/src/components/components.test.tsx +++ b/packages/docs/src/components/components.test.tsx @@ -1,9 +1,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { Accordion, AccordionItem } from "./accordion"; import { Callout } from "./callout"; import { Card } from "./card"; import { CommandTabs } from "./command-tabs"; +import { Example } from "./example"; import { Mermaid } from "./mermaid"; +import { TopicSwitcher } from "./topic-switcher"; import { TypeTable } from "./type-table"; describe("component semantics", () => { @@ -80,4 +83,59 @@ describe("component semantics", () => { expect(markup).not.toContain("string"); }); + + it("renders accordion items as native details and summary elements", () => { + const markup = renderToStaticMarkup( + + Hidden content. + + ); + + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + + Preview content. + + ); + + expect(markup).toContain("Preview content."); + expect(markup).toContain("export const value = true;"); + expect(markup).toContain('data-language="ts"'); + expect(markup).toContain("example.ts"); + }); + + it("marks the active topic switcher item as the current page", () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('aria-current="page"'); + expect(markup).toContain("/docs/frameworks/react/quickstart"); + expect(markup).toContain("/docs/frameworks/vue/quickstart"); + }); }); diff --git a/packages/docs/src/components/example.tsx b/packages/docs/src/components/example.tsx new file mode 100644 index 0000000..99540f9 --- /dev/null +++ b/packages/docs/src/components/example.tsx @@ -0,0 +1,66 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type ExampleSourceFile = { + filename: string; + language?: string; + code: string; +}; + +export type ExampleProps = HTMLAttributes & { + title?: string; + description?: string; + filename?: string; + language?: string; + code: string; + sourceFiles?: ExampleSourceFile[]; + children?: ReactNode; +}; + +export function Example({ + title, + description, + filename, + language = "tsx", + code, + sourceFiles = [], + children, + ...rest +}: ExampleProps) { + return ( +
+ {title || description ? ( +
+ {title ?

{title}

: null} + {description ? ( +

{description}

+ ) : null} +
+ ) : null} + {children ?
{children}
: null} +
+ {filename ?

{filename}

: null} +
+          {code}
+        
+
+ {sourceFiles.length > 0 ? ( +
+ {sourceFiles.map((sourceFile) => ( +
+

{sourceFile.filename}

+
+                {sourceFile.code}
+              
+
+ ))} +
+ ) : null} +
+ ); +} diff --git a/packages/docs/src/components/index.ts b/packages/docs/src/components/index.ts index b255a77..fe5f3d9 100644 --- a/packages/docs/src/components/index.ts +++ b/packages/docs/src/components/index.ts @@ -1,5 +1,11 @@ /** @biome-ignore lint/performance/noBarrelFile: package entry point */ +export { + Accordion, + AccordionItem, + type AccordionItemProps, + type AccordionProps, +} from "./accordion"; export { Callout, type CalloutProps, @@ -12,6 +18,7 @@ export { type CommandTabsProps, type PackageManager, } from "./command-tabs"; +export { Example, type ExampleProps, type ExampleSourceFile } from "./example"; export { type MdxComponents, mdxComponents, @@ -24,6 +31,11 @@ export { } from "./selector"; export { Step, type StepProps, Steps, type StepsProps } from "./steps"; export { Tab, type TabProps, Tabs, type TabsProps } from "./tabs"; +export { + TopicSwitcher, + type TopicSwitcherItem, + type TopicSwitcherProps, +} from "./topic-switcher"; export { ExtractedTypeTable, type ExtractedTypeTableProps, diff --git a/packages/docs/src/components/mdx-components.ts b/packages/docs/src/components/mdx-components.ts index 4023024..552f07a 100644 --- a/packages/docs/src/components/mdx-components.ts +++ b/packages/docs/src/components/mdx-components.ts @@ -1,10 +1,13 @@ +import { Accordion, AccordionItem } from "./accordion"; import { Callout } from "./callout"; import { Card, Cards } from "./card"; import { CommandTabs } from "./command-tabs"; +import { Example } from "./example"; import { Mermaid } from "./mermaid"; import { Selector } from "./selector"; import { Step, Steps } from "./steps"; import { Tab, Tabs } from "./tabs"; +import { TopicSwitcher } from "./topic-switcher"; import { ExtractedTypeTable, TypeTable } from "./type-table"; /** @@ -18,10 +21,13 @@ import { ExtractedTypeTable, TypeTable } from "./type-table"; * const components = { ...mdxComponents, Callout: MyCallout }; */ export const mdxComponents = { + Accordion, + AccordionItem, ExtractedTypeTable, Callout, Card, Cards, + Example, Mermaid, CommandTabs, Selector, @@ -29,6 +35,7 @@ export const mdxComponents = { Steps, Tab, Tabs, + TopicSwitcher, TypeTable, } as const; diff --git a/packages/docs/src/components/topic-switcher.tsx b/packages/docs/src/components/topic-switcher.tsx new file mode 100644 index 0000000..1d5d8ad --- /dev/null +++ b/packages/docs/src/components/topic-switcher.tsx @@ -0,0 +1,60 @@ +import type { AnchorHTMLAttributes, HTMLAttributes } from "react"; + +export type TopicSwitcherItem = AnchorHTMLAttributes & { + value: string; + label: string; + href: string; + description?: string; + current?: boolean; +}; + +export type TopicSwitcherProps = HTMLAttributes & { + label?: string; + activeValue?: string; + items: TopicSwitcherItem[]; +}; + +export function TopicSwitcher({ + label = "Topics", + activeValue, + items, + ...rest +}: TopicSwitcherProps) { + return ( + + ); +} diff --git a/packages/docs/src/remark/index.ts b/packages/docs/src/remark/index.ts index 3c008fe..28f5afa 100644 --- a/packages/docs/src/remark/index.ts +++ b/packages/docs/src/remark/index.ts @@ -1,10 +1,12 @@ /** @biome-ignore lint/performance/noBarrelFile: package entry point */ export * from "./libs"; +export { remarkAccordionToMarkdown } from "./plugins/accordion.remark"; export { remarkCalloutToMarkdown } from "./plugins/callout.remark"; export { remarkCardsToMarkdown } from "./plugins/cards.remark"; export { remarkCommandTabsToMarkdown } from "./plugins/command-tabs.remark"; export { remarkResolveDocPlaceholders } from "./plugins/doc-placeholders.remark"; +export { remarkExampleToMarkdown } from "./plugins/example.remark"; export { remarkInclude } from "./plugins/include.remark"; export { remarkLinkIcon } from "./plugins/link-icon.remark"; export { remarkMermaidToMarkdown } from "./plugins/mermaid.remark"; @@ -16,19 +18,23 @@ export { extractTocFromFile, type TOCItem, } from "./plugins/toc-extract.remark"; +export { remarkTopicSwitcherToMarkdown } from "./plugins/topic-switcher.remark"; export { extractTypeFromFile, remarkTypeTableToMarkdown, } from "./plugins/type-table.remark"; +import { remarkAccordionToMarkdown } from "./plugins/accordion.remark"; import { remarkCalloutToMarkdown } from "./plugins/callout.remark"; import { remarkCardsToMarkdown } from "./plugins/cards.remark"; import { remarkCommandTabsToMarkdown } from "./plugins/command-tabs.remark"; import { remarkResolveDocPlaceholders } from "./plugins/doc-placeholders.remark"; +import { remarkExampleToMarkdown } from "./plugins/example.remark"; import { remarkMermaidToMarkdown } from "./plugins/mermaid.remark"; import { remarkRemoveImports } from "./plugins/remove-imports.remark"; import { remarkStepsToMarkdown } from "./plugins/steps.remark"; import { remarkTabsToMarkdown } from "./plugins/tabs.remark"; +import { remarkTopicSwitcherToMarkdown } from "./plugins/topic-switcher.remark"; import { remarkTypeTableToMarkdown } from "./plugins/type-table.remark"; /** @@ -46,4 +52,7 @@ export const defaultRemarkPlugins = [ remarkStepsToMarkdown, remarkTabsToMarkdown, remarkTypeTableToMarkdown, + remarkAccordionToMarkdown, + remarkTopicSwitcherToMarkdown, + remarkExampleToMarkdown, ] as const; diff --git a/packages/docs/src/remark/plugins/accordion.remark.ts b/packages/docs/src/remark/plugins/accordion.remark.ts new file mode 100644 index 0000000..a3f510a --- /dev/null +++ b/packages/docs/src/remark/plugins/accordion.remark.ts @@ -0,0 +1,79 @@ +import type { Root, RootContent } from "mdast"; +import type { Transformer } from "unified"; +import { + createJsxComponentProcessor, + createStrongParagraph, + getAttributeValue, + hasName, + type MdxNode, + normalizeWhitespace, + processContentNode, +} from "../libs"; + +function processItemContent(children: RootContent[]): RootContent[] { + const result: RootContent[] = []; + + for (const child of children) { + const processed = processContentNode(child); + if (processed) { + result.push(processed as RootContent); + } + } + + return result; +} + +function collectAccordionItems(node: MdxNode): MdxNode[] { + const children = (node.children ?? []) as RootContent[]; + const items: MdxNode[] = []; + + for (const child of children) { + if (hasName(child, "AccordionItem")) { + items.push(child as MdxNode); + continue; + } + + if (child.type !== "paragraph") { + continue; + } + + const paragraphChildren = + (child as { children?: RootContent[] }).children ?? []; + for (const paragraphChild of paragraphChildren) { + if (hasName(paragraphChild, "AccordionItem")) { + items.push(paragraphChild as MdxNode); + } + } + } + + return items; +} + +function fallbackTitle(item: MdxNode, index: number): string { + const explicit = normalizeWhitespace(getAttributeValue(item, "title") ?? ""); + return explicit || `Item ${index + 1}`; +} + +function itemToMarkdown(item: MdxNode, index: number): RootContent[] { + const title = fallbackTitle(item, index); + const children = (item.children ?? []) as RootContent[]; + const processedChildren = processItemContent(children); + + if (processedChildren.length === 0) { + return [createStrongParagraph(title)]; + } + + return [createStrongParagraph(title), ...processedChildren]; +} + +export function remarkAccordionToMarkdown(): Transformer { + return createJsxComponentProcessor("Accordion", (node) => { + const items = collectAccordionItems(node); + + if (items.length === 0) { + return []; + } + + return items.flatMap(itemToMarkdown); + }); +} diff --git a/packages/docs/src/remark/plugins/example.remark.ts b/packages/docs/src/remark/plugins/example.remark.ts new file mode 100644 index 0000000..c12ecf9 --- /dev/null +++ b/packages/docs/src/remark/plugins/example.remark.ts @@ -0,0 +1,144 @@ +import JSON5 from "json5"; +import type { Code, Root, RootContent } from "mdast"; +import type { Transformer } from "unified"; +import { + createJsxComponentProcessor, + createParagraph, + createStrongParagraph, + getAttributeValue, + normalizeWhitespace, + processContentNode, +} from "../libs"; + +type SourceFile = { + filename: string; + language?: string; + code: string; +}; + +function parseString(raw: string | null): string { + if (!raw) { + return ""; + } + + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + + if (trimmed.startsWith("`") && trimmed.endsWith("`")) { + return trimmed.slice(1, -1).replaceAll("\\`", "`").replaceAll("\\${", "${"); + } + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + try { + const parsed = JSON5.parse(trimmed); + return typeof parsed === "string" ? parsed : trimmed; + } catch { + return trimmed.slice(1, -1); + } + } + + return raw; +} + +function isSourceFile(value: unknown): value is SourceFile { + if (typeof value !== "object" || value === null) { + return false; + } + + const entry = value as Record; + return typeof entry.filename === "string" && typeof entry.code === "string"; +} + +function parseSourceFiles(raw: string | null): SourceFile[] { + if (!raw) { + return []; + } + + try { + const parsed = JSON5.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isSourceFile).map((entry) => ({ + filename: entry.filename, + language: typeof entry.language === "string" ? entry.language : undefined, + code: entry.code, + })); + } catch { + return []; + } +} + +function createCodeBlock(value: string, lang: string): Code { + return { + type: "code", + lang, + value, + }; +} + +function processPreview(children: RootContent[]): RootContent[] { + const result: RootContent[] = []; + + for (const child of children) { + const processed = processContentNode(child); + if (processed) { + result.push(processed as RootContent); + } + } + + return result; +} + +export function remarkExampleToMarkdown(): Transformer { + return createJsxComponentProcessor("Example", (node) => { + const title = normalizeWhitespace(getAttributeValue(node, "title") ?? ""); + const description = normalizeWhitespace( + getAttributeValue(node, "description") ?? "" + ); + const filename = normalizeWhitespace( + getAttributeValue(node, "filename") ?? "" + ); + const language = normalizeWhitespace( + getAttributeValue(node, "language") ?? "tsx" + ); + const code = parseString(getAttributeValue(node, "code")); + const sourceFiles = parseSourceFiles( + getAttributeValue(node, "sourceFiles") + ); + const replacement: RootContent[] = []; + + if (title) { + replacement.push(createStrongParagraph(title)); + } + + if (description) { + replacement.push(createParagraph(description)); + } + + replacement.push(...processPreview((node.children ?? []) as RootContent[])); + + if (filename) { + replacement.push(createStrongParagraph(filename)); + } + + if (code) { + replacement.push(createCodeBlock(code, language)); + } + + for (const sourceFile of sourceFiles) { + replacement.push(createStrongParagraph(sourceFile.filename)); + replacement.push( + createCodeBlock(sourceFile.code, sourceFile.language ?? language) + ); + } + + return replacement; + }); +} diff --git a/packages/docs/src/remark/plugins/topic-switcher.remark.ts b/packages/docs/src/remark/plugins/topic-switcher.remark.ts new file mode 100644 index 0000000..768c96f --- /dev/null +++ b/packages/docs/src/remark/plugins/topic-switcher.remark.ts @@ -0,0 +1,134 @@ +import JSON5 from "json5"; +import type { + Link, + List, + ListItem, + Paragraph, + PhrasingContent, + Root, +} from "mdast"; +import type { Transformer } from "unified"; +import { u } from "unist-builder"; +import { SKIP, visit } from "unist-util-visit"; +import { + deriveDocContext, + resolveDocPlaceholders, +} from "../../internal/docs-context"; +import { + createParagraph, + getAttributeValue, + hasName, + type MdxNode, + normalizeWhitespace, +} from "../libs"; + +type TopicItem = { + value: string; + label: string; + href: string; + description?: string; +}; + +function isTopicItem(value: unknown): value is TopicItem { + if (typeof value !== "object" || value === null) { + return false; + } + + const item = value as Record; + return ( + typeof item.value === "string" && + typeof item.label === "string" && + typeof item.href === "string" + ); +} + +function parseItems(raw: string | null): TopicItem[] { + if (!raw) { + return []; + } + + try { + const parsed = JSON5.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isTopicItem).map((item) => ({ + value: item.value, + label: item.label, + href: item.href, + description: + typeof item.description === "string" ? item.description : undefined, + })); + } catch { + return []; + } +} + +function itemToListItem( + item: TopicItem, + withDescriptions: boolean, + sourcePath: string +): ListItem { + const context = deriveDocContext(sourcePath); + const href = resolveDocPlaceholders(item.href, context).value; + const linkNode: Link = u("link", { url: href }, [ + u("text", item.label), + ]) as Link; + const children: PhrasingContent[] = [linkNode]; + + if (withDescriptions && item.description) { + children.push(u("text", ` — ${item.description}`) as PhrasingContent); + } + + const paragraph: Paragraph = u("paragraph", children) as Paragraph; + + return { + type: "listItem", + spread: false, + children: [paragraph], + }; +} + +export function remarkTopicSwitcherToMarkdown(): Transformer { + return (tree, file): Root => { + const sourcePath = String(file.path ?? ""); + + visit( + tree, + ["mdxJsxFlowElement", "mdxJsxTextElement"], + (node, index, parent) => { + if ( + !parent || + typeof index !== "number" || + !hasName(node, "TopicSwitcher") + ) { + return; + } + + const mdxNode = node as MdxNode; + const items = parseItems(getAttributeValue(mdxNode, "items")); + + if (items.length === 0) { + parent.children.splice(index, 1); + return SKIP; + } + + const label = normalizeWhitespace( + getAttributeValue(mdxNode, "label") ?? "Topics" + ); + const list: List = { + type: "list", + ordered: false, + spread: false, + children: items.map((item) => itemToListItem(item, true, sourcePath)), + }; + + parent.children.splice(index, 1, createParagraph(label), list); + return SKIP; + } + ); + + return tree; + }; +} diff --git a/packages/docs/src/remark/remark-output.test.ts b/packages/docs/src/remark/remark-output.test.ts index da322a5..fe45e5c 100644 --- a/packages/docs/src/remark/remark-output.test.ts +++ b/packages/docs/src/remark/remark-output.test.ts @@ -159,6 +159,123 @@ Body expect(result.markdown).toContain("url: /docs/frameworks/next/quickstart"); }); + it("keeps accordion content in markdown output", async () => { + const sourcePath = await createTempMdxFile( + "faq.mdx", + ` + + Yes. Closed content is still converted. + + +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain("**Can agents read this?**"); + expect(result.markdown).toContain( + "Yes. Closed content is still converted." + ); + }); + + it("converts examples with preview content and fenced code", async () => { + const sourcePath = await createTempMdxFile( + "example.mdx", + ` + Preview content survives conversion. + +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain("**Render MDX**"); + expect(result.markdown).toContain("Preview content survives conversion."); + expect(result.markdown).toContain("**mdx-components.tsx**"); + expect(result.markdown).toContain("```tsx"); + expect(result.markdown).toContain( + 'import { mdxComponents } from "@inth/docs";' + ); + }); + + it("converts topic switchers to markdown links", async () => { + const sourcePath = await createTempMdxFile( + "topics.mdx", + ` +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain("Framework"); + expect(result.markdown).toContain( + "[React](/docs/frameworks/react/quickstart) — React integration" + ); + expect(result.markdown).toContain( + "[Vue](/docs/frameworks/vue/quickstart) — Vue integration" + ); + }); + + it("resolves framework placeholders inside topic switcher item hrefs", async () => { + const sourcePath = await createTempMdxFile( + path.join("docs", "frameworks", "next", "quickstart.mdx"), + ` +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain( + "[Current framework](/docs/frameworks/next/quickstart)" + ); + }); + + it("includes new component plugins in the default remark pipeline", () => { + const pluginNames = defaultRemarkPlugins.map((plugin) => plugin.name); + + expect(pluginNames).toContain("remarkAccordionToMarkdown"); + expect(pluginNames).toContain("remarkExampleToMarkdown"); + expect(pluginNames).toContain("remarkTopicSwitcherToMarkdown"); + }); + it("preserves non-plain frontmatter values while resolving placeholders", async () => { const sourcePath = await createTempMdxFile( path.join("docs", "frameworks", "next", "quickstart.mdx"), From 340a291277581e14b2bec137ec98c893e239e8c9 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:33:28 -0400 Subject: [PATCH 2/2] Address docs component review feedback --- apps/docs-smoke/tests/e2e/smoke.e2e.ts | 5 +- .../docs/src/components/components.test.tsx | 15 ++- packages/docs/src/components/example.tsx | 23 +++- .../docs/src/components/topic-switcher.tsx | 6 +- .../docs/src/remark/plugins/example.remark.ts | 125 +++++++++++++++++- .../remark/plugins/topic-switcher.remark.ts | 15 ++- .../docs/src/remark/remark-output.test.ts | 56 ++++++++ 7 files changed, 231 insertions(+), 14 deletions(-) diff --git a/apps/docs-smoke/tests/e2e/smoke.e2e.ts b/apps/docs-smoke/tests/e2e/smoke.e2e.ts index aff859f..879261f 100644 --- a/apps/docs-smoke/tests/e2e/smoke.e2e.ts +++ b/apps/docs-smoke/tests/e2e/smoke.e2e.ts @@ -4,6 +4,7 @@ const DASHBOARD_HEADING = /Build docs with @inth\/docs/i; const QUICKSTART_ROUTE_LINK = /Quickstart/; const AI_DISABLED_MESSAGE = /AI answers are disabled/i; const QUICKSTART_INSTALL_HEADING_HREF = "/docs/guides/quickstart#1-install"; +const COMPONENT_FIXTURE_CALLOUT_COUNT = 3; async function waitForClientHydration(page: Page): Promise { await page.waitForFunction( @@ -130,7 +131,9 @@ test("components fixture renders package adapters and preserves external link sa await expect( page.getByRole("heading", { name: "Runtime Components", exact: true }) ).toBeVisible(); - await expect(page.locator("[data-inth-callout]")).toHaveCount(3); + await expect(page.locator("[data-inth-callout]")).toHaveCount( + COMPONENT_FIXTURE_CALLOUT_COUNT + ); await expect(page.locator("[data-inth-accordion]")).toBeVisible(); await expect(page.locator("[data-inth-cards]")).toBeVisible(); await expect(page.locator("[data-inth-example]")).toBeVisible(); diff --git a/packages/docs/src/components/components.test.tsx b/packages/docs/src/components/components.test.tsx index eb10427..f5e3a98 100644 --- a/packages/docs/src/components/components.test.tsx +++ b/packages/docs/src/components/components.test.tsx @@ -6,7 +6,7 @@ import { Card } from "./card"; import { CommandTabs } from "./command-tabs"; import { Example } from "./example"; import { Mermaid } from "./mermaid"; -import { TopicSwitcher } from "./topic-switcher"; +import { TopicSwitcher, type TopicSwitcherItem } from "./topic-switcher"; import { TypeTable } from "./type-table"; describe("component semantics", () => { @@ -138,4 +138,17 @@ describe("component semantics", () => { expect(markup).toContain("/docs/frameworks/react/quickstart"); expect(markup).toContain("/docs/frameworks/vue/quickstart"); }); + + it("does not throw when topic switcher content omits an href at runtime", () => { + const malformedItems = [ + { value: "broken", label: "Broken" }, + ] as unknown as TopicSwitcherItem[]; + + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain("Broken"); + expect(markup).toContain('aria-disabled="true"'); + }); }); diff --git a/packages/docs/src/components/example.tsx b/packages/docs/src/components/example.tsx index 99540f9..1af9c20 100644 --- a/packages/docs/src/components/example.tsx +++ b/packages/docs/src/components/example.tsx @@ -1,6 +1,9 @@ import type { HTMLAttributes, ReactNode } from "react"; +const SOURCE_FILE_KEY_HASH_MODULUS = 2_147_483_647; + export type ExampleSourceFile = { + id?: string; filename: string; language?: string; code: string; @@ -16,6 +19,24 @@ export type ExampleProps = HTMLAttributes & { children?: ReactNode; }; +function hashString(input: string): string { + let hash = 0; + + for (const character of input) { + const codePoint = character.codePointAt(0) ?? 0; + hash = (hash * 31 + codePoint) % SOURCE_FILE_KEY_HASH_MODULUS; + } + + return hash.toString(36); +} + +function sourceFileKey(sourceFile: ExampleSourceFile): string { + return ( + sourceFile.id ?? + `${sourceFile.filename}:${sourceFile.language ?? "tsx"}:${hashString(sourceFile.code)}` + ); +} + export function Example({ title, description, @@ -48,7 +69,7 @@ export function Example({ {sourceFiles.map((sourceFile) => (

{sourceFile.filename}

 {
             const isActive = current || value === activeValue;
+            const href = item.href ?? "";
             const isExternal =
-              item.href.startsWith("http://") ||
-              item.href.startsWith("https://");
+              href.startsWith("http://") || href.startsWith("https://");
 
             return (
               
  • diff --git a/packages/docs/src/remark/plugins/example.remark.ts b/packages/docs/src/remark/plugins/example.remark.ts index c12ecf9..fd68f5f 100644 --- a/packages/docs/src/remark/plugins/example.remark.ts +++ b/packages/docs/src/remark/plugins/example.remark.ts @@ -11,11 +11,103 @@ import { } from "../libs"; type SourceFile = { + id?: string; filename: string; language?: string; code: string; }; +function decodeTemplateLiteralValue(value: string): string { + let result = ""; + let escaped = false; + + for (const character of value) { + if (escaped) { + if (character === "n") { + result += "\n"; + } else if (character === "r") { + result += "\r"; + } else if (character === "t") { + result += "\t"; + } else { + result += character; + } + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + result += character; + } + + return escaped ? `${result}\\` : result; +} + +function readTemplateLiteral( + input: string, + startIndex: number +): { value: string; endIndex: number } | null { + let current = ""; + let escaped = false; + + for (let index = startIndex + 1; index < input.length; index += 1) { + const character = input[index]; + + if (character === undefined) { + return null; + } + + if (escaped) { + current += `\\${character}`; + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character === "`") { + return { + value: decodeTemplateLiteralValue(current), + endIndex: index, + }; + } + + current += character; + } + + return null; +} + +function templateLiteralsToJsonStrings(raw: string): string | null { + let result = ""; + + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + + if (character !== "`") { + result += character; + continue; + } + + const literal = readTemplateLiteral(raw, index); + if (!literal) { + return null; + } + + result += JSON.stringify(literal.value); + index = literal.endIndex; + } + + return result; +} + function parseString(raw: string | null): string { if (!raw) { return ""; @@ -27,7 +119,7 @@ function parseString(raw: string | null): string { } if (trimmed.startsWith("`") && trimmed.endsWith("`")) { - return trimmed.slice(1, -1).replaceAll("\\`", "`").replaceAll("\\${", "${"); + return decodeTemplateLiteralValue(trimmed.slice(1, -1)); } if ( @@ -50,8 +142,12 @@ function isSourceFile(value: unknown): value is SourceFile { return false; } - const entry = value as Record; - return typeof entry.filename === "string" && typeof entry.code === "string"; + const hasRequiredKeys = "filename" in value && "code" in value; + if (!hasRequiredKeys) { + return false; + } + + return typeof value.filename === "string" && typeof value.code === "string"; } function parseSourceFiles(raw: string | null): SourceFile[] { @@ -66,12 +162,33 @@ function parseSourceFiles(raw: string | null): SourceFile[] { } return parsed.filter(isSourceFile).map((entry) => ({ + id: typeof entry.id === "string" ? entry.id : undefined, filename: entry.filename, language: typeof entry.language === "string" ? entry.language : undefined, code: entry.code, })); } catch { - return []; + const normalized = templateLiteralsToJsonStrings(raw); + if (!normalized) { + return []; + } + + try { + const parsed = JSON5.parse(normalized); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isSourceFile).map((entry) => ({ + id: typeof entry.id === "string" ? entry.id : undefined, + filename: entry.filename, + language: + typeof entry.language === "string" ? entry.language : undefined, + code: entry.code, + })); + } catch { + return []; + } } } diff --git a/packages/docs/src/remark/plugins/topic-switcher.remark.ts b/packages/docs/src/remark/plugins/topic-switcher.remark.ts index 768c96f..a1ad8fc 100644 --- a/packages/docs/src/remark/plugins/topic-switcher.remark.ts +++ b/packages/docs/src/remark/plugins/topic-switcher.remark.ts @@ -34,11 +34,16 @@ function isTopicItem(value: unknown): value is TopicItem { return false; } - const item = value as Record; + const hasRequiredKeys = + "value" in value && "label" in value && "href" in value; + if (!hasRequiredKeys) { + return false; + } + return ( - typeof item.value === "string" && - typeof item.label === "string" && - typeof item.href === "string" + typeof value.value === "string" && + typeof value.label === "string" && + typeof value.href === "string" ); } @@ -111,7 +116,7 @@ export function remarkTopicSwitcherToMarkdown(): Transformer { if (items.length === 0) { parent.children.splice(index, 1); - return SKIP; + return [SKIP, index]; } const label = normalizeWhitespace( diff --git a/packages/docs/src/remark/remark-output.test.ts b/packages/docs/src/remark/remark-output.test.ts index fe45e5c..22ee451 100644 --- a/packages/docs/src/remark/remark-output.test.ts +++ b/packages/docs/src/remark/remark-output.test.ts @@ -208,6 +208,37 @@ export const components = { ); }); + it("keeps example source files authored with template literals", async () => { + const sourcePath = await createTempMdxFile( + "example-source-files.mdx", + ` + Preview. + +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain("**support.ts**"); + expect(result.markdown).toContain("```ts"); + expect(result.markdown).toContain('export const message = "support";'); + expect(result.markdown).toContain("export const enabled = true;"); + }); + it("converts topic switchers to markdown links", async () => { const sourcePath = await createTempMdxFile( "topics.mdx", @@ -268,6 +299,31 @@ export const components = { ); }); + it("continues visiting siblings after removing an empty topic switcher", async () => { + const sourcePath = await createTempMdxFile( + "empty-topic-switcher.mdx", + ` + + +` + ); + + const result = await convertMdxToMarkdown(sourcePath, defaultRemarkPlugins); + + expect(result.markdown).toContain( + "[React](/docs/frameworks/react/quickstart)" + ); + }); + it("includes new component plugins in the default remark pipeline", () => { const pluginNames = defaultRemarkPlugins.map((plugin) => plugin.name);