diff --git a/examples/astro-docs/README.md b/examples/astro-docs/README.md index 704e9f4..73d3be8 100644 --- a/examples/astro-docs/README.md +++ b/examples/astro-docs/README.md @@ -18,6 +18,9 @@ npm run test:e2e --workspace=@inkform/example-astro-docs # Playwright: builds, every component in the kit. - `content/blog/` and `content/changelog/` turn on `/docs/blog` and `/docs/changelog`. +- [`src/mdx-components.tsx`](./src/mdx-components.tsx) is the site's own MDX + component (``), registered with the `components` option and + styled by `src/styles/docs.css` through `customCss`. - [`e2e/docs.spec.ts`](./e2e/docs.spec.ts) checks that every page renders with no CSP violation, that tabs, code groups, accordions, the TOC, the theme toggle, the drawer, diagrams, the API sample pickers and Try It work, that diff --git a/examples/astro-docs/astro.config.ts b/examples/astro-docs/astro.config.ts index d5e204c..4227497 100644 --- a/examples/astro-docs/astro.config.ts +++ b/examples/astro-docs/astro.config.ts @@ -9,7 +9,17 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), - integrations: [react(), inkform({ base: '/docs', contentRoot: './content', mermaid: true })], + integrations: [ + react(), + inkform({ + base: '/docs', + contentRoot: './content', + mermaid: true, + // The site's own MDX components, and the CSS they use. + components: './src/mdx-components.tsx', + customCss: ['./src/styles/docs.css'], + }), + ], // This site has no Markdown pages of its own. Docs code blocks are // highlighted by the integration, which keeps their colors CSP-safe. markdown: { syntaxHighlight: false }, diff --git a/examples/astro-docs/content/docs/essentials/components.mdx b/examples/astro-docs/content/docs/essentials/components.mdx index 1d493f6..5812e18 100644 --- a/examples/astro-docs/content/docs/essentials/components.mdx +++ b/examples/astro-docs/content/docs/essentials/components.mdx @@ -140,6 +140,13 @@ sequenceDiagram +## Site components + +A site registers its own components with the integration's `components` +option. This one comes from `src/mdx-components.tsx`: + + + ## Placeholders diff --git a/examples/astro-docs/e2e/docs.spec.ts b/examples/astro-docs/e2e/docs.spec.ts index 8473d97..82a67b2 100644 --- a/examples/astro-docs/e2e/docs.spec.ts +++ b/examples/astro-docs/e2e/docs.spec.ts @@ -176,6 +176,14 @@ test.describe('components', () => { } }); + test('a component from the site’s components module renders in MDX', async ({ page }) => { + await page.goto('/docs/essentials/components'); + const shot = page.locator('main .fw-prose .fw-frame .example-shot'); + await expect(shot).toHaveAttribute('data-shot', 'settings-page.png'); + await expect(shot).toHaveText('Screenshot coming'); + await expect(page.locator('main .fw-prose .fw-frame-caption', { hasText: 'The settings page.' })).toBeVisible(); + }); + test('tabs switch on click and with the arrow keys', async ({ page }) => { await page.goto('/docs/essentials/components'); const tabs = page.locator('.fw-tabs').first(); diff --git a/examples/astro-docs/src/mdx-components.tsx b/examples/astro-docs/src/mdx-components.tsx new file mode 100644 index 0000000..b2e4752 --- /dev/null +++ b/examples/astro-docs/src/mdx-components.tsx @@ -0,0 +1,20 @@ +/** + * This site's own MDX components, registered with the integration's + * `components` option. Every capitalized export can be used in any docs page + * without an import. React renders them to HTML at build time, so they can't + * use state or effects in the browser. + */ + +/** A slot for a screenshot that hasn't been taken yet, framed like the kit's . */ +export function Screenshot({ file, caption }: { file: string; caption?: string }) { + return ( +
+
+
+ Screenshot coming +
+
+ {caption ?
{caption}
: null} +
+ ); +} diff --git a/examples/astro-docs/src/styles/docs.css b/examples/astro-docs/src/styles/docs.css new file mode 100644 index 0000000..374abaa --- /dev/null +++ b/examples/astro-docs/src/styles/docs.css @@ -0,0 +1,20 @@ +/* Styles for this site's own MDX components (src/mdx-components.tsx), loaded on docs pages through `customCss`. */ + +.example-shot { + display: grid; + place-content: center; + gap: 0.25rem; + aspect-ratio: 21 / 9; + padding: 1rem; + background: var(--fw-bg-subtle); + color: var(--fw-muted); + font-weight: 600; + text-align: center; +} + +.example-shot::after { + content: attr(data-shot); + font-family: var(--fw-mono); + font-size: 0.75rem; + font-weight: 400; +} diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 1bb2d2a..e20f507 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -27,6 +27,11 @@ Versions track `packages/astro/package.json`. Format loosely follows ### Added +- `components` option: a module of the site's own MDX components. Every + capitalized export works in MDX without imports, next to the kit's, and a + kit name it reuses replaces the kit's component. A `.tsx`/`.jsx` module + works in a site without `@astrojs/react`: the integration has Vite compile + its JSX for React when no UI integration does. - `--fw-label-transform`, `--fw-label-letter-spacing` and `--fw-label-font-size` set the case, tracking and size of the sidebar's group names and the "On this page" title, which stay small uppercase by diff --git a/packages/astro/README.md b/packages/astro/README.md index bd644ca..5b8f7a5 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -77,6 +77,7 @@ content/ | `contentRoot` | `'./content'` | The folder holding `docs/docs.json`, relative to the project root. | | `theme` | `'galley'` | `'galley'`, `'plain'` (the framework's neutral tokens), or a path to your own CSS file of `--fw-*` overrides. | | `customCss` | `[]` | More CSS files to load on docs pages, after the theme. | +| `components` | none | A module of your own MDX components, relative to the project root (see [Your own components](#your-own-components)). | | `search` | `true` | Build a Pagefind index after `astro build` and show the search dialog. | | `mermaid` | `false` | Draw ```` ```mermaid ```` fences as diagrams in the browser. Needs `npm install mermaid`. Off, they show their source. | | `llmsTxt` | `true` | Serve `llms.txt` and `llms-full.txt`. | @@ -99,6 +100,53 @@ without JavaScript, search and view-source see all of it. Accordions and `` links to that operation's page and fails the build if the operation doesn't exist. +### Your own components + +Point `components` at a module, and every export whose name starts with a +capital letter works in MDX without imports, next to the kit's components: + +```ts +inkform({ + components: './src/mdx-components.tsx', + customCss: ['./src/styles/docs.css'], // styles for them, if they need any +}); +``` + +```tsx +// src/mdx-components.tsx +import { Frame } from '@inkform/framework/components'; + +export function Screenshot({ file, caption }: { file: string; caption: string }) { + return ( + + {caption} + + ); +} +``` + +```mdx + +``` + +- They are React components, rendered to HTML at build time like the kit. + Props and children work; state and effects don't run in the browser. +- A name the kit also has (`Frame`, `Card`) replaces the kit's component on + every page. +- `export default { Screenshot }` works too. Exports that aren't components + (helpers, constants) are ignored. +- The kit's components are exported from `@inkform/framework/components`, + which `@inkform/astro` installs, so yours can wrap them. With a strict + package manager (pnpm), add `@inkform/framework` to your dependencies. +- A `.tsx` or `.jsx` module works without `@astrojs/react`: when the site has + no UI framework integration, the integration has Vite compile JSX for + React. With Preact, Solid or Vue installed, those compile JSX instead, so + write the module with `createElement` from `react` in a `.ts` file. For + type checking, set `"jsx": "react-jsx"` and `"jsxImportSource": "react"` + in `tsconfig.json`. +- Image `src`s and links they render get Astro's `base` like the rest of the + content. + Links in content written from the site root, the way content for a Next.js theme is (`[Quickstart](/quickstart)`), get the docs base added when they point at a docs page. See [Astro's `base` and `trailingSlash`](#astros-base-and-trailingslash) diff --git a/packages/astro/src/index.ts b/packages/astro/src/index.ts index 4ec0eb1..de43e7c 100644 --- a/packages/astro/src/index.ts +++ b/packages/astro/src/index.ts @@ -15,7 +15,14 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { AstroIntegration } from 'astro'; -import { docsRoutePattern, InkformOptionsError, resolveOptions, type InkformOptions, type ResolvedOptions } from './options'; +import { + docsRoutePattern, + InkformOptionsError, + needsReactJsx, + resolveOptions, + type InkformOptions, + type ResolvedOptions, +} from './options'; import { buildSearchIndex } from './search-index'; import { inkformVitePlugin } from './vite-plugin'; @@ -56,6 +63,9 @@ export default function inkform(options: InkformOptions = {}): AstroIntegration for (const file of css) { if (!existsSync(file)) throw new InkformOptionsError(`${PACKAGE}: CSS file not found: ${file}`); } + if (resolved.components && !existsSync(resolved.components)) { + throw new InkformOptionsError(`${PACKAGE}: the \`components\` module was not found: ${resolved.components}`); + } injectRoute({ pattern: docsRoutePattern(resolved.base), @@ -71,9 +81,15 @@ export default function inkform(options: InkformOptions = {}): AstroIntegration }); } + const viteOxc = config.vite?.oxc; + const reactJsx = needsReactJsx(resolved.components, config.integrations, viteOxc ? viteOxc.jsx : undefined); + updateConfig({ vite: { plugins: [inkformVitePlugin({ options: resolved, css, mermaidClient: srcFile('client', 'mermaid.ts') })], + // A JSX components module in a site with no UI integration: compile + // its JSX for React, which renders it at build time. + ...(reactJsx ? { oxc: { jsx: { runtime: 'automatic' as const, importSource: 'react' } } } : {}), // Both packages ship TypeScript source for Vite to compile. ssr: { noExternal: ['@inkform/framework', PACKAGE] }, // In dev, prebundle what the diagram script loads on demand, so diff --git a/packages/astro/src/options.test.ts b/packages/astro/src/options.test.ts index 47603f5..e590c7a 100644 --- a/packages/astro/src/options.test.ts +++ b/packages/astro/src/options.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { docsRoutePattern, normalizeBase, resolveOptions, type HostConfig } from './options'; +import { docsRoutePattern, needsReactJsx, normalizeBase, resolveOptions, type HostConfig } from './options'; const host: HostConfig = { root: '/site', siteBase: '/', trailingSlash: 'ignore', csp: false }; @@ -58,6 +58,11 @@ describe('resolveOptions', () => { expect(r.customCss).toEqual([path.resolve('/site', 'fonts.css')]); }); + it('resolves the components module against the project root, and has none by default', () => { + expect(resolveOptions({}, host).components).toBeNull(); + expect(resolveOptions({ components: './src/mdx-components.tsx' }, host).components).toBe(path.resolve('/site', 'src/mdx-components.tsx')); + }); + it('keeps built-in theme names', () => { expect(resolveOptions({ theme: 'plain' }, host).theme).toBe('plain'); }); @@ -73,3 +78,20 @@ describe('docsRoutePattern', () => { expect(docsRoutePattern('')).toBe('/[...slug]'); }); }); + +describe('needsReactJsx', () => { + const tsx = '/site/src/mdx-components.tsx'; + + it('compiles JSX for a .jsx/.tsx components module when nothing else in the site does', () => { + expect(needsReactJsx(tsx, [{ name: '@inkform/astro' }], undefined)).toBe(true); + expect(needsReactJsx('/site/src/c.jsx', [], undefined)).toBe(true); + }); + + it('leaves it alone without a JSX module, beside a UI integration, or when the site sets it', () => { + expect(needsReactJsx(null, [], undefined)).toBe(false); + expect(needsReactJsx('/site/src/c.ts', [], undefined)).toBe(false); + expect(needsReactJsx(tsx, [{ name: '@astrojs/react' }], undefined)).toBe(false); + expect(needsReactJsx(tsx, [{ name: '@astrojs/preact' }], undefined)).toBe(false); + expect(needsReactJsx(tsx, [], 'preserve')).toBe(false); + }); +}); diff --git a/packages/astro/src/options.ts b/packages/astro/src/options.ts index 728ae44..4dc7fc4 100644 --- a/packages/astro/src/options.ts +++ b/packages/astro/src/options.ts @@ -36,6 +36,14 @@ export interface InkformOptions { theme?: 'galley' | 'plain' | (string & {}); /** More CSS files to load on docs pages after the theme, relative to the project root. */ customCss?: string[]; + /** + * A module of your own MDX components, relative to the project root, e.g. + * `'./src/mdx-components.tsx'`. Every export whose name starts with a + * capital letter can be used in any MDX page without an import, next to + * the kit's components; a name the kit also has replaces the kit's. They + * are React components, rendered to HTML at build time. + */ + components?: string; /** Build a Pagefind search index after `astro build`. Default `true`. */ search?: boolean; /** @@ -78,6 +86,8 @@ export interface ResolvedOptions { theme: string; /** Absolute paths. */ customCss: string[]; + /** Absolute path of the site's MDX components module, or null. */ + components: string | null; search: boolean; mermaid: boolean; llmsTxt: boolean; @@ -122,6 +132,7 @@ export function resolveOptions(options: InkformOptions, host: HostConfig): Resol contentRoot: path.resolve(host.root, options.contentRoot ?? './content'), theme: isBuiltIn ? theme : path.resolve(host.root, theme), customCss: (options.customCss ?? []).map((file) => path.resolve(host.root, file)), + components: options.components ? path.resolve(host.root, options.components) : null, search: options.search ?? true, mermaid: options.mermaid ?? false, llmsTxt: options.llmsTxt ?? true, @@ -131,6 +142,22 @@ export function resolveOptions(options: InkformOptions, host: HostConfig): Resol }; } +/** Integrations that set up their own JSX compilation. */ +const JSX_INTEGRATIONS = ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js', '@astrojs/vue']; + +/** + * Whether the integration should have Vite compile `.jsx`/`.tsx` as React's + * automatic JSX, so a `components` module can be written in JSX. Astro's + * tsconfig preset leaves JSX uncompiled (`"jsx": "preserve"`) unless a UI + * integration sets it up, so a site without one fails to parse the module. + * Not done when another integration compiles JSX, or when the site sets + * `vite.oxc.jsx` itself. + */ +export function needsReactJsx(components: string | null, integrations: readonly { name: string }[], viteOxcJsx: unknown): boolean { + if (!components || !/\.[jt]sx$/.test(components) || viteOxcJsx !== undefined) return false; + return !integrations.some((i) => JSX_INTEGRATIONS.includes(i.name)); +} + /** The catch-all route pattern every docs page is served from. */ export function docsRoutePattern(base: string): string { return `${base}/[...slug]`; diff --git a/packages/astro/src/runtime/mdx-components.ts b/packages/astro/src/runtime/mdx-components.ts index 246c455..d352d69 100644 --- a/packages/astro/src/runtime/mdx-components.ts +++ b/packages/astro/src/runtime/mdx-components.ts @@ -10,10 +10,13 @@ * - `NewsletterCTA` prints its endpoint and project on the form, for * ../client/kit.ts to submit without React. * + * The site's own components (the `components` option) are picked out of its + * module by `userComponents()` and merged over all of these. + * * Written with createElement, not JSX, so this file needs no JSX settings. */ -import { createElement as h, type ReactNode } from 'react'; +import { createElement as h, type ComponentType, type ReactNode } from 'react'; export interface OperationRef { href: string; @@ -85,3 +88,30 @@ export function siteComponents(options: SiteComponentOptions) { ...(options.mermaid ? { Mermaid: MermaidFigure } : {}), }; } + +export type MdxComponent = ComponentType>; + +/** A function component, a class, or one of React's wrapper objects (`memo`, `forwardRef`, `lazy`). */ +function isComponent(value: unknown): value is MdxComponent { + return typeof value === 'function' || (typeof value === 'object' && value !== null && '$$typeof' in value); +} + +/** + * The MDX components in the site's `components` module: every export whose + * name starts with a capital letter and is a component, plus the same from a + * default export that is an object (`export default { Screenshot }`). Named + * exports win over the default object's entries. Anything else in the module + * (helpers, constants) is ignored. + */ +export function userComponents(mod: Record): Record { + const out: Record = {}; + const pick = (entries: Record) => { + for (const [name, value] of Object.entries(entries)) { + if (/^[A-Z]/.test(name) && isComponent(value)) out[name] = value; + } + }; + const fallback = mod.default; + if (typeof fallback === 'object' && fallback !== null && !isComponent(fallback)) pick(fallback as Record); + pick(mod); + return out; +} diff --git a/packages/astro/src/runtime/render.test.ts b/packages/astro/src/runtime/render.test.ts index 79dc1fc..ea9bf2d 100644 --- a/packages/astro/src/runtime/render.test.ts +++ b/packages/astro/src/runtime/render.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { headingsFromHtml } from './headings'; import { docsLinkRewriter } from './inline-styles'; +import { createElement, forwardRef, memo, type ReactNode } from 'react'; +import { userComponents } from './mdx-components'; import { PageRenderer } from './render'; import { createLinker } from './routes'; import type { ApiOperation } from '@inkform/framework/openapi'; @@ -143,6 +145,44 @@ describe('PageRenderer.mdx', () => { }); }); +describe('site components', () => { + function Screenshot({ file, caption }: { file?: string; caption?: string }) { + return createElement('figure', { className: 'shot', 'data-shot': file }, createElement('figcaption', null, caption)); + } + function Frame({ children }: { children?: ReactNode }) { + return createElement('div', { className: 'my-frame' }, children); + } + + it('picks capitalized components from the module, default object included, named exports first', () => { + const Wrapped = memo(Screenshot); + const Ref = forwardRef(function Ref() { + return null; + }); + const fromDefault = () => null; + const picked = userComponents({ + Screenshot, + Wrapped, + Ref, + helper: () => null, + SHOTS: ['a.png'], + default: { Badge: fromDefault, Screenshot: fromDefault, lower: fromDefault }, + }); + expect(Object.keys(picked).sort()).toEqual(['Badge', 'Ref', 'Screenshot', 'Wrapped']); + expect(picked.Screenshot).toBe(Screenshot); + expect(userComponents({})).toEqual({}); + }); + + it('renders them in MDX without imports, over the kit’s own, with the fallback kept for unknown names', async () => { + const r = renderer({ components: userComponents({ Screenshot, Frame }) }); + const html = await r.mdx('\n\ninside\n\n\n\nkit'); + expect(html).toContain('
Create the app
'); + expect(html).toContain('
inside
'); + expect(html).not.toContain('fw-frame'); + expect(html).toContain('fw-unknown'); + expect(html).toContain('fw-callout'); + }); +}); + describe('PageRenderer.operation', () => { const operation: ApiOperation = { method: 'post', diff --git a/packages/astro/src/runtime/render.ts b/packages/astro/src/runtime/render.ts index b1361b9..63c1ed9 100644 --- a/packages/astro/src/runtime/render.ts +++ b/packages/astro/src/runtime/render.ts @@ -9,24 +9,24 @@ * framework's CSS styles) matches the Next.js themes exactly. */ -import { createElement, type ComponentType, type ReactNode } from 'react'; +import { createElement, type ReactNode } from 'react'; import { prerender } from 'react-dom/static'; import { mdxComponents } from '@inkform/framework/components'; import { compileMdx } from '@inkform/framework/mdx-options'; import type { ApiOperation, OpenApiServer } from '@inkform/framework/openapi'; import { OperationPage } from '@inkform/framework/openapi-render'; import { InlineStyleSheet, type HrefRewriter } from './inline-styles'; -import { siteComponents, type SiteComponentOptions } from './mdx-components'; +import { siteComponents, type MdxComponent, type SiteComponentOptions } from './mdx-components'; export interface PageRendererOptions extends SiteComponentOptions { + /** The site's own MDX components, merged over the kit's (see userComponents). */ + components?: Record; /** Applied to every link in the rendered HTML (see siteHrefRewriter). */ rewriteHref?: HrefRewriter; /** Applied to every image source (see assetRewriter). */ rewriteSrc?: HrefRewriter; } -type MdxComponent = ComponentType>; - /** Renders a React tree to an HTML string, waiting for async components (OperationPage, ApiLink). */ export async function renderToHtml(node: ReactNode, idPrefix: string): Promise { const { prelude } = await prerender(node, { identifierPrefix: idPrefix }); @@ -42,7 +42,7 @@ export class PageRenderer { /** An MDX body (frontmatter already removed) as HTML, inside `.fw-prose`. */ async mdx(source: string, extra: Record = {}): Promise { - const components = mdxComponents({ ...siteComponents(this.options), ...extra }); + const components = mdxComponents({ ...siteComponents(this.options), ...this.options.components, ...extra }); const element = await compileMdx(source, components); const html = await renderToHtml(createElement('div', { className: 'fw-prose' }, element), this.nextPrefix()); return this.styles.extract(html, { href: this.options.rewriteHref, src: this.options.rewriteSrc }); diff --git a/packages/astro/src/runtime/site.ts b/packages/astro/src/runtime/site.ts index 8ac3a60..8dca5f9 100644 --- a/packages/astro/src/runtime/site.ts +++ b/packages/astro/src/runtime/site.ts @@ -6,6 +6,7 @@ */ import config from 'virtual:@inkform/astro/config'; +import siteComponentsModule from 'virtual:@inkform/astro/components'; import type { BlogPost, ChangelogEntry } from '@inkform/framework/content'; import { loadBlogPosts, loadChangelogEntries, loadDocsConfig, loadOpenApiSpecForBuild } from '@inkform/framework/content'; import type { DocsConfig, DocsTab } from '@inkform/framework/nav'; @@ -15,7 +16,7 @@ import { parseOpenApiDocument } from '@inkform/framework/openapi-engine/parse'; import { apiTab, apiTabSlug } from '@inkform/framework/routes'; import { buildRoutes, createLinker, operationPath, routeIndex, type Linker, type SiteRoute } from './routes'; import { assetRewriter, siteHrefRewriter } from './inline-styles'; -import type { OperationRef } from './mdx-components'; +import { userComponents, type OperationRef } from './mdx-components'; import { PageRenderer } from './render'; export interface ApiReference { @@ -117,7 +118,8 @@ async function readSite(): Promise { trailingSlash: config.trailingSlash, }); const rewriteSrc = assetRewriter(config.siteBase); - const renderer = () => new PageRenderer({ mermaid: config.mermaid, operation, rewriteHref, rewriteSrc }); + const components = userComponents(siteComponentsModule); + const renderer = () => new PageRenderer({ mermaid: config.mermaid, operation, rewriteHref, rewriteSrc, components }); const asset = (path: string) => rewriteSrc(path) ?? path; const href = (value: string) => rewriteHref(value) ?? value; diff --git a/packages/astro/src/virtual.d.ts b/packages/astro/src/virtual.d.ts index 5b89188..be2c13e 100644 --- a/packages/astro/src/virtual.d.ts +++ b/packages/astro/src/virtual.d.ts @@ -12,3 +12,9 @@ declare module 'virtual:@inkform/astro/mermaid' { /** Renders every `[data-fw-mermaid]` figure on the page; a no-op when the `mermaid` option is off. */ export function mountMermaid(): void; } + +declare module 'virtual:@inkform/astro/components' { + /** The site's `components` module (every export), or `{}` without the option. */ + const components: Record; + export default components; +} diff --git a/packages/astro/src/vite-plugin.test.ts b/packages/astro/src/vite-plugin.test.ts index 416f77c..289c9ef 100644 --- a/packages/astro/src/vite-plugin.test.ts +++ b/packages/astro/src/vite-plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { resolveOptions } from './options'; -import { inkformVitePlugin, VIRTUAL_CONFIG, VIRTUAL_MERMAID, VIRTUAL_USER_CSS, virtualModuleSource } from './vite-plugin'; +import { inkformVitePlugin, VIRTUAL_COMPONENTS, VIRTUAL_CONFIG, VIRTUAL_MERMAID, VIRTUAL_USER_CSS, virtualModuleSource } from './vite-plugin'; const options = resolveOptions({}, { root: '/site', siteBase: '/', trailingSlash: 'ignore', csp: false }); const input = { options, css: ['/pkg/themes/galley.css', '/site/fonts.css'], mermaidClient: '/pkg/client/mermaid.ts' }; @@ -21,9 +21,18 @@ describe('virtual modules', () => { expect(virtualModuleSource(VIRTUAL_MERMAID, on)).toBe('export { mountMermaid } from "/pkg/client/mermaid.ts";'); }); + it('re-exports the site’s components module, or nothing without the option', () => { + expect(virtualModuleSource(VIRTUAL_COMPONENTS, input)).toBe('export default {};'); + const on = { ...input, options: { ...options, components: '/site/src/mdx-components.tsx' } }; + expect(virtualModuleSource(VIRTUAL_COMPONENTS, on)).toBe( + 'import * as components from "/site/src/mdx-components.tsx";\nexport default components;', + ); + }); + it('resolves only its own ids', () => { const plugin = inkformVitePlugin(input) as unknown as { resolveId: (id: string) => string | undefined }; expect(plugin.resolveId(VIRTUAL_CONFIG)).toBe(`\0${VIRTUAL_CONFIG}`); + expect(plugin.resolveId(VIRTUAL_COMPONENTS)).toBe(`\0${VIRTUAL_COMPONENTS}`); expect(plugin.resolveId('react')).toBeUndefined(); }); }); diff --git a/packages/astro/src/vite-plugin.ts b/packages/astro/src/vite-plugin.ts index 8131e79..643bc23 100644 --- a/packages/astro/src/vite-plugin.ts +++ b/packages/astro/src/vite-plugin.ts @@ -7,6 +7,8 @@ * - `virtual:@inkform/astro/mermaid`: the diagram renderer when the `mermaid` * option is on, an empty function when it's off, so a site without diagrams * never resolves (or needs) the `mermaid` package. + * - `virtual:@inkform/astro/components`: the site's `components` module as a + * namespace object, or an empty one without the option. */ import path from 'node:path'; @@ -18,7 +20,8 @@ type VitePlugin = NonNullable['plugins']>[numbe export const VIRTUAL_CONFIG = 'virtual:@inkform/astro/config'; export const VIRTUAL_USER_CSS = 'virtual:@inkform/astro/user-css'; export const VIRTUAL_MERMAID = 'virtual:@inkform/astro/mermaid'; -const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_USER_CSS, VIRTUAL_MERMAID]); +export const VIRTUAL_COMPONENTS = 'virtual:@inkform/astro/components'; +const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_USER_CSS, VIRTUAL_MERMAID, VIRTUAL_COMPONENTS]); export interface VitePluginInput { options: ResolvedOptions; @@ -41,6 +44,10 @@ export function virtualModuleSource(id: string, { options, css, mermaidClient }: return options.mermaid ? `export { mountMermaid } from ${importPath(mermaidClient)};` : 'export function mountMermaid() {}'; + case VIRTUAL_COMPONENTS: + return options.components + ? `import * as components from ${importPath(options.components)};\nexport default components;` + : 'export default {};'; default: return undefined; }