Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/astro-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<Screenshot>`), 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
Expand Down
12 changes: 11 additions & 1 deletion examples/astro-docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
7 changes: 7 additions & 0 deletions examples/astro-docs/content/docs/essentials/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ sequenceDiagram

<NewsletterCTA projectId="demo" heading="Get the next post by email" />

## Site components

A site registers its own components with the integration's `components`
option. This one comes from `src/mdx-components.tsx`:

<Screenshot file="settings-page.png" caption="The settings page." />

## Placeholders

<Playground template="react" />
Expand Down
8 changes: 8 additions & 0 deletions examples/astro-docs/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions examples/astro-docs/src/mdx-components.tsx
Original file line number Diff line number Diff line change
@@ -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 <Frame>. */
export function Screenshot({ file, caption }: { file: string; caption?: string }) {
return (
<figure className="fw-frame">
<div className="fw-frame-inner">
<div className="example-shot" data-shot={file}>
Screenshot coming
</div>
</div>
{caption ? <figcaption className="fw-frame-caption">{caption}</figcaption> : null}
</figure>
);
}
20 changes: 20 additions & 0 deletions examples/astro-docs/src/styles/docs.css
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions packages/astro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -99,6 +100,53 @@ without JavaScript, search and view-source see all of it. Accordions and
`<ApiLink operationId="…">` 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 (
<Frame caption={caption}>
<img src={`/screenshots/${file}`} alt={caption} />
</Frame>
);
}
```

```mdx
<Screenshot file="create-app.png" caption="Create the app." />
```

- 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)
Expand Down
18 changes: 17 additions & 1 deletion packages/astro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion packages/astro/src/options.test.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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');
});
Expand All @@ -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);
});
});
27 changes: 27 additions & 0 deletions packages/astro/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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]`;
Expand Down
32 changes: 31 additions & 1 deletion packages/astro/src/runtime/mdx-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -85,3 +88,30 @@ export function siteComponents(options: SiteComponentOptions) {
...(options.mermaid ? { Mermaid: MermaidFigure } : {}),
};
}

export type MdxComponent = ComponentType<Record<string, unknown>>;

/** 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<string, unknown>): Record<string, MdxComponent> {
const out: Record<string, MdxComponent> = {};
const pick = (entries: Record<string, unknown>) => {
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<string, unknown>);
pick(mod);
return out;
}
Loading
Loading