diff --git a/frontend/README.md b/frontend/README.md index aef0550f..79a29730 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,7 +12,8 @@ yarn frontend ``` Open **http://127.0.0.1:4700**. The content watcher updates MDX and browser -TypeScript; the process watcher reloads changes to the runner, components, and theme. +TypeScript, and reloads the Studio plugin when it, a component, or the theme +changes; the process watcher restarts the server when PureStack's own sources change. ```sh yarn frontend:build @@ -23,14 +24,15 @@ yarn frontend:publish - `frontend:publish` creates a clean, minified production artifact in `frontend/out/purestack.studio/web`. This is a local build, not a deployment. - Serve the production folder as a static website at `https://purestack.studio`. -- Use the site runner rather than invoking the generic CLI directly: the runner - registers the `studio` skin, composition components, template, and typed styles. +- Each command runs the `purestack` CLI. It loads `purestack.studio/purestack.config.ts`, + whose plugin registers the `studio` skin, composition components, template, and typed styles. ## Source map | File | Purpose | | --- | --- | -| `studio.ts` | Build/dev entry point and semantic HTML document template | +| `studioPlugin.ts` | The Studio plugin: skin, components, templates, styles, and generated previews | +| `purestack.studio/purestack.config.ts` | Adds the Studio plugin, so the CLI builds and serves the site | | `demoStyle.ts` | Typed CSS for the real Style API workbench preview | | `purestack.studio/index.mdx` | Landing page, source examples, and static component previews | | `purestack.studio/header.mdx`, `footer.mdx` | Shared navigation and footer | diff --git a/frontend/docs/siteGuide.ts b/frontend/docs/siteGuide.ts index f5bd079a..fcec3e7a 100644 --- a/frontend/docs/siteGuide.ts +++ b/frontend/docs/siteGuide.ts @@ -321,9 +321,9 @@ export async function writeSiteGuidePreviews(site: SiteConfig) { index, ), })) - const cards = samples - .map((sample) => { - const body = renderApp(sample.template, { + const cells = await Promise.all( + samples.map(async (sample) => { + const body = await renderApp(sample.template, { components: defineComponents(getSvgIcon), context: contextFor(site, sample), }) @@ -332,8 +332,9 @@ export async function writeSiteGuidePreviews(site: SiteConfig) { const isolated = name === 'TopBar' || name === 'SignIn' || sample.signedIn return `
${escapeAttribute(sample.label)}${isolated ? `` : body}
` - }) - .join('') + }), + ) + const cards = cells.join('') const targets = name === 'PageToc' ? '

Introduction

Outline links have real targets in this document.

Details

Nested entries link to supporting content.

' diff --git a/frontend/purestack.studio/components/site/consent/consent.mdx b/frontend/purestack.studio/components/site/consent/consent.mdx index 2cb74210..c2df8d69 100644 --- a/frontend/purestack.studio/components/site/consent/consent.mdx +++ b/frontend/purestack.studio/components/site/consent/consent.mdx @@ -75,7 +75,7 @@ const exampleTemplate = html`` -function createConsentPreviewDocument(site: SiteConfig): string { +async function createConsentPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -139,7 +139,7 @@ function createConsentPreviewDocument(site: SiteConfig): string { ], services: [], } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -169,7 +169,7 @@ export async function writeConsentPreview(site: SiteConfig) { 'consent', 'preview.html', ) - const documentHtml = createConsentPreviewDocument(site) + const documentHtml = await createConsentPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/consent/preview.ts b/frontend/purestack.studio/components/site/consent/preview.ts index 030d8292..7fe9e136 100644 --- a/frontend/purestack.studio/components/site/consent/preview.ts +++ b/frontend/purestack.studio/components/site/consent/preview.ts @@ -40,7 +40,7 @@ const exampleTemplate = html`` -function createConsentPreviewDocument(site: SiteConfig): string { +async function createConsentPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -104,7 +104,7 @@ function createConsentPreviewDocument(site: SiteConfig): string { ], services: [], } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -134,7 +134,7 @@ export async function writeConsentPreview(site: SiteConfig) { 'consent', 'preview.html', ) - const documentHtml = createConsentPreviewDocument(site) + const documentHtml = await createConsentPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx b/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx index 675b5cd2..97ec1e9a 100644 --- a/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx +++ b/frontend/purestack.studio/components/site/nav-menu/nav-menu.mdx @@ -457,7 +457,7 @@ const navMenuPreviewTemplate = html`` const exampleTemplate = html`` -function createNavMenuPreviewDocument(site: SiteConfig): string { +async function createNavMenuPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -512,7 +512,7 @@ function createNavMenuPreviewDocument(site: SiteConfig): string { }, ], } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -543,7 +543,7 @@ export async function writeNavMenuPreview(site: SiteConfig) { 'nav-menu', 'preview.html', ) - const documentHtml = createNavMenuPreviewDocument(site) + const documentHtml = await createNavMenuPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/nav-menu/preview.ts b/frontend/purestack.studio/components/site/nav-menu/preview.ts index e479f3c1..ac922835 100644 --- a/frontend/purestack.studio/components/site/nav-menu/preview.ts +++ b/frontend/purestack.studio/components/site/nav-menu/preview.ts @@ -24,7 +24,7 @@ const navMenuPreviewTemplate = html`` const exampleTemplate = html`` -function createNavMenuPreviewDocument(site: SiteConfig): string { +async function createNavMenuPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -79,7 +79,7 @@ function createNavMenuPreviewDocument(site: SiteConfig): string { }, ], } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -110,7 +110,7 @@ export async function writeNavMenuPreview(site: SiteConfig) { 'nav-menu', 'preview.html', ) - const documentHtml = createNavMenuPreviewDocument(site) + const documentHtml = await createNavMenuPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/page-toc/page-toc.mdx b/frontend/purestack.studio/components/site/page-toc/page-toc.mdx index 926e0cb9..595e1f6e 100644 --- a/frontend/purestack.studio/components/site/page-toc/page-toc.mdx +++ b/frontend/purestack.studio/components/site/page-toc/page-toc.mdx @@ -67,7 +67,7 @@ const exampleTemplate = html`

Keep headings descriptive and ordered.

` -function createPageTocPreviewDocument(site: SiteConfig): string { +async function createPageTocPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -106,7 +106,7 @@ function createPageTocPreviewDocument(site: SiteConfig): string { children: [{ id: 'preview-contract', title: 'Contract', depth: 3 }], }, ] - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -135,7 +135,7 @@ export async function writePageTocPreview(site: SiteConfig) { 'page-toc', 'preview.html', ) - const documentHtml = createPageTocPreviewDocument(site) + const documentHtml = await createPageTocPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/page-toc/preview.ts b/frontend/purestack.studio/components/site/page-toc/preview.ts index d256982a..5a9c6d45 100644 --- a/frontend/purestack.studio/components/site/page-toc/preview.ts +++ b/frontend/purestack.studio/components/site/page-toc/preview.ts @@ -32,7 +32,7 @@ const exampleTemplate = html`

Keep headings descriptive and ordered.

` -function createPageTocPreviewDocument(site: SiteConfig): string { +async function createPageTocPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -71,7 +71,7 @@ function createPageTocPreviewDocument(site: SiteConfig): string { children: [{ id: 'preview-contract', title: 'Contract', depth: 3 }], }, ] - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -100,7 +100,7 @@ export async function writePageTocPreview(site: SiteConfig) { 'page-toc', 'preview.html', ) - const documentHtml = createPageTocPreviewDocument(site) + const documentHtml = await createPageTocPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/sign-in/preview.ts b/frontend/purestack.studio/components/site/sign-in/preview.ts index 75daaa47..07e2ec00 100644 --- a/frontend/purestack.studio/components/site/sign-in/preview.ts +++ b/frontend/purestack.studio/components/site/sign-in/preview.ts @@ -41,7 +41,7 @@ const exampleTemplate = html` preview does not sign you in.

` -function createSignInPreviewDocument(site: SiteConfig): string { +async function createSignInPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -72,7 +72,7 @@ function createSignInPreviewDocument(site: SiteConfig): string { recordRuntimeEmbed: () => {}, } context.site.auth = { ...context.site.auth, enabled: true, signUp: false } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -101,7 +101,7 @@ export async function writeSignInPreview(site: SiteConfig) { 'sign-in', 'preview.html', ) - const documentHtml = createSignInPreviewDocument(site) + const documentHtml = await createSignInPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/sign-in/sign-in.mdx b/frontend/purestack.studio/components/site/sign-in/sign-in.mdx index ff80cc47..45d2e437 100644 --- a/frontend/purestack.studio/components/site/sign-in/sign-in.mdx +++ b/frontend/purestack.studio/components/site/sign-in/sign-in.mdx @@ -76,7 +76,7 @@ const exampleTemplate = html` preview does not sign you in.

` -function createSignInPreviewDocument(site: SiteConfig): string { +async function createSignInPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -107,7 +107,7 @@ function createSignInPreviewDocument(site: SiteConfig): string { recordRuntimeEmbed: () => {}, } context.site.auth = { ...context.site.auth, enabled: true, signUp: false } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -136,7 +136,7 @@ export async function writeSignInPreview(site: SiteConfig) { 'sign-in', 'preview.html', ) - const documentHtml = createSignInPreviewDocument(site) + const documentHtml = await createSignInPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/top-bar/preview.ts b/frontend/purestack.studio/components/site/top-bar/preview.ts index 13d42278..729ac081 100644 --- a/frontend/purestack.studio/components/site/top-bar/preview.ts +++ b/frontend/purestack.studio/components/site/top-bar/preview.ts @@ -27,7 +27,7 @@ const exampleTemplate = html` changes this preview.

` -function createTopBarPreviewDocument(site: SiteConfig): string { +async function createTopBarPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -58,7 +58,7 @@ function createTopBarPreviewDocument(site: SiteConfig): string { recordRuntimeEmbed: () => {}, } context.site.pagefind = { ...context.site.pagefind, enabled: false } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -87,7 +87,7 @@ export async function writeTopBarPreview(site: SiteConfig) { 'top-bar', 'preview.html', ) - const documentHtml = createTopBarPreviewDocument(site) + const documentHtml = await createTopBarPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx index 08a5d237..25cd3376 100644 --- a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx +++ b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx @@ -69,7 +69,7 @@ const exampleTemplate = html` changes this preview.

` -function createTopBarPreviewDocument(site: SiteConfig): string { +async function createTopBarPreviewDocument(site: SiteConfig): Promise { const context: TsSsgContext = { site: { ...site }, pageInfo: { @@ -100,7 +100,7 @@ function createTopBarPreviewDocument(site: SiteConfig): string { recordRuntimeEmbed: () => {}, } context.site.pagefind = { ...context.site.pagefind, enabled: false } - const body = renderApp(exampleTemplate, { + const body = await renderApp(exampleTemplate, { components: defineComponents(getSvgIcon), context, }) @@ -129,7 +129,7 @@ export async function writeTopBarPreview(site: SiteConfig) { 'top-bar', 'preview.html', ) - const documentHtml = createTopBarPreviewDocument(site) + const documentHtml = await createTopBarPreviewDocument(site) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, documentHtml, 'utf8') } diff --git a/frontend/purestack.studio/guides/_nav.json b/frontend/purestack.studio/guides/_nav.json index b943bb6f..1fd20ff1 100644 --- a/frontend/purestack.studio/guides/_nav.json +++ b/frontend/purestack.studio/guides/_nav.json @@ -12,7 +12,8 @@ "utility-css-classes.mdx", "semantic-tones.mdx", "icons.mdx", - "themes.mdx" + "themes.mdx", + "plugins.mdx" ], "items": [ { diff --git a/frontend/purestack.studio/guides/index.mdx b/frontend/purestack.studio/guides/index.mdx index 4feb75ab..d42c3bd8 100644 --- a/frontend/purestack.studio/guides/index.mdx +++ b/frontend/purestack.studio/guides/index.mdx @@ -1,6 +1,6 @@ --- title: Guides -description: Learn how to build and style a PureStack site with the CLI, site configuration, links, VS Code extension, Regor, typography, utility classes, semantic tones, icons, and themes. +description: Learn how to build and style a PureStack site with the CLI, site configuration, links, VS Code extension, Regor, typography, utility classes, semantic tones, icons, themes, and plugins. template: doc layout: showNav: true @@ -67,6 +67,11 @@ These guides follow the path from a content folder to a finished interface. Star

Start with a skin, tune light and dark palettes, and add theme-aware styles.

Style a site + +

Plugins

+

Add your own skin, components, page templates, and build steps as named plugins.

+ Extend a site +
## Explore the building blocks diff --git a/frontend/purestack.studio/guides/plugins.mdx b/frontend/purestack.studio/guides/plugins.mdx new file mode 100644 index 00000000..05a5c75f --- /dev/null +++ b/frontend/purestack.studio/guides/plugins.mdx @@ -0,0 +1,551 @@ +--- +title: Plugins +description: Add your own skins, components, page layouts, Markdown transforms, generated pages, and build steps to a PureStack site, bundled into plugins. +template: doc +layout: + showNav: true + showToc: true + showFooter: true +nav: + order: 45 + icon: tabler:plug +--- + +# Plugins + +A plugin is a named object that extends how PureStack builds your site. Each of its fields adds one kind of extension, so a single plugin can bring a brand skin together with the components and page layout that go with it. Find the field for what you need: + +| To | Use | +| --- | --- | +| Give the site its own colors | [`skins`](#add-a-skin) | +| Reuse a piece of markup in your pages | [`components`](#add-components) | +| Lay out some pages differently | [`templates`](#add-page-templates) | +| Change how Markdown turns into HTML | [`markdown`](#transform-markdown) | +| Build pages from data, such as one page per tag | [`pages`](#generate-pages) | +| Change a whole page, header and footer included | [`hooks.onPageDocument`](#change-a-pages-dom) | +| Write extra files, such as a feed | [`hooks`](#write-extra-files) | +| Add styles or run code at other points in the build | [`hooks`](#run-code-during-the-build) | +| Answer requests while you develop | [`devMiddleware`](#handle-requests-in-development) | + +## Add your first plugin + +Keep plugins in a folder of their own, beside the content folder: + +```text +content/ + siteConfig.json + purestack.config.ts + index.mdx +plugins/ + site.ts +package.json +``` + +This plugin adds a component that shows a release badge: + +```ts +// plugins/site.ts +import { definePlugin } from 'purestack' +import { defineComponent, html } from 'regor' + +export const sitePlugin = definePlugin({ + name: 'site', + components: () => ({ + releaseBadge: defineComponent<{ version?: string }>( + html`v{{ version }}`, + { props: ['version'] }, + ), + }), +}) +``` + +List it in `purestack.config.ts`, next to `siteConfig.json`: + +```ts +// content/purestack.config.ts +import { defineConfig } from 'purestack' +import { sitePlugin } from '../plugins/site' + +export default defineConfig({ + plugins: [sitePlugin], +}) +``` + +Use the component in a page: + +```mdx +Version 2.0 is out. +``` + +Start the development server as usual: + +```sh +yarn purestack serve --content ./content +``` + +The badge appears on the page. Change its `tone` in `plugins/site.ts` while the server runs: the site rebuilds and the page reloads. `build` and `publish` load the same config, so the published site gets the same badge. + +## How the config loads + +- **Where it lives.** The CLI looks for `purestack.config.ts` in the folder passed to `--content`. It must default-export `defineConfig({ plugins: [...] })`. +- **Your own files.** The config and the files it imports are bundled when the config loads, so they can be TypeScript and live in any folder, with no build step. +- **Packages.** Packages load from `node_modules` as they are. `purestack`, `regor`, and the `@purestack/*` packages always resolve to the copies the CLI runs, so your components and skins register where the build looks for them. Install the ones you import anyway, so your editor knows their types. +- **Reloading.** `serve` loads the config again, and rebuilds the site, when the config or one of your files changes. A package loads once; restart `serve` after updating one. +- **Mistakes.** When a reload fails, the terminal shows the error and the site keeps its last working plugins until you fix it. + +Keep plugin code out of the content folder: TypeScript files there are [page scripts](/components/runtime/page-script/) for the browser. + +## Add a skin + +A skin is a light and dark palette pair. This one starts from the standard skin with brand colors: + +```ts +import { themeSkins } from '@purestack/ts-style' +import { definePlugin } from 'purestack' + +export const sitePlugin = definePlugin({ + name: 'site', + skins: { + brand: { + create: () => + themeSkins.standard.create({ + accent: '#4b64c8', + neutral: '#293047', + }), + }, + }, +}) +``` + +Select it in `siteConfig.json`: + +```json +{ + "style": { "theme": { "skin": "brand" } } +} +``` + +Give a skin its own name; `standard` belongs to the built-in skin. The [Themes guide](./themes) covers palettes in depth. + +## Add components + +`components` returns your [Regor](./regor) components by name, as the first plugin above does. It receives the resolved site config, so a component can depend on settings such as `config.siteTitle`. + +- **Create them inside `components`.** Regor needs the page document that the build sets up before it calls `components`. A component defined at the top of a module fails with `document is not defined`. To keep components in their own files, export a function that creates them, and call it from `components`. +- **Names.** Register a component in camelCase, like `releaseBadge`, and write it in PascalCase in your pages: ``. +- **Built-in components.** A component's template can use any [built-in component](/components/), as `` does in the release badge. A component with a built-in one's name replaces it. +- **Browser behavior.** Components render to HTML when the site builds. For behavior in the browser, add a [page script](/components/runtime/page-script/). + +## Add page templates + +A template renders a whole page around its content. Build the markup with `h` from `@purestack/ts-html`: + +```ts +import { h } from '@purestack/ts-html' +import { definePlugin } from 'purestack' + +export const sitePlugin = definePlugin({ + name: 'site', + templates: { + landing: ({ head, bodyHtml, headerHtml, footerHtml }) => + h('html').push( + head, + h('body').push( + h('').raw(headerHtml ?? ''), + h('main').attr({ class: 'landing' }).raw(bodyHtml), + h('').raw(footerHtml ?? ''), + ), + ), + }, +}) +``` + +A page selects it in its frontmatter: + +```md +--- +title: Welcome +template: landing +--- +``` + +Besides the prepared `head` and the page's `bodyHtml`, a template receives the page's shared `headerHtml` and `footerHtml`, the `site` config, `navigation`, the heading `outline`, and `pageInfo`, whose `frontmatter` includes any custom fields the page defines. A template named `doc` or `splash` replaces the built-in one. [Run code during the build](#run-code-during-the-build) shows how to style `.landing`. + +## Transform Markdown + +`markdown` adds [remark](https://github.com/remarkjs/remark) plugins, which change the Markdown tree, and [rehype](https://github.com/rehypejs/rehype) plugins, which change the HTML tree. Every page, header, and footer goes through these steps: + +1. PureStack reads the Markdown. Regor markup, such as ``, stays raw HTML. +2. Your remark plugins run. +3. The Markdown tree becomes an HTML tree. +4. Your rehype plugins run. +5. PureStack collects the headings for the table of contents and highlights code. +6. Regor components render, and the template wraps the page. + +Because your rehype plugins run before step 5, a heading they change also changes in the table of contents. + +Install the plugins you need and list them, with options where a plugin takes them: + +```ts +import { definePlugin } from 'purestack' +import rehypeExternalLinks from 'rehype-external-links' +import remarkSmartypants from 'remark-smartypants' + +export const sitePlugin = definePlugin({ + name: 'site', + markdown: { + remarkPlugins: [remarkSmartypants], + rehypePlugins: [[rehypeExternalLinks, { target: '_blank' }]], + }, +}) +``` + +A plugin of your own is a function that returns a tree transform. This one makes every image load lazily: + +```ts +import type { Root } from 'hast' +import { definePlugin } from 'purestack' +import { visit } from 'unist-util-visit' + +function rehypeLazyImages() { + return (tree: Root) => { + visit(tree, 'element', (node) => { + if (node.tagName === 'img') node.properties.loading = 'lazy' + }) + } +} + +export const sitePlugin = definePlugin({ + name: 'site', + markdown: { rehypePlugins: [rehypeLazyImages] }, +}) +``` + +A transform also receives the file being processed. Its `path` is the page's path in the content folder, such as `blog/first.mdx`, always with forward slashes, so a plugin can treat some folders differently. + +## Generate pages + +`pages` adds pages that have no file of their own, such as one page per tag or one per entry of an API reference. It receives the site config and the content files, and returns the pages to add: + +| Field | Value | +| --- | --- | +| `path` | The content path the page takes, such as `blog/tags/regor.mdx`. | +| `source` | The page's content, as a file at that path would hold it: frontmatter and Markdown. Give it as a string, or as a function that returns the string. | + +The returned pages can be the source string or a function returning the source string. + +Example tags plugin: + +```ts +import { readFile } from 'node:fs/promises' +import { definePlugin, parseFrontmatterSource } from 'purestack' + +export const tagsPlugin = definePlugin({ + name: 'tags', + async pages({ files }) { + const postsByTag = new Map() + for (const file of files) { + if (!file.urlPath.startsWith('/blog/')) continue + const source = await readFile(file.absPath, 'utf8') + const { frontmatter } = parseFrontmatterSource(source, file.relPath) + for (const tag of (frontmatter.tags as string[] | undefined) ?? []) { + const posts = postsByTag.get(tag) ?? [] + posts.push(`- [${frontmatter.title}](${file.urlPath})`) + postsByTag.set(tag, posts) + } + } + return [...postsByTag].map(([tag, posts]) => ({ + path: `blog/tags/${tag}.mdx`, + source: ['---', `title: Posts tagged ${tag}`, '---', ...posts].join('\n'), + })) + }, +}) +``` + +An API reference can have thousands of pages, so this plugin returns functions instead. It adds one page for each JSON file in an `api/` folder beside `package.json`, and reads a file only when its page needs it: + +```ts +import { readdir, readFile } from 'node:fs/promises' +import { definePlugin } from 'purestack' + +export const apiPlugin = definePlugin({ + name: 'api', + async pages() { + const files = await readdir('api') + return files.map((file) => ({ + path: `api/${file.replace(/\.json$/, '.mdx')}`, + source: async () => { + const symbol = JSON.parse(await readFile(`api/${file}`, 'utf8')) + return ['---', `title: ${symbol.name}`, '---', symbol.summary].join('\n') + }, + })) + }, +}) +``` + +A generated page works like a file at its path. `blog/tags/regor.mdx` is served at `/blog/tags/regor/`, shows up in automatic navigation, uses its folder's header and footer, and other pages can [link to it](./links) as `./tags/regor`. Its frontmatter can select a template or set `nav` options, as in any page. + +- **Paths.** A path ends in `.md`, `.mdx`, or `.rmdx` and stays inside the content folder. It cannot be a `header.mdx` or `footer.mdx`, or the path of a real file or of another plugin's page. +- **Files.** `files` lists the pages in the content folder, each with its `relPath`, its `absPath`, and the `urlPath` it is served at. Generated pages are never among them. +- **Source functions.** PureStack calls a source function to render its page and, while navigation is on, to read the page's frontmatter. Return the same text until the data behind it changes, so navigation and the page agree. +- **Updates.** `pages` runs once per build. While `serve` runs, it runs again whenever a content file or asset changes: a new tag on a post gets its page on the next reload, and a tag no one uses anymore loses its page. A page whose source is a function renders again on its next request, since PureStack keeps no text to compare. + +## Run code during the build + +Hooks run at fixed points in a build. Page hooks run every time a page renders, including each re-render while `serve` runs, and for generated pages too: + +| Hook | Runs | +| --- | --- | +| `onPageStart(context, file)` | Before a page renders. | +| `onPageDocument(context, page)` | When its document is ready, before it becomes HTML. [Change the page's DOM](#change-a-pages-dom) here. | +| `onPageRendered(context, page)` | After it becomes HTML. Changes to `page.html` are written. | +| `onPageWritten(context, page)` | After its HTML file is written. | + +The other hooks run once per full build, in this order. `serve` runs a full build when it starts and whenever `siteConfig.json` or your plugins change: + +| Hook | Runs | +| --- | --- | +| `onConfigResolved(context)` | Before any output. Register styles here. | +| `onContentDiscovered(context, files)` | After the pages are found, before they render. | +| `onNavigationBuilt(context, navigation)` | After the navigation is built. | +| `onStylesWritten(context, result)` | After the theme stylesheets are written. | +| `onBuildComplete(context, result)` | After every page and asset is written. | + +Every hook receives `context.config`, the resolved site config. This hook gives the `landing` template's `
` a background that follows the light and dark themes: + +```ts +import { styleBuilder, themes } from '@purestack/ts-style' +import { definePlugin } from 'purestack' + +export const sitePlugin = definePlugin({ + name: 'site', + hooks: { + onConfigResolved() { + themes.forEach((theme, palette) => { + styleBuilder.get(theme).select('.landing').css({ + background: palette.semanticTone.info.surface.rest.background, + }) + }) + }, + }, +}) +``` + +## Change a page's DOM + +`onPageDocument` receives the whole page as a document, after its components and template render: header, navigation, content, and footer. Change it with the usual DOM methods instead of editing HTML text. This hook adds the date each page's file last changed to the end of its `
`: + +```ts +import { stat } from 'node:fs/promises' +import { definePlugin } from 'purestack' + +export const lastUpdatedPlugin = definePlugin({ + name: 'last-updated', + hooks: { + async onPageDocument(context, { document, file }) { + // Generated pages have no file. + if (file.source !== undefined) return + const { mtime } = await stat(file.absPath) + const note = document.createElement('p') + note.className = 'last-updated' + note.textContent = `Last updated ${mtime.toISOString().slice(0, 10)}` + document.querySelector('main')?.appendChild(note) + }, + }, +}) +``` + +- **It can await.** The document stays the page's own while the hook awaits, even when `serve` renders several pages at once. Inside the hook, the global `document` is the page too, so libraries that use it work. +- **Links resolve.** Links the hook adds, such as ``, resolve and are checked like links you write in a page. +- **What it receives.** `page` holds the `document`, the page's `file`, its `frontmatter`, and the `urlPath` it is served at. For a [generated page](#generate-pages), `file.source` holds its source, and no file exists at `file.absPath`. + +Use `markdown` instead to change only the page's content, and `onPageRendered` for the final HTML text. + +## Write extra files + +A feed, a search index of your own, or any file built from the site's pages needs no special field. Collect pages as they render, and write the file once the build completes. Making the plugin a function lets each site pass its own options: + +```ts +// plugins/feed.ts +import { writeFile } from 'node:fs/promises' +import path from 'node:path' +import { definePlugin } from 'purestack' + +const escapeXml = (text: string) => + text.replace(/[<>&"]/g, (char) => `&#${char.charCodeAt(0)};`) + +export function feed(options: { siteUrl: string; folder?: string }) { + const folder = options.folder ?? '/blog/' + const posts = new Map() + return definePlugin({ + name: 'feed', + hooks: { + onConfigResolved() { + posts.clear() + }, + onPageRendered(context, page) { + const { title, date } = page.frontmatter + if (!page.urlPath.startsWith(folder) || !title || !date) return + posts.set(page.urlPath, { title, date: new Date(date as string | Date) }) + }, + async onBuildComplete(context) { + const { basePath, outDir, siteTitle } = context.config + const url = (urlPath: string) => `${options.siteUrl}${basePath}${urlPath}` + const items = [...posts] + .sort(([, a], [, b]) => b.date.getTime() - a.date.getTime()) + .map(([urlPath, post]) => + [ + '', + `${escapeXml(post.title)}`, + `${url(urlPath)}`, + `${post.date.toUTCString()}`, + '', + ].join(''), + ) + const rss = [ + '', + '', + `${escapeXml(siteTitle)}`, + `${url('/')}`, + ...items, + '', + ].join('\n') + await writeFile(path.join(outDir, 'feed.xml'), rss) + }, + }, + }) +} +``` + +```ts +// content/purestack.config.ts +export default defineConfig({ + plugins: [feed({ siteUrl: 'https://example.com' })], +}) +``` + +- **Every page is included.** Every page renders before `onBuildComplete` runs, and `onConfigResolved` empties the list when the next full build starts. +- **Write into `outDir`.** `context.config.outDir` points at `publishDir` during `publish`, so the file lands in the release folder too. +- **Links include `basePath`.** `page.urlPath` is the path inside the site; adding `basePath` gives the address visitors use. +- **While developing.** `serve` rewrites the file on full builds, not after each page edit. + +## Handle requests in development + +`devMiddleware` sees each request the development server receives, before the site does. Answer a request to handle it, for example to stand in for an API that your page scripts call: + +```ts +import { definePlugin } from 'purestack' + +export const mockApiPlugin = definePlugin({ + name: 'mock-api', + devMiddleware(request, response) { + if (request.url !== '/api/status') return + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ ok: true })) + }, +}) +``` + +- **Node's objects.** `request` and `response` are Node's [HTTP request and response](https://nodejs.org/api/http.html). `request.url` is the full path, with `basePath` if the site has one. +- **Passing a request on.** Return without sending a response, and the next plugin sees the request, then the site. A header set with `response.setHeader` still applies to whatever answers, including the site's pages. +- **Errors.** When the middleware throws, the terminal shows the error and the request gets status 500. +- **Development only.** `build` and `publish` ignore it. + +## Combine plugins + +List plugins in the order they should apply: + +```ts +export default defineConfig({ + plugins: [sitePlugin, tagsPlugin, feed({ siteUrl: 'https://example.com' })], +}) +``` + +Hooks, remark and rehype plugins, page generators, and dev middleware all run in that order, one plugin after another. Skins, components, templates, and generated pages combine by name, so two plugins cannot add the same one; rename one of them. + +For a single hook, an inline plugin is enough: + +```ts +export default defineConfig({ + plugins: [sitePlugin, { name: 'notify', hooks: { onBuildComplete() {} } }], +}) +``` + +## Share a plugin as a package + +A plugin is a plain object, so any package can export one. The feed plugin above is already a function of its options, so it can move into a package unchanged, and each site lists it like a local plugin: + +```ts +import { defineConfig } from 'purestack' +import { feed } from 'purestack-feed' + +export default defineConfig({ + plugins: [feed({ siteUrl: 'https://example.com' })], +}) +``` + +When you publish the package: + +- **Ship JavaScript.** Packages load without bundling, so publish compiled JavaScript with type declarations, not TypeScript source. +- **Share the site's PureStack.** List `purestack`, and any `@purestack/*` package the plugin imports, under `peerDependencies`, not `dependencies`. The plugin then uses the site's copy instead of installing its own. + +## Build from a script + +`buildSite` and `startDevServer` run the same build as the CLI, for a project that needs its own build script. They do not look for `purestack.config.ts`; pass plugins in `options.plugins`: + +```ts +import path from 'node:path' +import { buildSite } from 'purestack' +import { sitePlugin } from './plugins/site' + +await buildSite({ + siteConfig: { contentDir: path.resolve('content') }, + options: { plugins: [sitePlugin] }, +}) +``` + +To have `startDevServer` load a config file and reload it as `serve` does, pass the file's path as `configFile`. Its plugins apply after those in `options.plugins`. + +## When something goes wrong + +PureStack checks every plugin before the build starts, so a mistake fails at once and names the plugin: + +```text +Plugin "site" has an unknown hook "onPageRender". Hooks: onConfigResolved, … +Plugins "site" and "docs" both define the component "releaseBadge". +Plugin "site" cannot replace the built-in skin "standard". Give its skin another name. +Plugin "tags" generates "blog/first.mdx", which is already a content file. +``` + +When a hook or page generator throws, the error names the plugin and where it failed: + +```text +Plugin "feed" failed in onBuildComplete: ENOENT: no such file or directory +``` + +While `serve` runs, a failing page hook shows its error on that page and leaves the server running. Most other problems are one of these: + +- **`Unknown theme skin`.** The plugin with the skin is missing from `plugins`, or `purestack.config.ts` is not in the folder passed to `--content`. +- **`Could not bundle`.** An import in the config, or in a file it imports, does not resolve. The message names it. +- **`Could not run` with `document is not defined`.** A component is defined at the top of a module. Create it inside `components`, as [Add components](#add-components) shows. +- **An edit has no effect while `serve` runs.** Only the config and your own files reload. After changing a package, restart `serve`. +- **A file a hook writes is missing from the published site.** Write it into `context.config.outDir`, not a fixed folder. + +## Plugin reference + +Every field except `name` is optional. + +| Field | Type | +| --- | --- | +| `name` | `string`, unique among the site's plugins | +| `skins` | Skins by name, each `{ create(): ThemeSkinPair }` | +| `components` | `(config) => ({ [name]: component })` | +| `templates` | Templates by name, each `(input) => TSNode<'html'>` | +| `markdown` | `{ remarkPlugins?: PluggableList, rehypePlugins?: PluggableList }` | +| `pages` | `({ config, files }) => { path, source }[]`, or a promise of them; `source` is the text, or a function that returns it | +| `hooks` | Any of the hooks above | +| `devMiddleware` | `(request, response) => void`, or a promise | + +## A complete example + +The PureStack Studio site you are reading uses one plugin for its skin, components, page layout, styles, and generated previews. Read the [Studio plugin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studioPlugin.ts) and the [config that adds it](https://github.com/PureStackStudio/PureStack/blob/main/frontend/purestack.studio/purestack.config.ts); the CLI builds and serves the site with no other code. diff --git a/frontend/purestack.studio/guides/purestack-cli.mdx b/frontend/purestack.studio/guides/purestack-cli.mdx index e5380e2a..4ac80839 100644 --- a/frontend/purestack.studio/guides/purestack-cli.mdx +++ b/frontend/purestack.studio/guides/purestack-cli.mdx @@ -86,6 +86,8 @@ Routes follow filenames and folders: Files such as `assets/logo.png` are copied into the output folder with their relative paths intact. The root `siteConfig.json` and navigation files are inputs to the build, not public assets. You can place `_nav.json` in a folder for custom or hybrid navigation, and `header.mdx` or `footer.mdx` for shared page sections. The [Site configuration guide](/guides/site-config/) shows how to enable and order navigation. +A `purestack.config.ts` beside `siteConfig.json` adds code of your own, such as a brand skin, components, templates, or generated pages, through [plugins](/guides/plugins/). Every command loads it when it is present; like other `.ts` files in the content folder, it is never copied to the output. + ## Commands at a glance Every build command requires `--content `. The path is resolved from the working directory and must contain `siteConfig.json`. @@ -116,7 +118,7 @@ yarn purestack serve --content ./content yarn purestack serve --content ./content --host 127.0.0.1 --port 4300 ``` -The server uses port `4173` and host `0.0.0.0` unless you override them. It serves files from `outDir`, checks watched content changes, and reloads open pages after changes. Content changes are handled incrementally where possible; a change to `siteConfig.json` causes a full rebuild so its new settings are applied. +The server uses port `4173` and host `0.0.0.0` unless you override them. It serves files from `outDir`, checks watched content changes, and reloads open pages after changes. Content changes are handled incrementally where possible; a change to `siteConfig.json`, to `purestack.config.ts`, or to a file the config imports causes a full rebuild so the new settings and plugins are applied. These options belong to `serve`: @@ -166,8 +168,9 @@ The home page stays at `dist/site/index.html`, but links and the development ser | `Missing required --content option` | Pass `--content` to `build`, `serve`, or `publish`. | | `Missing required siteConfig.json` | Check the path passed to `--content` and the file's exact name. | | `Unknown option` | Run `yarn purestack --help`; `--host`, `--port`, `--no-watch`, and `--no-reload` apply only to `serve`. | +| `Could not bundle …purestack.config.ts` | Check the imports in `purestack.config.ts` and the files it imports; the message names the import that failed. | | `Duplicate content routes detected` | Find two files that map to the same URL, such as `guide/index.md` and `guide/guide.mdx`. | | A page or asset returns 404 under a subpath | Check `basePath`, the URL printed by `serve`, and the path at which your host serves the output folder. | | `publish` fails while `serve` displayed an error page | Fix the page or asset error shown by the development server, then rerun `publish`. | -The checked-in [sample content](https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg/sample-content) is a larger working site. PureStack Studio uses a [custom TypeScript runner](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studio.ts) to register its own components, styles, and templates before building; `yarn frontend` starts that site's development server. +The checked-in [sample content](https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg/sample-content) is a larger working site. PureStack Studio adds its own skin, components, styles, and templates through a [plugin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studioPlugin.ts) listed in its [`purestack.config.ts`](https://github.com/PureStackStudio/PureStack/blob/main/frontend/purestack.studio/purestack.config.ts); `yarn frontend` runs `purestack serve` on that site. diff --git a/frontend/purestack.studio/guides/themes.mdx b/frontend/purestack.studio/guides/themes.mdx index f66f639b..7d451082 100644 --- a/frontend/purestack.studio/guides/themes.mdx +++ b/frontend/purestack.studio/guides/themes.mdx @@ -41,30 +41,36 @@ palettes. You can provide any subset of its semantic colors; omitted colors use the standard defaults: ```ts -import { registerSkin, themeSkins } from '@purestack/ts-style' +import { definePlugin } from '@purestack/ts-ssg' +import { themeSkins } from '@purestack/ts-style' import { merge } from '@purestack/ts-util' -registerSkin('my-site', { - create: () => { - const standard = themeSkins.standard.create({ - accent: '#4b64c8', - neutral: '#293047', - secondary: '#168a75', - feature: '#b04783', - }) - return { - light: merge(standard.light, { - font: { size: { body: '1rem' } }, - }), - dark: merge(standard.dark, { - font: { size: { body: '1rem' } }, - }), - } +export const mySitePlugin = definePlugin({ + name: 'my-site', + skins: { + 'my-site': { + create: () => { + const standard = themeSkins.standard.create({ + accent: '#4b64c8', + neutral: '#293047', + secondary: '#168a75', + feature: '#b04783', + }) + return { + light: merge(standard.light, { + font: { size: { body: '1rem' } }, + }), + dark: merge(standard.dark, { + font: { size: { body: '1rem' } }, + }), + } + }, + }, }, }) ``` -Import the file that registers your skin before the site build begins, then set `"theme": { "skin": "my-site" }` in the style config. A skin is a full light/dark pair, and `merge` preserves palette values you do not override. You can also pass a list of built-in preset names, such as `['green']`, to `themeSkins.standard.create()`. +Add the plugin to your build's `plugins`, then set `"theme": { "skin": "my-site" }` in the style config. A skin is a full light/dark pair, and `merge` preserves palette values you do not override. You can also pass a list of built-in preset names, such as `['green']`, to `themeSkins.standard.create()`. ## Extend styles with palette roles @@ -82,4 +88,4 @@ themes.forEach((theme, palette) => { }) ``` -Register such styles in your site runner before the build renders theme assets. For a real example, inspect the [Studio skin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioSkin.ts) and [Studio styles](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioStyles.ts). +Register such styles from a plugin's `onConfigResolved` hook, which runs before the build writes theme assets. For a real example, inspect the [Studio plugin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studioPlugin.ts), with its [skin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioSkin.ts) and [styles](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioStyles.ts). diff --git a/frontend/purestack.studio/guides/typography.mdx b/frontend/purestack.studio/guides/typography.mdx index fc45e340..f5a083f2 100644 --- a/frontend/purestack.studio/guides/typography.mdx +++ b/frontend/purestack.studio/guides/typography.mdx @@ -217,7 +217,11 @@ Values you leave out keep the skin's settings. When you build your own skin, define the type once and merge it into both modes: ```ts -import { registerSkin, type ThemePalette, themeSkins } from '@purestack/ts-style' +import { + type ThemePalette, + type ThemeSkin, + themeSkins, +} from '@purestack/ts-style' import { type DeepPartial, merge } from '@purestack/ts-util' const type = { @@ -235,7 +239,7 @@ const type = { }, } satisfies DeepPartial -registerSkin('my-site', { +export const mySiteSkin: ThemeSkin = { create: () => { const standard = themeSkins.standard.create() return { @@ -243,10 +247,10 @@ registerSkin('my-site', { dark: merge(standard.dark, type), } }, -}) +} ``` -Keep the scale in order from `xxxs` up to `display`, with each step at least as large as the one before it. The [Themes guide](/guides/themes/) shows how to select a registered skin. +Keep the scale in order from `xxxs` up to `display`, with each step at least as large as the one before it. The [Themes guide](/guides/themes/) shows how to add the skin with a plugin and select it. ### Change the root size diff --git a/frontend/purestack.studio/purestack.config.ts b/frontend/purestack.studio/purestack.config.ts new file mode 100644 index 00000000..8ff34b7f --- /dev/null +++ b/frontend/purestack.studio/purestack.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@purestack/ts-ssg' +import { studioPlugin } from '../studioPlugin' + +export default defineConfig({ + plugins: [studioPlugin], +}) diff --git a/frontend/studio.ts b/frontend/studio.ts deleted file mode 100644 index f1d20623..00000000 --- a/frontend/studio.ts +++ /dev/null @@ -1,92 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import type { PageTemplateMap } from '@purestack/ts-common' -import { h } from '@purestack/ts-html' -import { type BuildInput, buildSite, startDevServer } from '@purestack/ts-ssg' -import { createLogger, getLogger } from 'logpot' -import { defineStudioComponents } from './components/studioComponents' -import { card } from './demoStyle' -import { registerApiReferenceStyles } from './docs/apiReferenceStyles' -import { registerComponentGuideStyles } from './docs/componentGuide' -import { defineDocumentationComponents } from './docs/docsComponents' -import { writeSiteGuidePreviews } from './docs/siteGuide' -import { writeConsentPreview } from './purestack.studio/components/site/consent/preview' -import { writeNavMenuPreview } from './purestack.studio/components/site/nav-menu/preview' -import { writePageTocPreview } from './purestack.studio/components/site/page-toc/preview' -import { writeSignInPreview } from './purestack.studio/components/site/sign-in/preview' -import { writeTopBarPreview } from './purestack.studio/components/site/top-bar/preview' -import { registerStudioSkin } from './theme/studioSkin' -import { registerStudioStyles } from './theme/studioStyles' - -registerStudioSkin() - -const command = process.argv[2] ?? 'serve' -if (!['serve', 'build', 'publish'].includes(command)) { - throw new Error('Usage: yarn tsx frontend/studio.ts [serve|build|publish]') -} - -const templates: PageTemplateMap = { - studio: ({ head, bodyHtml, headerHtml, footerHtml }) => { - head.push(h('style').raw(card.toCSS())) - return h('html') - .attr({ lang: 'en' }) - .push( - head, - h('body') - .class('studio tone--neutral') - .push( - h('a') - .class('skip-link') - .attr({ href: '#main' }) - .text('Skip to content'), - h('').raw(headerHtml ?? ''), - h('main').id('main').attr({ tabindex: '-1' }).raw(bodyHtml), - h('consent'), - h('').raw(footerHtml ?? ''), - ), - ) - }, -} - -const build: BuildInput = { - siteConfig: { - contentDir: path.join( - path.dirname(fileURLToPath(import.meta.url)), - 'purestack.studio', - ), - }, - options: { - templates, - hooks: { - onConfigResolved(context) { - context.components = { - ...defineStudioComponents(), - ...defineDocumentationComponents(context.config), - } - registerStudioStyles() - registerApiReferenceStyles() - registerComponentGuideStyles() - }, - async onContentDiscovered(context) { - await writeSiteGuidePreviews(context.config) - await writeConsentPreview(context.config) - await writeNavMenuPreview(context.config) - await writePageTocPreview(context.config) - await writeSignInPreview(context.config) - await writeTopBarPreview(context.config) - }, - }, - }, - publish: { enabled: command === 'publish' }, -} - -await createLogger() -if (command === 'serve') { - await startDevServer({ build, port: 4700 }) -} else { - try { - await buildSite(build) - } finally { - await getLogger().close() - } -} diff --git a/frontend/studioPlugin.ts b/frontend/studioPlugin.ts new file mode 100644 index 00000000..24da2fda --- /dev/null +++ b/frontend/studioPlugin.ts @@ -0,0 +1,62 @@ +import { h } from '@purestack/ts-html' +import { definePlugin } from '@purestack/ts-ssg' +import { defineStudioComponents } from './components/studioComponents' +import { card } from './demoStyle' +import { registerApiReferenceStyles } from './docs/apiReferenceStyles' +import { registerComponentGuideStyles } from './docs/componentGuide' +import { defineDocumentationComponents } from './docs/docsComponents' +import { writeSiteGuidePreviews } from './docs/siteGuide' +import { writeConsentPreview } from './purestack.studio/components/site/consent/preview' +import { writeNavMenuPreview } from './purestack.studio/components/site/nav-menu/preview' +import { writePageTocPreview } from './purestack.studio/components/site/page-toc/preview' +import { writeSignInPreview } from './purestack.studio/components/site/sign-in/preview' +import { writeTopBarPreview } from './purestack.studio/components/site/top-bar/preview' +import { studioSkin } from './theme/studioSkin' +import { registerStudioStyles } from './theme/studioStyles' + +/** Everything purestack.studio adds to PureStack: skin, components, layout, previews. */ +export const studioPlugin = definePlugin({ + name: 'purestack-studio', + skins: { studio: studioSkin }, + components: (config) => ({ + ...defineStudioComponents(), + ...defineDocumentationComponents(config), + }), + templates: { + studio: ({ head, bodyHtml, headerHtml, footerHtml }) => { + head.push(h('style').raw(card.toCSS())) + return h('html') + .attr({ lang: 'en' }) + .push( + head, + h('body') + .class('studio tone--neutral') + .push( + h('a') + .class('skip-link') + .attr({ href: '#main' }) + .text('Skip to content'), + h('').raw(headerHtml ?? ''), + h('main').id('main').attr({ tabindex: '-1' }).raw(bodyHtml), + h('consent'), + h('').raw(footerHtml ?? ''), + ), + ) + }, + }, + hooks: { + onConfigResolved() { + registerStudioStyles() + registerApiReferenceStyles() + registerComponentGuideStyles() + }, + async onContentDiscovered(context) { + await writeSiteGuidePreviews(context.config) + await writeConsentPreview(context.config) + await writeNavMenuPreview(context.config) + await writePageTocPreview(context.config) + await writeSignInPreview(context.config) + await writeTopBarPreview(context.config) + }, + }, +}) diff --git a/frontend/theme/studioSkin.ts b/frontend/theme/studioSkin.ts index bf300c73..65ada468 100644 --- a/frontend/theme/studioSkin.ts +++ b/frontend/theme/studioSkin.ts @@ -1,14 +1,12 @@ -import { registerSkin } from '@purestack/ts-style' +import type { ThemeSkin } from '@purestack/ts-style' import { createStudioDarkPalette } from './studioSkinDark' import { createStudioLightPalette } from './studioSkinLight' -export function registerStudioSkin() { - registerSkin('studio', { - create: () => ({ - light: createStudioLightPalette(), - dark: createStudioDarkPalette(), - }), - }) +export const studioSkin: ThemeSkin = { + create: () => ({ + light: createStudioLightPalette(), + dark: createStudioDarkPalette(), + }), } export const studioTypography = { diff --git a/package.json b/package.json index d33dc7b1..72dc8397 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, @@ -33,9 +33,9 @@ "scripts": { "purestack": "yarn tsx packages/purestack/src/cli.ts", "dev": "yarn tsx --watch packages/ts-ssg/src/cli.ts serve --content packages/ts-ssg/sample-content", - "frontend": "yarn tsx --watch frontend/studio.ts serve", - "frontend:build": "yarn tsx frontend/studio.ts build", - "frontend:publish": "yarn tsx frontend/studio.ts publish", + "frontend": "yarn tsx --watch packages/purestack/src/cli.ts serve --content frontend/purestack.studio --port 4700", + "frontend:build": "yarn purestack build --content frontend/purestack.studio", + "frontend:publish": "yarn purestack publish --content frontend/purestack.studio", "embed": "yarn tsx scripts/embed.ts && yarn format", "regor-components": "yarn tsx scripts/regor-components.ts", "gen-icons": "yarn tsx scripts/generate-icons.ts", diff --git a/packages/purestack/README.md b/packages/purestack/README.md index 478187e0..3f198c00 100644 --- a/packages/purestack/README.md +++ b/packages/purestack/README.md @@ -108,6 +108,26 @@ needs `--content` pointing to a directory with `siteConfig.json`. For the full option list, run `npx purestack --help`. +## Add plugins + +Plugins add a site's own skins, components, templates, generated pages, and +build steps. List them in `content/purestack.config.ts`; every command loads it, +and `serve` reloads it when it or a file it imports changes: + +```ts +import { defineConfig, definePlugin } from 'purestack' + +const releaseNotes = definePlugin({ + name: 'release-notes', + pages: () => [{ path: 'releases.mdx', source: '# Releases' }], +}) + +export default defineConfig({ plugins: [releaseNotes] }) +``` + +The [Plugins guide](https://purestack.studio/guides/plugins/) covers each kind +of extension. + ## TypeScript API The package also re-exports the `@purestack/ts-ssg` API. For example, a custom @@ -124,14 +144,15 @@ const result = await buildSite({ console.log(`Built ${result.pages} pages in ${result.outDir}`) ``` -`startDevServer`, build hooks, custom templates, and component registration are -available for projects that need more control. +`startDevServer` is available too. Neither function looks for +`purestack.config.ts`; pass plugins through `options.plugins`. ## Learn more - [CLI guide](https://purestack.studio/guides/purestack-cli/) - [Site configuration](https://purestack.studio/guides/site-config/) - [Regor MDX guide](https://purestack.studio/guides/regor/) +- [Plugins guide](https://purestack.studio/guides/plugins/) - [Working sample site](https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg/sample-content) MIT licensed. Source and issues: [PureStack on GitHub](https://github.com/PureStackStudio/PureStack). diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 68ead2e9..cca624d1 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 5598cd9f..36f8b6fe 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.2' +export const version: string = '1.1.3' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 92373060..350e3de8 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 91cf9d66..c1f1747d 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-components/src/standard/btn/btn.test.ts b/packages/ts-components/src/standard/btn/btn.test.ts index 1add61e4..6573adab 100644 --- a/packages/ts-components/src/standard/btn/btn.test.ts +++ b/packages/ts-components/src/standard/btn/btn.test.ts @@ -7,13 +7,13 @@ import { defineIconComponents } from '../icon/icon' import { defineButtonComponents } from './btn' describe('Button rendering', () => { - it('renders default label button with default classes and type', () => { + it('renders default label button with default classes and type', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp(`Save`, { + const html = await renderApp(`Save`, { components, context: createTestContext(), }) @@ -24,17 +24,17 @@ describe('Button rendering', () => { expect(html).not.toContain('btn__icon') }) - it('renders icon at start and end based on iconPosition', () => { + it('renders icon at start and end based on iconPosition', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const startHtml = renderApp(`Code`, { + const startHtml = await renderApp(`Code`, { components, context: createTestContext(), }) - const endHtml = renderApp( + const endHtml = await renderApp( `Code`, { components, @@ -53,13 +53,13 @@ describe('Button rendering', () => { ) }) - it('renders icon-only button with aria label and icon-only class', () => { + it('renders icon-only button with aria label and icon-only class', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp( + const html = await renderApp( ``, { components, @@ -74,13 +74,13 @@ describe('Button rendering', () => { expect(html).not.toContain('btn__label') }) - it('applies tone, size, type, disabled, and custom class', () => { + it('applies tone, size, type, disabled, and custom class', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp( + const html = await renderApp( `Deploy`, { components, @@ -96,13 +96,13 @@ describe('Button rendering', () => { expect(html).toContain('disabled') }) - it('renders BtnLink as an anchor with the same visual classes', () => { + it('renders BtnLink as an anchor with the same visual classes', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp( + const html = await renderApp( `Read docs`, { components, @@ -120,13 +120,13 @@ describe('Button rendering', () => { expect(html).not.toContain('type="button"') }) - it('renders BtnLink icons and resolves rel for external targets', () => { + it('renders BtnLink icons and resolves rel for external targets', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp( + const html = await renderApp( `Docs`, { components, @@ -141,17 +141,17 @@ describe('Button rendering', () => { expect(html).toContain('class="icon btn__icon"') }) - it('supports warning and danger tones', () => { + it('supports warning and danger tones', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const warningHtml = renderApp(`Warn`, { + const warningHtml = await renderApp(`Warn`, { components, context: createTestContext(), }) - const dangerHtml = renderApp(`Delete`, { + const dangerHtml = await renderApp(`Delete`, { components, context: createTestContext(), }) @@ -161,13 +161,13 @@ describe('Button rendering', () => { expect(dangerHtml).toContain('tone--danger') }) - it('supports none variant for fully custom classes', () => { + it('supports none variant for fully custom classes', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp( + const html = await renderApp( `Custom`, { components, @@ -182,13 +182,13 @@ describe('Button rendering', () => { expect(html).not.toContain('tone-text-button-all') }) - it('renders empty label span when button has no slot and no icon', () => { + it('renders empty label span when button has no slot and no icon', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), } - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext(), }) diff --git a/packages/ts-components/src/standard/btnGroup/btnGroup.test.ts b/packages/ts-components/src/standard/btnGroup/btnGroup.test.ts index 8b00f4b8..0a3c01cd 100644 --- a/packages/ts-components/src/standard/btnGroup/btnGroup.test.ts +++ b/packages/ts-components/src/standard/btnGroup/btnGroup.test.ts @@ -7,9 +7,9 @@ import { defineButtonComponents } from '../btn/btn' import { defineIconComponents } from '../icon/icon' import { defineBtnGroupComponents } from './btnGroup' -function renderBtnGroup(markup: string) { +async function renderBtnGroup(markup: string) { const cleanup = ensureDomGlobals() - const html = renderApp(markup, { + const html = await renderApp(markup, { components: { ...defineIconComponents(getSvgIcon), ...defineButtonComponents(), @@ -22,8 +22,8 @@ function renderBtnGroup(markup: string) { } describe('Button group rendering', () => { - it('renders grouped actions without proxy item components', () => { - const html = renderBtnGroup(` + it('renders grouped actions without proxy item components', async () => { + const html = await renderBtnGroup(` Save Docs `) @@ -35,8 +35,8 @@ describe('Button group rendering', () => { expect(html).toContain('href="./docs"') }) - it('renders a native dropdown menu with default trigger affordance', () => { - const html = renderBtnGroup(` + it('renders a native dropdown menu with default trigger affordance', async () => { + const html = await renderBtnGroup(` Archive
Custom
`) @@ -53,8 +53,8 @@ describe('Button group rendering', () => { expect(html).toContain('class="custom-row"') }) - it('supports icon-only dropdown triggers with accessible labels', () => { - const html = renderBtnGroup( + it('supports icon-only dropdown triggers with accessible labels', async () => { + const html = await renderBtnGroup( ` Delete `, @@ -66,8 +66,8 @@ describe('Button group rendering', () => { expect(html).not.toContain('More') }) - it('applies group, trigger, and menu presentation props', () => { - const html = renderBtnGroup(` + it('applies group, trigger, and menu presentation props', async () => { + const html = await renderBtnGroup(` { expect(html).toContain('tone-fill-surface') }) - it('keeps dropdown content open to links and arbitrary markup', () => { - const html = renderBtnGroup(` + it('keeps dropdown content open to links and arbitrary markup', async () => { + const html = await renderBtnGroup(` Default Settings diff --git a/packages/ts-components/src/standard/classicLogo/classicLogo.test.ts b/packages/ts-components/src/standard/classicLogo/classicLogo.test.ts index 0dac64ef..dbac65e1 100644 --- a/packages/ts-components/src/standard/classicLogo/classicLogo.test.ts +++ b/packages/ts-components/src/standard/classicLogo/classicLogo.test.ts @@ -79,13 +79,13 @@ describe('ClassicLogo rendering', () => { } }) - it('renders two brand words and subtitle', () => { + it('renders two brand words and subtitle', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineClassicLogoComponents(), } - const html = renderApp( + const html = await renderApp( ` { expect(html).toContain('href="/"') }) - it('omits subtitle when not provided', () => { + it('omits subtitle when not provided', async () => { const cleanup = ensureDomGlobals() const components = defineClassicLogoComponents() - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext(), }) @@ -152,10 +152,10 @@ describe('ClassicLogo rendering', () => { expect(html).not.toContain('classic-logo__subtitle') }) - it('uses default fills when letter color maps are not provided', () => { + it('uses default fills when letter color maps are not provided', async () => { const cleanup = ensureDomGlobals() const components = defineClassicLogoComponents() - const html = renderApp( + const html = await renderApp( ``, { components, @@ -174,13 +174,13 @@ describe('ClassicLogo rendering', () => { expect(html).not.toContain('--ps-classic-logo-glyph-foreground') }) - it('renders the shared icon component when an icon name is provided', () => { + it('renders the shared icon component when an icon name is provided', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineClassicLogoComponents(), } - const html = renderApp( + const html = await renderApp( ` { - it('renders consent shell and categories when enabled', () => { + it('renders consent shell and categories when enabled', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -18,7 +18,7 @@ describe('Consent component rendering', () => { ...definePanelComponents(), ...defineConsentComponents(), } - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext({ site: { diff --git a/packages/ts-components/src/standard/contactForm/contactForm.test.ts b/packages/ts-components/src/standard/contactForm/contactForm.test.ts index d0bd7d9a..e07e159b 100644 --- a/packages/ts-components/src/standard/contactForm/contactForm.test.ts +++ b/packages/ts-components/src/standard/contactForm/contactForm.test.ts @@ -6,13 +6,13 @@ import { defineButtonComponents } from '../btn/btn' import { defineContactFormComponents } from './contactForm' describe('ContactForm rendering', () => { - it('renders contact fields and action with configured labels', () => { + it('renders contact fields and action with configured labels', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), ...defineContactFormComponents(), } - const html = renderApp( + const html = await renderApp( ``, { components, diff --git a/packages/ts-components/src/standard/flex/flex.test.ts b/packages/ts-components/src/standard/flex/flex.test.ts index eadcf340..dadaa48d 100644 --- a/packages/ts-components/src/standard/flex/flex.test.ts +++ b/packages/ts-components/src/standard/flex/flex.test.ts @@ -5,10 +5,10 @@ import { createTestContext } from '../../test/testContext' import { defineFlexComponents } from './flex' describe('Flex rendering', () => { - it('renders modifier classes for direction, alignment, justification, wrapping, and inline mode', () => { + it('renders modifier classes for direction, alignment, justification, wrapping, and inline mode', async () => { const cleanup = ensureDomGlobals() const components = defineFlexComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -23,10 +23,10 @@ describe('Flex rendering', () => { expect(html).toContain('item') }) - it('supports reverse directions for base and responsive layouts', () => { + it('supports reverse directions for base and responsive layouts', async () => { const cleanup = ensureDomGlobals() const components = defineFlexComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -40,10 +40,10 @@ describe('Flex rendering', () => { expect(html).toContain('flex-direction-md-row-reverse') }) - it('supports wrap as a boolean prop', () => { + it('supports wrap as a boolean prop', async () => { const cleanup = ensureDomGlobals() const components = defineFlexComponents() - const html = renderApp('item', { + const html = await renderApp('item', { components, context: createTestContext(), }) @@ -52,10 +52,10 @@ describe('Flex rendering', () => { expect(html).toContain('class="flex flex-wrap"') }) - it('renders responsive modifier classes for breakpoint-specific layout props', () => { + it('renders responsive modifier classes for breakpoint-specific layout props', async () => { const cleanup = ensureDomGlobals() const components = defineFlexComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -73,10 +73,10 @@ describe('Flex rendering', () => { expect(html).toContain('flex-nowrap-xl') }) - it('omits classes for default row direction and invalid values', () => { + it('omits classes for default row direction and invalid values', async () => { const cleanup = ensureDomGlobals() const components = defineFlexComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, diff --git a/packages/ts-components/src/standard/footer/footer.test.ts b/packages/ts-components/src/standard/footer/footer.test.ts index 0c8f45e2..79e0aa62 100644 --- a/packages/ts-components/src/standard/footer/footer.test.ts +++ b/packages/ts-components/src/standard/footer/footer.test.ts @@ -17,8 +17,8 @@ function withDom(html: string, run: () => T): T { } describe('SiteFooter rendering', () => { - it('renders body content with legal links and socials in footer bottom', () => { - const html = withDom('', () => { + it('renders body content with legal links and socials in footer bottom', async () => { + const html = await withDom('', () => { const components = { ...defineButtonComponents(), ...defineIconComponents(getSvgIcon), @@ -55,8 +55,8 @@ describe('SiteFooter rendering', () => { expect(html).toContain('GitHub') }) - it('teleports to a custom host when teleport prop is provided', () => { - const html = withDom('', () => { + it('teleports to a custom host when teleport prop is provided', async () => { + const html = await withDom('', () => { const components = { ...defineButtonComponents(), ...defineIconComponents(getSvgIcon), diff --git a/packages/ts-components/src/standard/grid/grid.test.ts b/packages/ts-components/src/standard/grid/grid.test.ts index 98effc74..e65e50e0 100644 --- a/packages/ts-components/src/standard/grid/grid.test.ts +++ b/packages/ts-components/src/standard/grid/grid.test.ts @@ -5,10 +5,10 @@ import { createTestContext } from '../../test/testContext' import { defineGridComponents } from './grid' describe('Grid rendering', () => { - it('renders responsive grid variables and modifier classes', () => { + it('renders responsive grid variables and modifier classes', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -27,10 +27,10 @@ describe('Grid rendering', () => { expect(html).toContain('item') }) - it('renders stretch alignment and justification classes', () => { + it('renders stretch alignment and justification classes', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -42,10 +42,10 @@ describe('Grid rendering', () => { expect(html).toContain('class="grid align-stretch justify-items-stretch"') }) - it('renders custom template columns for base and responsive props', () => { + it('renders custom template columns for base and responsive props', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -58,10 +58,10 @@ describe('Grid rendering', () => { expect(html).toContain('--grid-template-columns-md: 200px 1fr') }) - it('supports container as a semantic element override', () => { + it('supports container as a semantic element override', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -75,10 +75,10 @@ describe('Grid rendering', () => { expect(html).toContain('--grid-template-columns: repeat(2, minmax(0, 1fr))') }) - it('keeps responsive numeric columns working when base columns use a template', () => { + it('keeps responsive numeric columns working when base columns use a template', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp( + const html = await renderApp( 'item', { components, @@ -94,13 +94,16 @@ describe('Grid rendering', () => { ) }) - it('keeps the base template across breakpoints when no responsive columns are set', () => { + it('keeps the base template across breakpoints when no responsive columns are set', async () => { const cleanup = ensureDomGlobals() const components = defineGridComponents() - const html = renderApp('item', { - components, - context: createTestContext(), - }) + const html = await renderApp( + 'item', + { + components, + context: createTestContext(), + }, + ) cleanup() expect(html).toContain('--grid-template-columns: minmax(0, 1fr) auto') diff --git a/packages/ts-components/src/standard/icon/icon.test.ts b/packages/ts-components/src/standard/icon/icon.test.ts index 8e3b361d..66a55220 100644 --- a/packages/ts-components/src/standard/icon/icon.test.ts +++ b/packages/ts-components/src/standard/icon/icon.test.ts @@ -6,10 +6,10 @@ import { createTestContext } from '../../test/testContext' import { defineIconComponents } from './icon' describe('Icon rendering', () => { - it('renders svg content by icon name', () => { + it('renders svg content by icon name', async () => { const cleanup = ensureDomGlobals() const components = defineIconComponents(getSvgIcon) - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext(), }) @@ -20,10 +20,10 @@ describe('Icon rendering', () => { expect(html).not.toContain('style="') }) - it('passes accessibility attributes through to the icon root', () => { + it('passes accessibility attributes through to the icon root', async () => { const cleanup = ensureDomGlobals() const components = defineIconComponents(getSvgIcon) - const html = renderApp( + const html = await renderApp( ``, { components, @@ -37,10 +37,10 @@ describe('Icon rendering', () => { expect(html).toMatch(/]*aria-hidden="false"/) }) - it('renders nothing when icon name is missing', () => { + it('renders nothing when icon name is missing', async () => { const cleanup = ensureDomGlobals() const components = defineIconComponents(getSvgIcon) - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext(), }) @@ -49,10 +49,10 @@ describe('Icon rendering', () => { expect(html).not.toContain('class="icon"') }) - it('renders framed icons through IconFrame', () => { + it('renders framed icons through IconFrame', async () => { const cleanup = ensureDomGlobals() const components = defineIconComponents(getSvgIcon) - const html = renderApp( + const html = await renderApp( ``, { components, @@ -72,10 +72,10 @@ describe('Icon rendering', () => { expect(html).toContain(' { + it('renders no frame when IconFrame has no icon name', async () => { const cleanup = ensureDomGlobals() const components = defineIconComponents(getSvgIcon) - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext(), }) diff --git a/packages/ts-components/src/standard/landing/landing.test.ts b/packages/ts-components/src/standard/landing/landing.test.ts index 0fbf10b2..e4ee5a4e 100644 --- a/packages/ts-components/src/standard/landing/landing.test.ts +++ b/packages/ts-components/src/standard/landing/landing.test.ts @@ -6,9 +6,9 @@ import { defineComponents } from '../../defineComponents' import { createTestContext } from '../../test/testContext' describe('Landing components rendering', () => { - it('renders landing band shaped edges with horizontal slant start coordinates', () => { + it('renders landing band shaped edges with horizontal slant start coordinates', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( ` { expect(html).toContain('padding-bottom: calc(1em + 3rem)') }) - it('renders the landing section, metrics, feature card, and CTA actions', () => { + it('renders the landing section, metrics, feature card, and CTA actions', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( ` { expect(html).toContain('href="/getting-started/"') }) - it('renders code showcase result and comparison columns', () => { + it('renders code showcase result and comparison columns', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( ` ({ }) describe('SiteLogo', () => { - it('renders natural brand text and resolves configured links under a base path', () => { + it('renders natural brand text and resolves configured links under a base path', async () => { const cleanup = ensureDomGlobals() try { - const html = renderApp( + const html = await renderApp( '', { components: defineComponents(), @@ -34,10 +34,10 @@ describe('SiteLogo', () => { } }) - it('supports a named mark slot and omits navigation for a null href', () => { + it('supports a named mark slot and omits navigation for a null href', async () => { const cleanup = ensureDomGlobals() try { - const html = renderApp( + const html = await renderApp( '', { components: defineComponents(), context: createTestContext() }, ) diff --git a/packages/ts-components/src/standard/modal/modal.test.ts b/packages/ts-components/src/standard/modal/modal.test.ts index c93d319a..765f3434 100644 --- a/packages/ts-components/src/standard/modal/modal.test.ts +++ b/packages/ts-components/src/standard/modal/modal.test.ts @@ -6,13 +6,13 @@ import { defineButtonComponents } from '../btn/btn' import { defineModalComponents } from './modal' describe('Modal rendering', () => { - it('renders modal shell and trigger with configured motion classes', () => { + it('renders modal shell and trigger with configured motion classes', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), ...defineModalComponents(), } - const html = renderApp( + const html = await renderApp( '

Body

', { components, @@ -32,13 +32,13 @@ describe('Modal rendering', () => { expect(html).toContain('Confirm') }) - it('supports full shell override via content slot', () => { + it('supports full shell override via content slot', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), ...defineModalComponents(), } - const html = renderApp( + const html = await renderApp( '', { components, @@ -51,13 +51,13 @@ describe('Modal rendering', () => { expect(html).not.toContain('modal__close') }) - it('hides close button when showClose is false', () => { + it('hides close button when showClose is false', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), ...defineModalComponents(), } - const html = renderApp( + const html = await renderApp( '

Body

', { components, diff --git a/packages/ts-components/src/standard/navMenu/navMenu.test.ts b/packages/ts-components/src/standard/navMenu/navMenu.test.ts index 93b98c1f..0d4dd90f 100644 --- a/packages/ts-components/src/standard/navMenu/navMenu.test.ts +++ b/packages/ts-components/src/standard/navMenu/navMenu.test.ts @@ -11,7 +11,7 @@ import { defineSignInComponents } from '../signIn/signIn' import { defineNavigationComponents } from './navMenu' describe('NavMenu rendering', () => { - it('evaluates r-else branches for leaf items', () => { + it('evaluates r-else branches for leaf items', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -21,7 +21,7 @@ describe('NavMenu rendering', () => { ...defineSignInComponents(), ...defineNavigationComponents(), } - const html = renderApp( + const html = await renderApp( ``) - const customHtml = render( + const defaultHtml = await render(``) + const customHtml = await render( ``, ) cleanup() @@ -76,9 +76,9 @@ describe('NavMenu rendering', () => { expect(customHtml).not.toContain('tone-fill-flat') }) - it('adds a given class to the navigation panel', () => { + it('adds a given class to the navigation panel', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( ``, { components: { @@ -99,7 +99,7 @@ describe('NavMenu rendering', () => { expect(navClass).toContain('tone-fill-flat') }) - it('marks the currentUrl item as the current page and opens its group', () => { + it('marks the currentUrl item as the current page and opens its group', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -109,7 +109,7 @@ describe('NavMenu rendering', () => { ...defineSignInComponents(), ...defineNavigationComponents(), } - const html = renderApp( + const html = await renderApp( ` { + it('renders stable state keys for collapsible groups', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -136,7 +136,7 @@ describe('NavMenu rendering', () => { ...defineSignInComponents(), ...defineNavigationComponents(), } - const html = renderApp( + const html = await renderApp( `', { + const html = await renderApp('', { components, context: createTestContext({ pageInfo: { @@ -24,10 +24,10 @@ describe('PageScript rendering', () => { expect(html).toContain('type="module"') }) - it('resolves nested relative paths and keeps query/hash suffix', () => { + it('resolves nested relative paths and keeps query/hash suffix', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() - const html = renderApp( + const html = await renderApp( '', { components, @@ -45,33 +45,36 @@ describe('PageScript rendering', () => { expect(html).toContain('type="module"') }) - it('uses the SSG script public path resolver when available', () => { + it('uses the SSG script public path resolver when available', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() const resolvedScripts: string[] = [] - const html = renderApp('', { - components, - context: createTestContext({ - pageInfo: { - relPath: 'login.mdx', - urlPath: '/login/', - }, - resolveScriptPublicPath: (sourceRelPath) => { - resolvedScripts.push(sourceRelPath) - return '/login.ab12.js' - }, - }), - }) + const html = await renderApp( + '', + { + components, + context: createTestContext({ + pageInfo: { + relPath: 'login.mdx', + urlPath: '/login/', + }, + resolveScriptPublicPath: (sourceRelPath) => { + resolvedScripts.push(sourceRelPath) + return '/login.ab12.js' + }, + }), + }, + ) cleanup() expect(resolvedScripts).toEqual(['login.ts']) expect(html).toContain('src="/login.ab12.js?mode=prod#boot"') }) - it('resolves local src relative to an explicit source owner', () => { + it('resolves local src relative to an explicit source owner', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() - const html = renderApp( + const html = await renderApp( '', { components, @@ -89,10 +92,10 @@ describe('PageScript rendering', () => { expect(html).not.toContain('/account/auth-state.js') }) - it('maps same-name folder page scripts beside their folder page', () => { + it('maps same-name folder page scripts beside their folder page', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() - const html = renderApp('', { + const html = await renderApp('', { components, context: createTestContext({ pageInfo: { @@ -106,10 +109,10 @@ describe('PageScript rendering', () => { expect(html).toContain('src="/account/account.js"') }) - it('renders RegorApp as app shell and reuses PageScript src mapping', () => { + it('renders RegorApp as app shell and reuses PageScript src mapping', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() - const html = renderApp( + const html = await renderApp( '', { components, @@ -130,10 +133,10 @@ describe('PageScript rendering', () => { expect(html).toContain('type="module"') }) - it('passes RegorApp source owner through to its PageScript', () => { + it('passes RegorApp source owner through to its PageScript', async () => { const cleanup = ensureDomGlobals() const components = defineScriptComponents() - const html = renderApp( + const html = await renderApp( '', { components, diff --git a/packages/ts-components/src/standard/pageToc/pageToc.test.ts b/packages/ts-components/src/standard/pageToc/pageToc.test.ts index 79370e96..d9cf14d2 100644 --- a/packages/ts-components/src/standard/pageToc/pageToc.test.ts +++ b/packages/ts-components/src/standard/pageToc/pageToc.test.ts @@ -7,9 +7,9 @@ import { defineButtonComponents } from '../btn/btn' import { defineIconComponents } from '../icon/icon' import { definePageTocComponents } from './pageToc' -function renderPageToc(template: string) { +async function renderPageToc(template: string) { const cleanup = ensureDomGlobals() - const html = renderApp(template, { + const html = await renderApp(template, { components: { ...defineButtonComponents(), ...defineIconComponents(getSvgIcon), @@ -24,8 +24,8 @@ function renderPageToc(template: string) { } describe('PageToc rendering', () => { - it('renders a stateless flat panel by default', () => { - const classes = renderPageToc(``) + it('renders a stateless flat panel by default', async () => { + const classes = await renderPageToc(``) expect(classes).toContain('page-toc') expect(classes).toContain('tone-fill-flat') @@ -33,8 +33,8 @@ describe('PageToc rendering', () => { expect(classes).not.toContain('tone-fill-flat-hover') }) - it('accepts tone, variant, variant mode and extra classes', () => { - const classes = renderPageToc( + it('accepts tone, variant, variant mode and extra classes', async () => { + const classes = await renderPageToc( ``, ) diff --git a/packages/ts-components/src/standard/signIn/signIn.test.ts b/packages/ts-components/src/standard/signIn/signIn.test.ts index e625d4c9..391ac98f 100644 --- a/packages/ts-components/src/standard/signIn/signIn.test.ts +++ b/packages/ts-components/src/standard/signIn/signIn.test.ts @@ -11,9 +11,9 @@ import { defineSignInComponents } from './signIn' const getSvgIcon = (name: string) => `` describe('SignIn rendering', () => { - it('renders a circular icon trigger with default account links', () => { + it('renders a circular icon trigger with default account links', async () => { const cleanup = ensureDomGlobals() - const html = renderApp(``, { + const html = await renderApp(``, { components: { ...defineButtonComponents(), ...defineFlexComponents(), @@ -45,9 +45,9 @@ describe('SignIn rendering', () => { expect(html).toContain('href="/signout/"') }) - it('renders a supplied avatar image and custom menu content', () => { + it('renders a supplied avatar image and custom menu content', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( `
Billing `, @@ -77,7 +77,7 @@ describe('SignIn rendering', () => { expect(html).not.toContain('href="/settings/"') }) - it('renders nothing when site auth is disabled or unavailable', () => { + it('renders nothing when site auth is disabled or unavailable', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -86,11 +86,11 @@ describe('SignIn rendering', () => { ...definePanelComponents(), ...defineSignInComponents(), } - const disabledHtml = renderApp(``, { + const disabledHtml = await renderApp(``, { components, context: createTestContext(), }) - const noContextHtml = renderApp(``, { + const noContextHtml = await renderApp(``, { components, context: {} as never, }) @@ -100,9 +100,9 @@ describe('SignIn rendering', () => { expect(noContextHtml).not.toContain('class="sign-in"') }) - it('hides sign up when site auth disables registration', () => { + it('hides sign up when site auth disables registration', async () => { const cleanup = ensureDomGlobals() - const html = renderApp(``, { + const html = await renderApp(``, { components: { ...defineButtonComponents(), ...defineFlexComponents(), @@ -126,9 +126,9 @@ describe('SignIn rendering', () => { expect(html).toContain('href="/account/"') }) - it('passes tone, variant, and variant mode to the panel', () => { + it('passes tone, variant, and variant mode to the panel', async () => { const cleanup = ensureDomGlobals() - const html = renderApp( + const html = await renderApp( ``, { components: { diff --git a/packages/ts-components/src/standard/tabs/tabs.test.ts b/packages/ts-components/src/standard/tabs/tabs.test.ts index 70375c87..95b0acca 100644 --- a/packages/ts-components/src/standard/tabs/tabs.test.ts +++ b/packages/ts-components/src/standard/tabs/tabs.test.ts @@ -8,13 +8,13 @@ import { defineIconComponents } from '../icon/icon' import { defineTabsComponents } from './tabs' describe('Tabs rendering', () => { - it('renders slotted tab content and active tab state', () => { + it('renders slotted tab content and active tab state', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineTabsComponents(), } - const html = renderApp( + const html = await renderApp( ` Run npm install @@ -39,13 +39,13 @@ describe('Tabs rendering', () => { ) }) - it('inherits tab group from parent tabs and marks disabled panes', () => { + it('inherits tab group from parent tabs and marks disabled panes', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineTabsComponents(), } - const html = renderApp( + const html = await renderApp( ` blocked ready @@ -64,13 +64,13 @@ describe('Tabs rendering', () => { expect(html).toContain('>Ready<') }) - it('renders optional tab icons', () => { + it('renders optional tab icons', async () => { const cleanup = ensureDomGlobals() const components = { ...defineIconComponents(getSvgIcon), ...defineTabsComponents(), } - const html = renderApp( + const html = await renderApp( ` Run npm install `, diff --git a/packages/ts-components/src/standard/themeToggle/themeToggle.test.ts b/packages/ts-components/src/standard/themeToggle/themeToggle.test.ts index 03b51815..ef341e0a 100644 --- a/packages/ts-components/src/standard/themeToggle/themeToggle.test.ts +++ b/packages/ts-components/src/standard/themeToggle/themeToggle.test.ts @@ -6,9 +6,9 @@ import { defineIconComponents } from '../icon/icon' import { defineThemeToggleComponents } from './themeToggle' describe('ThemeToggle rendering', () => { - it('renders one labelled toggle button the theme runtime can bind', () => { + it('renders one labelled toggle button the theme runtime can bind', async () => { const cleanup = ensureDomGlobals() - const html = renderApp(``, { + const html = await renderApp(``, { components: { ...defineIconComponents((name) => ``), ...defineThemeToggleComponents(), diff --git a/packages/ts-components/src/standard/topBar/topBar.test.ts b/packages/ts-components/src/standard/topBar/topBar.test.ts index 7683fd92..4952c04c 100644 --- a/packages/ts-components/src/standard/topBar/topBar.test.ts +++ b/packages/ts-components/src/standard/topBar/topBar.test.ts @@ -87,7 +87,7 @@ describe('TopBar rendering', () => { } }) - it('applies logo values from site config', () => { + it('applies logo values from site config', async () => { const cleanup = ensureDomGlobals() const components = { ...defineButtonComponents(), @@ -100,7 +100,7 @@ describe('TopBar rendering', () => { ...defineThemeToggleComponents(), ...defineTopBarComponents(), } - const html = renderApp(``, { + const html = await renderApp(``, { components, context: createTestContext({ site: { diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index b19a953d..7d5e4662 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 25e34cae..f2de880c 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/README.md b/packages/ts-minidom/README.md index 5c5657bc..7ae2d85e 100644 --- a/packages/ts-minidom/README.md +++ b/packages/ts-minidom/README.md @@ -4,6 +4,26 @@ A small DOM implementation used to render Regor components in Node.js. It provides HTML parsing and the document and element APIs needed by PureStack's server-side renderer and component tests. -Use `parseHtml` or `parseFragment` to work with a document directly. `createDom` -temporarily installs DOM globals and returns a cleanup function; call that -function after rendering to restore the previous globals. +Use `parseHtml` or `parseFragment` to work with a document directly. + +`runInDom(html, render)` runs `render` with its own document. The DOM globals, +such as `document` and `window`, point at that document for `render` and +everything it awaits. It returns what `render` returns, a promise included. + +The package stays browser-safe, so how a render keeps its document is up to the +host. By default the document stays current until the render finishes, and +renders must run one at a time. In Node, pass an `AsyncLocalStorage` to +`useDomScope`, and renders can overlap without seeing each other's documents: + +```ts +import { AsyncLocalStorage } from 'node:async_hooks' +import { useDomScope } from '@purestack/ts-minidom' + +useDomScope(new AsyncLocalStorage()) +``` + +PureStack's site builder does this when it starts. + +`createDom(html)` installs a document for the whole process instead, for code +that runs outside a render, such as component tests. It returns a function +that restores the previous globals. diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index f33e07b7..d3fdd820 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-minidom/src/createDom.test.ts b/packages/ts-minidom/src/createDom.test.ts new file mode 100644 index 00000000..93e96d91 --- /dev/null +++ b/packages/ts-minidom/src/createDom.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { type AsyncScope, createDom, runInDom, useDomScope } from './createDom' + +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)) + +describe('runInDom', () => { + it('points the DOM globals at its own document and returns what render returns', () => { + const title = runInDom('

Own

', (doc) => { + expect(globalThis.document).toBe(doc) + expect(globalThis.window.document).toBe(doc) + return document.querySelector('h1')?.textContent + }) + + expect(title).toBe('Own') + }) + + it('keeps its document across awaits until the render finishes', async () => { + const text = await runInDom( + '

first

', + async (doc) => { + await tick() + expect(globalThis.document).toBe(doc) + document.body.appendChild(document.createTextNode(' then')) + return document.body.textContent + }, + ) + + expect(text).toBe('first then') + }) + + it('restores the outer document after a nested render', async () => { + await runInDom('outer', async (outer) => { + const inner = await runInDom( + 'inner', + async () => { + await tick() + return document.body.textContent + }, + ) + expect(inner).toBe('inner') + expect(globalThis.document).toBe(outer) + }) + }) + + it('returns to the process DOM when the render finishes, even when it fails', async () => { + const restore = createDom('

process

') + try { + const processDocument = globalThis.document + + expect(() => + runInDom('', () => { + throw new Error('Render failed.') + }), + ).toThrow('Render failed.') + await expect( + runInDom('', async () => { + await tick() + throw new Error('Async render failed.') + }), + ).rejects.toThrow('Async render failed.') + + expect(globalThis.document).toBe(processDocument) + expect(document.body.textContent).toBe('process') + } finally { + restore() + } + }) + + it('keeps each DOM in the scope useDomScope installs', () => { + const runs: unknown[] = [] + let store: unknown + const scope: AsyncScope = { + getStore: () => store, + run(next, callback) { + runs.push(next) + store = next + try { + return callback() + } finally { + store = undefined + } + }, + } + const restore = useDomScope(scope) + try { + runInDom('', (doc) => { + expect(globalThis.document).toBe(doc) + }) + } finally { + restore() + } + + expect(runs).toHaveLength(1) + expect((runs[0] as { document: unknown }).document).toBeDefined() + }) +}) + +describe('createDom', () => { + it('restores the previous process DOM', () => { + const restoreOuter = createDom('outer') + try { + const outer = globalThis.document + const restoreInner = createDom('inner') + expect(document.body.textContent).toBe('inner') + restoreInner() + expect(globalThis.document).toBe(outer) + } finally { + restoreOuter() + } + }) +}) diff --git a/packages/ts-minidom/src/createDom.ts b/packages/ts-minidom/src/createDom.ts index c40431b2..8728577e 100644 --- a/packages/ts-minidom/src/createDom.ts +++ b/packages/ts-minidom/src/createDom.ts @@ -1,102 +1,144 @@ -import cssEscape from './cssEscape' import { parseHtml, resetMiniDomCaches } from './minidom' -type GlobalKey = - | 'window' - | 'document' - | 'Node' - | 'Element' - | 'HTMLElement' - | 'HTMLSlotElement' - | 'DocumentFragment' - | 'CustomEvent' - | 'Event' - | 'MouseEvent' - | 'MutationObserver' - | 'Comment' - | 'Text' - | 'HTMLTemplateElement' - | 'CSS' - | 'localStorage' - | 'sessionStorage' +const DOM_GLOBAL_KEYS = [ + 'window', + 'document', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLSlotElement', + 'DocumentFragment', + 'CustomEvent', + 'Event', + 'MouseEvent', + 'MutationObserver', + 'Comment', + 'Text', + 'HTMLTemplateElement', + 'CSS', + 'localStorage', + 'sessionStorage', +] as const -export function createDom(html: string): () => void { - const { document, window } = parseHtml(html) - const globals = globalThis as Record - const original: Partial> = {} - const keys: GlobalKey[] = [ - 'window', - 'document', - 'Node', - 'Element', - 'HTMLElement', - 'HTMLSlotElement', - 'DocumentFragment', - 'CustomEvent', - 'Event', - 'MouseEvent', - 'MutationObserver', - 'Comment', - 'Text', - 'HTMLTemplateElement', - 'CSS', - 'localStorage', - 'sessionStorage', - ] +type DomGlobals = Partial> - for (const key of keys) original[key] = globals[key] +/** + * Keeps a value for a call and everything it awaits. Node's + * `AsyncLocalStorage` is one. + */ +export interface AsyncScope { + getStore(): unknown + run(store: unknown, callback: () => R): R +} - const win = window as Record - assignGlobal(globals, 'window', window) - assignGlobal(globals, 'document', document) - assignGlobal(globals, 'Node', win.Node) - assignGlobal(globals, 'Element', win.Element) - assignGlobal(globals, 'HTMLElement', win.HTMLElement) - assignGlobal(globals, 'HTMLSlotElement', win.HTMLSlotElement) - assignGlobal(globals, 'DocumentFragment', win.DocumentFragment) - assignGlobal(globals, 'CustomEvent', win.CustomEvent) - assignGlobal(globals, 'Event', win.Event) - assignGlobal(globals, 'MouseEvent', win.MouseEvent) - assignGlobal(globals, 'MutationObserver', win.MutationObserver) - assignGlobal(globals, 'Comment', win.Comment) - assignGlobal(globals, 'Text', win.Text) - assignGlobal(globals, 'HTMLTemplateElement', win.HTMLTemplateElement) +/** The current render's DOM, found through the scope `useDomScope` set. */ +let domScope: AsyncScope = createSequentialScope() +/** The DOM that code outside `runInDom` sees, installed by `createDom`. */ +let processDom: DomGlobals = {} +let globalsInstalled = false - const windowCss = win.CSS as { escape?: unknown } | undefined - assignGlobal( - globals, - 'CSS', - windowCss && typeof windowCss.escape === 'function' - ? windowCss - : { escape: cssEscape }, - ) - assignGlobal(globals, 'localStorage', win.localStorage) - assignGlobal(globals, 'sessionStorage', win.sessionStorage) +/** + * Sets how `runInDom` keeps each render's DOM. With an async scope, such as + * Node's `AsyncLocalStorage`, renders can overlap. Without one, a render's + * DOM stays current until it finishes, so renders must run one at a time. + * Returns a function that restores the previous scope. + */ +export function useDomScope(scope: AsyncScope): () => void { + const previous = domScope + domScope = scope + return () => { + domScope = previous + } +} +/** + * Runs `render` with its own DOM, parsed from `html`. The DOM globals point + * at it for `render` and everything it awaits, and return to the process DOM + * when it finishes. It returns what `render` returns, a promise included. + */ +export function runInDom( + html: string, + render: (document: Document) => T, +): T { + installDomGlobals() + const dom = createDomGlobals(html) + return domScope.run(dom, () => render(dom.document as Document)) +} + +/** + * Installs a DOM for the whole process, for code that runs outside a render, + * such as component tests. Returns a function that restores the previous one. + */ +export function createDom(html: string): () => void { + installDomGlobals() + const previous = processDom + processDom = createDomGlobals(html) return () => { - for (const key of keys) globals[key] = original[key] + processDom = previous resetMiniDomCaches() } } -function assignGlobal( - globals: Record, - key: GlobalKey, - value: unknown, -) { - try { +export function ensureDomGlobals(): () => void { + const globals = globalThis + if (globals.document && globals.window) return () => {} + return createDom('') +} + +function createDomGlobals(html: string): DomGlobals { + const window = parseHtml(html).window as Record + return Object.fromEntries(DOM_GLOBAL_KEYS.map((key) => [key, window[key]])) +} + +function currentDom() { + return (domScope.getStore() as DomGlobals | undefined) ?? processDom +} + +/** + * Turns each DOM global into an accessor that reads the current render's DOM + * inside `runInDom`, and the process DOM everywhere else. + */ +function installDomGlobals() { + if (globalsInstalled) return + globalsInstalled = true + const globals = globalThis as Record + for (const key of DOM_GLOBAL_KEYS) { + processDom[key] = globals[key] Object.defineProperty(globals, key, { - value, configurable: true, - writable: true, + get: () => currentDom()[key], + set: (value: unknown) => { + currentDom()[key] = value + }, }) - } catch { - globals[key] = value } } -export function ensureDomGlobals(): () => void { - const globals = globalThis - if (globals.document && globals.window) return () => {} - return createDom('') +/** + * The scope used until `useDomScope` sets another: the store stays current + * from the start of `run` until its callback, or the promise it returns, + * finishes. Nested runs work; overlapping ones would share the latest store. + */ +function createSequentialScope(): AsyncScope { + let current: unknown + return { + getStore: () => current, + run(store: unknown, callback: () => R): R { + const previous = current + current = store + const restore = () => { + current = previous + } + let result: R + try { + result = callback() + } catch (error) { + restore() + throw error + } + if (result instanceof Promise) return result.finally(restore) as R + restore() + return result + }, + } } diff --git a/packages/ts-minidom/src/index.ts b/packages/ts-minidom/src/index.ts index bb3809dd..15fe3c92 100644 --- a/packages/ts-minidom/src/index.ts +++ b/packages/ts-minidom/src/index.ts @@ -1,4 +1,10 @@ -export { createDom, ensureDomGlobals } from './createDom' +export { + type AsyncScope, + createDom, + ensureDomGlobals, + runInDom, + useDomScope, +} from './createDom' export { default as cssEscape } from './cssEscape' export { MiniComment, diff --git a/packages/ts-minidom/src/minidom.ts b/packages/ts-minidom/src/minidom.ts index a1451ae9..b60c3773 100644 --- a/packages/ts-minidom/src/minidom.ts +++ b/packages/ts-minidom/src/minidom.ts @@ -1825,13 +1825,6 @@ const selectorListCache = new Map() export function resetMiniDomCaches() { selectorListCache.clear() - const doc = globalThis.document as - | (Document & { head?: ParentNode | null; body?: ParentNode | null }) - | undefined - doc?.head?.replaceChildren() - doc?.body?.replaceChildren() - globalThis.localStorage?.clear() - globalThis.sessionStorage?.clear() } function getCompiledSelectors(selector: string) { diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 5079874f..06987bf2 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-render/README.md b/packages/ts-render/README.md index 43df8361..80334e16 100644 --- a/packages/ts-render/README.md +++ b/packages/ts-render/README.md @@ -1,8 +1,16 @@ # @purestack/ts-render Server-side rendering for PureStack's Regor components. `renderApp` takes HTML, -a component set, and a `TsSsgContext`, then returns rendered HTML with any -required component scripts attached. +a component set, and a `TsSsgContext`, and resolves to the rendered HTML with +any required component scripts attached. + +Each render gets a document of its own. With an async DOM scope installed, as +the site builder does, renders can run at the same time; see `useDomScope` in +`@purestack/ts-minidom`. + +`onRendered(document)` runs on the rendered document before it becomes HTML. +It may be async, and the document stays that render's own while it runs. `componentRegistry` lets custom build pipelines register components by name. +The components passed to `renderApp` apply on top of them, for that render only. The site generator uses this package to turn page markup into static output. diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index f527a0bb..7e79981b 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-render/src/componentRegistry.ts b/packages/ts-render/src/componentRegistry.ts index 9bdfbdd2..3285d63a 100644 --- a/packages/ts-render/src/componentRegistry.ts +++ b/packages/ts-render/src/componentRegistry.ts @@ -21,15 +21,6 @@ export const componentRegistry = { hasComponentName(name: string) { return hasComponentName(name, registry) }, - snapshot(): Map> { - return new Map(registry) - }, - restore(snapshot: Map>) { - registry.clear() - for (const [name, component] of snapshot) { - registry.set(name, component) - } - }, clear() { registry.clear() }, diff --git a/packages/ts-render/src/renderApp.ts b/packages/ts-render/src/renderApp.ts index d3cd60b5..3762a1dd 100644 --- a/packages/ts-render/src/renderApp.ts +++ b/packages/ts-render/src/renderApp.ts @@ -1,20 +1,29 @@ import type { TsSsgContext } from '@purestack/ts-common' -import { createDom } from '@purestack/ts-minidom' +import { runInDom } from '@purestack/ts-minidom' import { buildModalScript, buildTabsScript } from '@purestack/ts-page-scripts' +import { isPlainObject } from '@purestack/ts-util' import { createApp } from 'regor' import { componentRegistry } from './componentRegistry' export interface RenderAppOptions { + /** Components for this render, on top of the registered ones. */ components: unknown context: TContext - /** Runs on the rendered document, before it becomes HTML. */ - onRendered?: (document: Document) => void + /** + * Runs on the rendered document, before it becomes HTML. It may be async: + * the document stays this render's own until it finishes. + */ + onRendered?: (document: Document) => void | Promise } +/** + * Renders Regor markup to HTML in a document of its own, so renders can + * overlap, and `onRendered` can await, without seeing each other's pages. + */ export const renderApp = ( html: string, options: RenderAppOptions, -) => { +): Promise => { const normalizedHtml = html.trimStart() const isDocument = normalizedHtml.startsWith('') || @@ -23,28 +32,25 @@ export const renderApp = ( const htmlToParse = isDocument ? normalizedHtml : `${html}` - const cleanup = createDom(htmlToParse) - const snapshot = componentRegistry.snapshot() - const runtimeEmbeds = new Map() - const baseContext = options.context - const tsSsgContext = { - ...baseContext, - recordRuntimeEmbed: (name: string, position: 'body' | 'head') => { - const normalized = name.trim().toLowerCase() - if (normalized.length > 0) { - runtimeEmbeds.set(normalized, position) - } - baseContext.recordRuntimeEmbed(name, position) - }, - } - try { - if (options.components) componentRegistry.registerMany(options.components) - const components = { - ...componentRegistry.getAll(), + return runInDom(htmlToParse, async (document) => { + const runtimeEmbeds = new Map() + const baseContext = options.context + const tsSsgContext = { + ...baseContext, + recordRuntimeEmbed: (name: string, position: 'body' | 'head') => { + const normalized = name.trim().toLowerCase() + if (normalized.length > 0) { + runtimeEmbeds.set(normalized, position) + } + baseContext.recordRuntimeEmbed(name, position) + }, } createApp( { - components, + components: { + ...componentRegistry.getAll(), + ...(isPlainObject(options.components) ? options.components : {}), + }, tsSsgContext, }, { @@ -52,16 +58,12 @@ export const renderApp = ( }, ) appendEmbeddedScriptsToDom(runtimeEmbeds, tsSsgContext) - options.onRendered?.(document) + await options.onRendered?.(document) if (isDocument) { - const documentHtml = document.documentElement?.outerHTML ?? '' - return `${documentHtml}` + return `${document.documentElement?.outerHTML ?? ''}` } return document.body?.innerHTML ?? '' - } finally { - componentRegistry.restore(snapshot) - cleanup() - } + }) } function appendEmbeddedScriptsToDom( diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 8fab2f0f..c8a25bd0 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.2", + "version": "1.1.3", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index b9402171..5d1fab0d 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -73,6 +73,8 @@ Flags: - `--no-watch` (`serve`) - `--no-reload` (`serve`) +When the content directory holds a `purestack.config.ts`, every command loads its plugins; `serve` reloads it when it or a local file it imports changes. See [Plugins](#plugins). + Examples from this monorepo: ```bash @@ -305,41 +307,101 @@ Set `"pageLinks": true` in a nav file to render previous/next `BtnLink` controls after doc page content. The links follow the final visible navigation order, inherit into child folders, and skip external URLs. -## Templates +## Plugins -Built-in templates: +A plugin is a named bundle of extensions: skins, components, templates, Markdown transforms, generated pages, build hooks, and dev server middleware. The [Plugins guide](https://purestack.studio/guides/plugins/) walks through each part. -- `doc` (default) -- `splash` +A site lists its plugins in `purestack.config.ts`, next to `siteConfig.json`, and the CLI loads it: + +```ts +import { defineConfig } from '@purestack/ts-ssg' +import { productPlugin } from '../plugins/product' + +export default defineConfig({ plugins: [productPlugin] }) +``` -Provide custom templates through `options.templates` and select one with the page's `template` frontmatter: +The config and the local files it imports are bundled with esbuild when it loads; packages are imported from `node_modules`, and PureStack packages resolve to the copy running the build. `buildSite` and `startDevServer` do not look for the file: pass plugins through `options.plugins`, or give `startDevServer` the file as `configFile` to load and reload it as `serve` does. Config plugins apply after `options.plugins`. ```ts -import { h, type TSNode } from '@purestack/ts-html' -import { buildSite, type PageTemplateMap } from '@purestack/ts-ssg' - -const templates: PageTemplateMap = { - product: ({ head, bodyHtml, headerHtml, footerHtml }) => - h('html').push( - head, - h('body').push( - h('').raw(headerHtml ?? ''), - h('main').attr({ class: 'product' }).raw(bodyHtml), - h('').raw(footerHtml ?? ''), +import { h } from '@purestack/ts-html' +import { buildSite, definePlugin } from '@purestack/ts-ssg' +import { themeSkins } from '@purestack/ts-style' + +const productPlugin = definePlugin({ + name: 'product', + skins: { + product: { create: () => themeSkins.standard.create() }, + }, + components: (config) => ({ + // Regor components, built from the resolved site config + }), + templates: { + product: ({ head, bodyHtml }) => + h('html').push( + head, + h('body').push(h('main').attr({ class: 'product' }).raw(bodyHtml)), ), - ) as TSNode<'html'>, -} + }, + hooks: { + onPageRendered(context, page) { + page.html = page.html.replace('', '') + }, + }, +}) -await buildSite({ siteConfig: { contentDir: './content' }, options: { templates } }) +await buildSite({ + siteConfig: { contentDir: './content', style: { theme: { skin: 'product' } } }, + options: { plugins: [productPlugin] }, +}) ``` +Plugins apply in order: + +- **Skins** are available while the site config resolves, so `style.theme.skin` can select one; they are removed afterwards and never leak into another build. A plugin skin cannot reuse a built-in skin's name, such as `standard`. +- **Components** are built from the resolved site config and may replace built-in components by name. +- **Templates** may replace the built-in `doc` and `splash` templates by name. +- **Markdown** remark and rehype plugins run in plugin order on every page and shared header or footer: remark on the Markdown tree, then rehype on the HTML tree, before outline collection and code highlighting. Regor markup reaches them as raw HTML nodes, and `file.path` is the content path with forward slashes. +- **Pages** generators return `{ path, source }` pages that build like files at those content paths. `source` is the text, or a function PureStack calls whenever it needs the text, without keeping what it returns. They see the site's content files, run on every build, and run again in the dev server whenever content or assets change; pages no longer generated are removed. +- **Hooks** run every plugin's handler for each lifecycle event, one after another. An error names the plugin it came from. +- **Dev middleware** sees each dev server request, except live reload, before the site; the first plugin to send headers handles it. Builds ignore it. + +Each plugin is checked when the build starts. An unknown field or hook name, such as `onPageRender`, a value of the wrong type, two plugins sharing a name, or two plugins defining the same skin, component, template, or generated page fails the build with the plugin's name. For a single hook, pass an inline plugin: `{ name: 'site', hooks: { … } }`. + +### Hooks + +Every page render, in a full build or a dev server re-render, runs the page hooks: + +- `onPageStart(context, file)`: before the page renders. +- `onPageDocument(context, page)`: on the rendered document, before it becomes HTML and before its links resolve. `page` holds the `document`, `file`, `frontmatter`, and `urlPath`. The hook may await: the document, and the global `document` with it, stay this page's while other pages render. +- `onPageRendered(context, page)`: after it becomes HTML; changes to `page.html` are written. +- `onPageWritten(context, page)`: after the HTML file is written. + +Each full build also runs, in order: + +- `onConfigResolved(context)`: before any output; register styles here. +- `onContentDiscovered(context, files)`: after content discovery, before pages render. +- `onNavigationBuilt(context, navigation)` +- `onStylesWritten(context, result)` +- `onBuildComplete(context, result)` + +The dev server runs a full build when it starts, when `siteConfig.json` changes, and when `purestack.config.ts` or a file it imports changes; other edits re-render only the affected pages. + +## Templates + +Built-in templates: + +- `doc` (default) +- `splash` + +Add templates with a plugin's `templates` field and select one with the page's `template` frontmatter. + A template receives a `PageTemplateInput`: the prepared `head`, the compiled `bodyHtml`, the page's nearest `headerHtml` and `footerHtml`, `site` config, `navigation`, `outline`, and `pageInfo`, whose `frontmatter` keeps any custom fields the page defines. ## Components and Regor MDX Every component in `@purestack/ts-components` is registered for each build, so content can use them directly. The [component reference](https://purestack.studio/components/) documents each one. -Register your own Regor components through `options.components`, or assign `context.components` in the `onConfigResolved` hook. +Add your own Regor components with a plugin's `components` field. A component registered as `productCard` is used as `` in content. Components render to static HTML at build time. For behavior in the browser, load a page script with `PageScript` or mount a browser-side Regor app with `RegorApp`. @@ -347,7 +409,7 @@ Components render to static HTML at build time. For behavior in the browser, loa The built-in skin is `standard`. Select a skin with `style.theme.skin` in `siteConfig.json`. -Skins come from `@purestack/ts-style`: `themeSkins` holds the registered skins, and `registerSkin(name, skin)` adds your own before the build starts. See the [Themes guide](https://purestack.studio/guides/themes/) for creating one. +Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` holds the registered skins; see the [Themes guide](https://purestack.studio/guides/themes/) for creating one. `style.themes` controls generated files: @@ -359,6 +421,7 @@ Skins come from `@purestack/ts-style`: `themeSkins` holds the registered skins, - Markdown: `remark-parse` + `remark-gfm` - Regor MDX (`.mdx`, `.rmdx`): `remark-parse` + `remark-gfm` with Regor component markup preservation +- Plugin remark and rehype plugins, in plugin order - HTML output via HAST + rehype - H2/H3 outline extraction for page TOC - Code highlighting with highlight.js or Shiki @@ -369,41 +432,11 @@ Skins come from `@purestack/ts-style`: `themeSkins` holds the registered skins, - `disableHighlighter`: skip highlighting - `compileMdAsMdx`: compile `.md` files as Regor MDX (default `true`); set `false` to keep `.md` as plain Markdown -## Build Hooks - -Pass `BuildHooks` through `options.hooks` to `buildSite` or `startDevServer`. Hooks, templates, and components need the programmatic API; the CLI reads only `siteConfig.json`. - -```ts -import { type BuildHooks, startDevServer } from '@purestack/ts-ssg' - -const hooks: BuildHooks = { - onPageRendered(context, page) { - page.html = page.html.replace('', '') - }, -} - -await startDevServer({ build: { siteConfig: { contentDir: './content' }, options: { hooks } } }) -``` - -Every page render, in a full build or a dev server re-render, runs the page hooks: - -- `onPageStart(context, file)`: before the page renders. -- `onPageRendered(context, page)`: after it renders; changes to `page.html` are written. -- `onPageWritten(context, page)`: after the HTML file is written. - -Each full build also runs, in order: - -- `onConfigResolved(context)`: before any output; register components on `context.components` here. -- `onContentDiscovered(context, files)`: after content discovery, before pages render. -- `onNavigationBuilt(context, navigation)` -- `onStylesWritten(context, result)` -- `onBuildComplete(context, result)` - -The dev server runs a full build when it starts and when `siteConfig.json` changes; other edits re-render only the affected pages. - -Build option: +## Build Options -- `writeErrorPages`: when true, render failures write an HTML error page to the target output path and an archive copy under `/.ts-ssg/errors/`. +- `plugins`: plugins to apply, in order; see [Plugins](#plugins). +- `cleanOutDir`: empty the output folder before building. +- `writeErrorPages`: when true, render failures write an HTML error page to the target output path and an archive copy under `/.ts-ssg/errors/`. The dev server enables it. ## Incremental Build and Manifest @@ -423,6 +456,8 @@ In dev/watch mode: - file changes apply incrementally when safe, - a page affected by a shared change, such as a header, footer, or navigation edit, renders again on its next request, before it is served, - site config changes trigger full rebuild, +- a change to `purestack.config.ts` or a local file it imports loads the config again and triggers a full rebuild, +- plugin generated pages regenerate when content or assets change, - lazy route render can happen on first request for missing HTML route, - live reload is served over SSE (`/__ts-ssg/events`). - dev server enables `writeErrorPages` automatically so template/MDX errors are visible immediately at the failing route. @@ -563,6 +598,7 @@ Behavior: Functions: - `buildSite`, `startDevServer`, `runCli` +- `definePlugin`, `defineConfig` - `resolveSiteConfig` - `normalizeFrontmatter`, `parseFrontmatterSource` - `buildNavigation`, `resolveNavigationConfig`, `resolvePageNavigation` @@ -572,13 +608,13 @@ Functions: Types: - Build: `BuildInput`, `BuildOptions`, `BuildResult`, `BuildCountSummary`, `PublishOptions` -- Hooks: `BuildHooks`, `BuildContext`, `ResolvedContentFile`, `PageRenderResult`, `NavigationTree`, `WriteStylesResult` +- Plugins and hooks: `PureStackPlugin`, `PureStackConfig`, `PureStackMarkdown`, `GeneratedPage`, `PageGenerationContext`, `BuildHooks`, `BuildContext`, `ResolvedContentFile`, `PageDocument`, `PageRenderResult`, `NavigationTree`, `WriteStylesResult` - Templates: `PageTemplateMap`, `PageTemplate`, `PageTemplateInput`, `PageInfo`, `PageFrontmatter` - Config and components: `SiteConfig`, `SiteConfigInput`, `TsSsgContext` - Dev server: `DevServerInput`, `DevServerOptions`, `DevServerHandle` - Highlighting: `MdxCodeHighlighter`, `MdxCodeLangs`, `MdxCodeThemes` -Related packages: `@purestack/ts-html` builds template markup (`h`), `@purestack/ts-style` provides skins (`registerSkin`, `themeSkins`), and `@purestack/ts-components` provides the built-in components. +Related packages: `@purestack/ts-html` builds template markup (`h`), `@purestack/ts-style` provides skins (`themeSkins`, `ThemeSkin`), and `@purestack/ts-components` provides the built-in components. ## Build Result Shape diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 385b54c1..477384c1 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/src/build/build-config.ts b/packages/ts-ssg/src/build/build-config.ts index d3d64dff..c54660de 100644 --- a/packages/ts-ssg/src/build/build-config.ts +++ b/packages/ts-ssg/src/build/build-config.ts @@ -1,5 +1,6 @@ import type { SiteConfig } from '@purestack/ts-common' import { resolveSiteConfig } from '../config/config' +import { assertValidPlugins, withPluginSkins } from '../plugins/plugin' import type { BuildInput } from './site' export interface PublishOptions { @@ -7,7 +8,11 @@ export interface PublishOptions { } export function resolveBuildSiteConfig(input: BuildInput = {}): SiteConfig { - const config = resolveSiteConfig(input.siteConfig) + const plugins = input.options?.plugins ?? [] + assertValidPlugins(plugins) + const config = withPluginSkins(plugins, () => + resolveSiteConfig(input.siteConfig), + ) if (input.publish?.enabled !== true) return config return { ...config, diff --git a/packages/ts-ssg/src/build/incremental/change-applier.ts b/packages/ts-ssg/src/build/incremental/change-applier.ts index c5bae8df..05e8d651 100644 --- a/packages/ts-ssg/src/build/incremental/change-applier.ts +++ b/packages/ts-ssg/src/build/incremental/change-applier.ts @@ -198,6 +198,7 @@ export class IncrementalChangeApplier { delete manifest.assets[relPath] this.input.scriptEntrypoints.removeTrackedEntrypoint(relPath) this.input.contentState.refreshAssets() + await this.input.contentState.refreshGeneratedPages() result.deletedAssets += 1 } if (ext === '.ts') await this.handleScriptAssetChange(relPath, result) @@ -263,6 +264,7 @@ export class IncrementalChangeApplier { ...signature, } this.input.contentState.refreshAssets() + await this.input.contentState.refreshGeneratedPages() result.changedAssets += 1 await this.input.persistManifest() } diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 1f70bfe6..d94e6398 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -7,10 +7,12 @@ import { resolveContentFile, } from '../../i18n/content' import { buildNavigation } from '../../navigation/navigation' +import type { PureStackPlugin } from '../../plugins/plugin' import type { ContentRouteIndex } from '../content-urls' import { type BuildManifest, type FileSignature, + readContentSignature, readSignature, } from '../manifest' import { resolveOutPath } from '../out-path' @@ -36,8 +38,10 @@ interface IncrementalContentStateInput { config: SiteConfig context: BuildContext hooks: BuildHooks + plugins: readonly PureStackPlugin[] log: Logger onPageBuilt: (relPath: string, scriptEntrypoints: string[]) => void + onPageRemoved: (relPath: string) => void persistManifest: () => Promise getManifest: () => BuildManifest } @@ -60,6 +64,7 @@ interface RebuildSingleContentInput { export class IncrementalContentState { private readonly dirtyPages = new Set() private markedPages = 0 + private generatedPages = new Map() private readonly renderInFlight = new Map>() private readonly contentIndex: ManifestContentIndex @@ -103,7 +108,7 @@ export class IncrementalContentState { const { context, hooks, config } = this.input try { await hooks.onPageStart?.(context, file) - const page = await renderPageFromFile(context, file) + const page = await renderPageFromFile(context, file, hooks) this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) await hooks.onPageRendered?.(context, page) await writePage(page, config.html.minify) @@ -145,13 +150,58 @@ export class IncrementalContentState { * the same pages cost nothing beyond discovery. */ async refreshContent() { - const { config, context } = this.input - const contentFiles = await discoverSiteContent(config) + const { config, context, plugins } = this.input + const contentFiles = await discoverSiteContent(config, plugins) + const generated = this.trackGeneratedPages(contentFiles) + for (const relPath of generated.removed) { + await this.handleMissingRelPathSource(relPath) + this.input.onPageRemoved(relPath) + } this.updateContentRoutes(context.contentRoutes.withPages(contentFiles)) + this.markPagesDirty(generated.changed) context.translationsByKey = buildTranslationsByKey(contentFiles) return contentFiles } + /** + * Maps the URLs of freshly discovered pages, so a request finds a new page + * before the build that discovered it writes the manifest. + */ + indexContentFiles(contentFiles: ResolvedContentFile[]) { + this.contentIndex.updateUrlPathMapFromFiles(contentFiles) + } + + /** Generators may read data files, so an asset change runs them again. */ + async refreshGeneratedPages() { + if (this.input.plugins.some((plugin) => plugin.pages)) { + await this.refreshContent() + } + } + + /** + * Remembers the generated pages among `contentFiles` and reports which + * ones changed their source or stopped being generated. + */ + trackGeneratedPages(contentFiles: readonly ResolvedContentFile[]) { + const previous = this.generatedPages + const changed: string[] = [] + this.generatedPages = new Map() + for (const file of contentFiles) { + if (file.source === undefined) continue + const before = previous.get(file.relPath) + // A source function's text is never kept, so it counts as changed on + // every regeneration; its page renders again when next requested. + const sourceChanged = + typeof file.source === 'function' || before?.source !== file.source + if (before && sourceChanged) changed.push(file.relPath) + this.generatedPages.set(file.relPath, file) + } + const removed = [...previous.keys()].filter( + (relPath) => !this.generatedPages.has(relPath), + ) + return { changed, removed } + } + /** Picks up added or removed assets from the manifest. */ refreshAssets() { const { context, getManifest } = this.input @@ -296,6 +346,8 @@ export class IncrementalContentState { } private toResolvedContentFile(relPath: string, ext: string) { + const generated = this.generatedPages.get(relPath) + if (generated) return generated const file = toContentFile(this.input.config.contentDir, relPath, ext) return resolveContentFile(this.input.config, file) } @@ -343,6 +395,8 @@ export class IncrementalContentState { } private async readRelPathSignature(relPath: string) { + const generated = this.generatedPages.get(relPath) + if (generated) return readContentSignature(generated) const absPath = path.join(this.input.config.contentDir, relPath) return readSignature(absPath) } diff --git a/packages/ts-ssg/src/build/incremental/incremental.md b/packages/ts-ssg/src/build/incremental/incremental.md index 353c8566..0a5c859a 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.md +++ b/packages/ts-ssg/src/build/incremental/incremental.md @@ -26,13 +26,21 @@ reason about “what changed?” vs “what must be rebuilt?”. ## Lifecycle overview -1. `createIncrementalBuilder` resolves config, navigation config, and initial - navigation tree. +1. `createIncrementalBuilder` resolves the config, plugins, and build context. + It reads no content: discovery, page generation, navigation, and headers + and footers are prepared once, by `prepareContent` (see below). 2. It loads the existing manifest, keeping it only if compatible with current config (`isCompatibleManifest`); otherwise it starts from `createEmptyManifest`. -3. It returns `{ buildAll, applyChange }` for the caller to use in dev servers - or watch mode. +3. It returns `{ buildAll, applyChange, renderIfDirtyByOutPath, + renderByUrlPath }` for the caller to use in dev servers or watch mode. + +The content is prepared once per full build. `buildAll` prepares it as part of +the build. `applyChange` and the two render calls first await +`ensureContentReady`: they wait for a build that is preparing, or, when no build +has run, as when resuming from an earlier build's manifest, prepare the content +from the manifest themselves. A failed preparation is tried again by the next +call. ## `buildAll(reason)` @@ -40,7 +48,9 @@ This is the canonical full build path: - Resolves user hooks from `input.hooks`. - Prepares output directory and copies static assets. -- Discovers content and rebuilds navigation. +- Discovers content, runs page generators, and rebuilds navigation, once + (`prepareContent`). Discovered pages join the URL index right away, so a + request during the build finds a new page. - Renders all pages sequentially. - Writes styles and captures style outputs/signature. - Discovers assets to build an authoritative manifest snapshot. @@ -79,6 +89,23 @@ so the dev server can reload the browser. - **A script bundle's hashed name changed** ➜ the pages that load it, rendered right away. +## Generated pages + +Plugin `pages` generators run inside `discoverSiteContent`, so every discovery, +full or incremental, sees the same content list. A generated page is a +`ContentFile` whose `source` is its text, or a function that returns it; its +`absPath` names no file. `readContentSource` reads either, or a real file. + +- `readContentSignature` signs a generated page by its source size (0 for a + source function) and the time it was generated, so the manifest keeps an + entry and an `outPath` for it like any page. No file event names a generated + page, so nothing compares it. +- `refreshContent` regenerates pages. A page whose source text changed, or + whose source is a function, is marked dirty, and a page no longer generated + loses its output and manifest entry. +- An asset change calls `refreshGeneratedPages`, since generators may read data + files. It does nothing when no plugin generates pages. + ## Manifest assembly `buildManifest` composes a fresh `BuildManifest` from: diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index 66522229..d580f3da 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -4,6 +4,8 @@ import path from 'node:path' import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../../config/config' +import { parseFrontmatterSource } from '../../frontmatter/frontmatter' +import type { PureStackPlugin } from '../../plugins/plugin' import { makeRepoTempDir } from '../../test/repoTempDir' import { createEmptyManifest, readManifest, writeManifest } from '../manifest' import type { BuildHooks } from '../site' @@ -431,7 +433,7 @@ describe('incremental builder', () => { base: string, mode: 'auto' | 'hybrid' | 'none', files: Record, - hooks: BuildHooks = {}, + plugins: PureStackPlugin[] = [], ) { const contentDir = path.join(base, 'content') const outDir = path.join(base, 'out') @@ -451,7 +453,7 @@ describe('incremental builder', () => { outDir, navigation: { mode }, }, - options: { writeErrorPages: true, hooks }, + options: { writeErrorPages: true, plugins }, }) await builder.buildAll('initial') const outPath = (urlPath: string) => @@ -776,6 +778,8 @@ describe('incremental builder', () => { } const hooks: BuildHooks = { onPageStart: (_context, file) => record('start', file.relPath), + onPageDocument: (_context, page) => + record('document', page.file.relPath), onPageRendered: (_context, page) => { record('rendered', page.file.relPath) page.html = page.html.replace('', '') @@ -796,14 +800,16 @@ describe('incremental builder', () => { 'index.mdx': '# Home', 'guides/a.mdx': '# A', }, - hooks, + [{ name: 'test', hooks }], ) expect(calls).toEqual( expect.arrayContaining([ 'start:index.mdx', + 'document:index.mdx', 'rendered:index.mdx', 'written:index.mdx', 'start:guides/a.mdx', + 'document:guides/a.mdx', 'rendered:guides/a.mdx', 'written:guides/a.mdx', ]), @@ -813,6 +819,7 @@ describe('incremental builder', () => { await site.change('guides/a.mdx', '# A, edited') expect(calls).toEqual([ 'start:guides/a.mdx', + 'document:guides/a.mdx', 'rendered:guides/a.mdx', 'written:guides/a.mdx', ]) @@ -824,6 +831,7 @@ describe('incremental builder', () => { expect(await site.renderIfDirty('/')).toBe(true) expect(calls).toEqual([ 'start:index.mdx', + 'document:index.mdx', 'rendered:index.mdx', 'written:index.mdx', ]) @@ -833,24 +841,290 @@ describe('incremental builder', () => { }) }) - it('writes an error page when a page hook fails during a re-render', async () => { + it('lets onPageDocument await and change the document, resolving the links it adds', async () => { await withTempDir(async (base) => { - let failing = false const site = await createSite( base, 'none', - { 'index.mdx': '# Home' }, - { - onPageRendered: () => { - if (failing) throw new Error('Hook failed on purpose.') + { 'index.mdx': '# Home', 'guides/a.mdx': '# A' }, + [ + { + name: 'test', + hooks: { + async onPageDocument( + _context, + { document, file, frontmatter }, + ) { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (file.relPath !== 'index.mdx') return + const link = document.createElement('a') + link.setAttribute('href', './guides/a') + link.textContent = `After ${frontmatter.title}` + document.body.appendChild(link) + }, + }, }, + ], + ) + + expect(await site.read('/')).toContain( + 'After Home', + ) + }) + }) + + it('keeps each page on its own document while renders overlap', async () => { + await withTempDir(async (base) => { + const delays: Record = { '/': 30, '/guides/a/': 1 } + const site = await createSite( + base, + 'none', + { + 'header.mdx': '

Header v1

', + 'index.mdx': '# Home', + 'guides/a.mdx': '# A', }, + [ + { + name: 'test', + hooks: { + async onPageDocument(_context, { urlPath }) { + await new Promise((resolve) => + setTimeout(resolve, delays[urlPath]), + ) + // The global document, as code a plugin calls would use it. + const marker = globalThis.document.createElement('meta') + marker.setAttribute('name', `page:${urlPath}`) + globalThis.document.head.appendChild(marker) + }, + }, + }, + ], ) + await site.change('header.mdx', '

Header v2

') + await Promise.all([ + site.renderIfDirty('/'), + site.renderIfDirty('/guides/a/'), + ]) + + const home = await site.read('/') + const guide = await site.read('/guides/a/') + expect(home).toContain('Header v2') + expect(home).toContain('') + expect(home).not.toContain('page:/guides/a/') + expect(guide).toContain('') + expect(guide).not.toContain('') + }) + }) + + it('writes an error page when a page hook fails during a re-render', async () => { + await withTempDir(async (base) => { + let failing = false + const site = await createSite(base, 'none', { 'index.mdx': '# Home' }, [ + { + name: 'test', + hooks: { + onPageRendered: () => { + if (failing) throw new Error('Hook failed on purpose.') + }, + }, + }, + ]) + failing = true await site.change('index.mdx', '# Home, edited') - expect(await site.read('/')).toContain('Hook failed on purpose.') + expect(await site.read('/')).toContain( + 'Plugin "test" failed in onPageRendered: Hook failed on purpose.', + ) + }) + }) + }) + + describe('generated pages', () => { + /** One page per tag in the posts' frontmatter, listing their titles. */ + const tagPages: PureStackPlugin = { + name: 'tags', + async pages({ files }) { + const titlesByTag = new Map() + for (const file of files) { + const source = await fs.readFile(file.absPath, 'utf8') + const { frontmatter } = parseFrontmatterSource(source, file.relPath) + for (const tag of (frontmatter.tags as string[] | undefined) ?? []) { + const titles = titlesByTag.get(tag) ?? [] + titlesByTag.set(tag, [...titles, String(frontmatter.title)]) + } + } + return [...titlesByTag].map(([tag, titles]) => ({ + path: `tags/${tag}.mdx`, + source: titles.map((title) => `- ${title}`).join('\n'), + })) + }, + } + const post = (title: string, tags: string[]) => + [ + '---', + `title: ${title}`, + `tags: [${tags.join(', ')}]`, + '---', + title, + ].join('\n') + + it.each(['auto', 'none'] as const)( + 'regenerates a page when the content it reads changes (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createSite( + base, + mode, + { + 'index.mdx': '# Home', + 'post.mdx': post('First post', ['regor']), + }, + [tagPages], + ) + expect(await site.read('/tags/regor/')).toContain('First post') + + const result = await site.change( + 'post.mdx', + post('Renamed post', ['regor']), + ) + + expect(result.markedPages).toBeGreaterThan(0) + expect(await site.renderIfDirty('/tags/regor/')).toBe(true) + expect(await site.read('/tags/regor/')).toContain('Renamed post') + }) + }, + ) + + it.each(['auto', 'none'] as const)( + 'reads a source function again for each render after a regeneration (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + let version = 'v1' + const site = await createSite(base, mode, { 'index.mdx': '# Home' }, [ + { + name: 'status', + pages: () => [ + { path: 'status.mdx', source: () => `# Status ${version}` }, + ], + }, + ]) + expect(await site.read('/status/')).toContain('Status v1') + + // The plugin's data changes; any content change regenerates pages. + version = 'v2' + await site.change('index.mdx', '# Home, edited') + + expect(await site.renderIfDirty('/status/')).toBe(true) + expect(await site.read('/status/')).toContain('Status v2') + }) + }, + ) + + it('prepares content once when a request arrives during a build', async () => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + await fs.mkdir(contentDir, { recursive: true }) + await fs.writeFile(path.join(contentDir, 'index.mdx'), '# Home') + let generations = 0 + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir: path.join(base, 'out'), + }, + options: { + plugins: [ + { + name: 'status', + pages: () => { + generations += 1 + return [{ path: 'status.mdx', source: '# Status' }] + }, + }, + ], + }, + }) + expect(generations).toBe(0) + + await Promise.all([ + builder.buildAll('initial'), + builder.renderByUrlPath('/status/'), + ]) + + expect(generations).toBe(1) + }) + }) + + it('removes the output of a page that is no longer generated', async () => { + await withTempDir(async (base) => { + const site = await createSite( + base, + 'none', + { 'index.mdx': '# Home', 'post.mdx': post('First post', ['regor']) }, + [tagPages], + ) + const tagOutPath = site.outPath('/tags/regor/') + expect(await fileExists(tagOutPath)).toBe(true) + + await site.change('post.mdx', post('First post', [])) + + expect(await fileExists(tagOutPath)).toBe(false) + const manifest = await readManifest(path.join(base, 'out')) + expect( + manifest?.content[path.join('tags', 'regor.mdx')], + ).toBeUndefined() + }) + }) + + it('renders a newly generated page on its first request', async () => { + await withTempDir(async (base) => { + const site = await createSite( + base, + 'none', + { 'index.mdx': '# Home', 'post.mdx': post('First post', ['regor']) }, + [tagPages], + ) + + await site.change('post.mdx', post('First post', ['regor', 'css'])) + + expect(await site.builder.renderByUrlPath('/tags/css/')).toBe(true) + expect(await site.read('/tags/css/')).toContain('First post') + }) + }) + + it('regenerates pages when a data file they read changes', async () => { + await withTempDir(async (base) => { + const menu: PureStackPlugin = { + name: 'menu', + async pages({ config }) { + const data = await fs.readFile( + path.join(config.contentDir, 'data', 'menu.json'), + 'utf8', + ) + const items = JSON.parse(data) as string[] + return [ + { + path: 'menu.mdx', + source: items.map((i) => `- ${i}`).join('\n'), + }, + ] + }, + } + const site = await createSite( + base, + 'none', + { 'index.mdx': '# Home', 'data/menu.json': '["Soup"]' }, + [menu], + ) + expect(await site.read('/menu/')).toContain('Soup') + + await site.change('data/menu.json', '["Soup", "Salad"]') + + expect(await site.renderIfDirty('/menu/')).toBe(true) + expect(await site.read('/menu/')).toContain('Salad') }) }) }) diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index dd6e7c3f..46eb7d90 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -15,6 +15,13 @@ import { resolveContentFile, } from '../../i18n/content' import { buildNavigation } from '../../navigation/navigation' +import { + composePluginHooks, + type PureStackPlugin, + resolvePluginComponents, + resolvePluginContentProcessor, + resolvePluginTemplates, +} from '../../plugins/plugin' import { initBuiltinComponents } from '../../regor/initBuiltinComponents' import { resolveRouteInfo } from '../../routing/route' import { copyStaticAssets } from '../assets' @@ -60,6 +67,7 @@ export async function createIncrementalBuilder( interface IncrementalRuntimeOptions { config: SiteConfig hooks: BuildHooks + plugins: readonly PureStackPlugin[] cleanOutDir: boolean minifyScripts: boolean failOnAssetError: boolean @@ -74,22 +82,20 @@ async function createIncrementalRuntime( ): Promise { const buildOptions = input.options ?? {} const publishOptions = input.publish ?? {} + const plugins = buildOptions.plugins ?? [] const config = resolveBuildSiteConfig(input) - const hooks = buildOptions.hooks ?? {} + const hooks = composePluginHooks(plugins) const cleanOutDir = publishOptions.enabled === true || buildOptions.cleanOutDir === true const minifyScripts = publishOptions.enabled === true const failOnAssetError = publishOptions.enabled === true - const mdx = await resolveMdxBuildOptions(config.mdx) + const mdx = { + ...(await resolveMdxBuildOptions(config.mdx)), + contentProcessor: resolvePluginContentProcessor(plugins), + } themes.setOptions(config.style.theme) initBuiltinComponents({ includeShikiStyles: isShikiEnabled(config.mdx) }) const log = getLogger() - const discovered = await discoverSiteContent(config) - const navigation = await buildNavigation( - config.contentDir, - discovered, - config.navigation, - ) const existing = await readManifest(config.outDir) const manifest = existing && isCompatibleManifest(existing, config) @@ -98,15 +104,12 @@ async function createIncrementalRuntime( const scriptCacheKeys = new ScriptCacheKeyStore(manifest.assets) const context: BuildContext = { config, - contentRoutes: new ContentRouteIndex( - discovered, - Object.keys(manifest.assets), - ), + // The content is prepared once, by the first build or the first change or + // request; see IncrementalRuntime.ensureContentReady. + contentRoutes: new ContentRouteIndex([]), writeErrorPages: buildOptions.writeErrorPages === true, - components: buildOptions.components, - templates: buildOptions.templates, - navigation, - translationsByKey: buildTranslationsByKey(discovered), + components: resolvePluginComponents(plugins, config), + templates: resolvePluginTemplates(plugins), mdx, resolveScriptPublicPath: config.scripts.cacheBusting ? (sourceRelPath) => @@ -115,11 +118,11 @@ async function createIncrementalRuntime( })}` : undefined, } - await resolveHeaderFooterHtml(context) return new IncrementalRuntime({ config, hooks, + plugins, cleanOutDir, minifyScripts, failOnAssetError, @@ -159,6 +162,8 @@ class IncrementalRuntime { private readonly scriptEntrypoints: ScriptEntrypointManager private readonly changeApplier: IncrementalChangeApplier private readonly scriptCacheKeys: ScriptCacheKeyStore + /** Settles once the content is prepared; see ensureContentReady. */ + private contentReady: Promise | undefined constructor(private readonly options: IncrementalRuntimeOptions) { this.scriptCacheKeys = options.scriptCacheKeys @@ -178,9 +183,11 @@ class IncrementalRuntime { config: options.config, context: options.context, hooks: options.hooks, + plugins: options.plugins, log: options.log, onPageBuilt: (relPath, scriptEntrypoints) => this.scriptEntrypoints.setPageEntrypoints(relPath, scriptEntrypoints), + onPageRemoved: (relPath) => this.scriptEntrypoints.removePage(relPath), persistManifest: () => this.persistManifest(), getManifest: () => this.manifest, }) @@ -229,7 +236,9 @@ class IncrementalRuntime { this.scriptCacheKeys.clear() this.log.info('build started', { reason }) - const prepared = await this.prepareBuild(hooks) + const preparing = this.prepareBuild(hooks) + this.contentReady = this.settleContentReady(preparing) + const prepared = await preparing this.scriptEntrypoints.clearPageEntrypoints() const pages = await this.contentState.renderAllPages(prepared.contentFiles) const scriptAssetFiles = await this.scriptEntrypoints.syncState({ @@ -274,21 +283,69 @@ class IncrementalRuntime { this.scriptEntrypoints.rebuildDependencyIndex( copiedAssets.tsDependencyIndex, ) - const contentFiles = await discoverSiteContent(this.config) + const contentFiles = await this.prepareContent( + copiedAssets.files.map((file) => file.relPath), + hooks, + ) + return { contentFiles, assetFiles: copiedAssets.files } + } + + /** + * Discovers the pages, generated ones included, and prepares everything + * pages share: content routes, headers and footers, navigation, and + * translations. Runs once per full build. + */ + private async prepareContent(assetRelPaths: string[], hooks?: BuildHooks) { + const contentFiles = await discoverSiteContent( + this.config, + this.options.plugins, + ) + const generated = this.contentState.trackGeneratedPages(contentFiles) + for (const relPath of generated.removed) { + await this.contentState.handleMissingRelPathSource(relPath) + } + this.contentState.indexContentFiles(contentFiles) this.context.contentRoutes = new ContentRouteIndex( contentFiles, - copiedAssets.files.map((file) => file.relPath), + assetRelPaths, ) await resolveHeaderFooterHtml(this.context) - await hooks.onContentDiscovered?.(this.context, contentFiles) + await hooks?.onContentDiscovered?.(this.context, contentFiles) this.context.navigation = await buildNavigation( this.config.contentDir, contentFiles, this.config.navigation, ) this.context.translationsByKey = buildTranslationsByKey(contentFiles) - await hooks.onNavigationBuilt?.(this.context, this.context.navigation) - return { contentFiles, assetFiles: copiedAssets.files } + await hooks?.onNavigationBuilt?.(this.context, this.context.navigation) + return contentFiles + } + + /** + * Waits until the content is prepared. A full build prepares it; a change + * or request that comes first, such as one resuming from an earlier + * build's manifest, prepares it from the manifest instead. Either way it is + * prepared once, and calls made meanwhile wait for it. + */ + private ensureContentReady(): Promise { + this.contentReady ??= this.settleContentReady( + this.prepareContent(Object.keys(this.manifest.assets)), + ) + return this.contentReady + } + + private settleContentReady(preparing: Promise): Promise { + const ready = preparing.then( + () => undefined, + (error: unknown) => { + // The next build, change, or request prepares the content again. + if (this.contentReady === ready) this.contentReady = undefined + throw error + }, + ) + // Whoever started the preparation handles its failure. + ready.catch(() => undefined) + return ready } private async writeStylesWithHooks(hooks: BuildHooks) { @@ -354,6 +411,7 @@ class IncrementalRuntime { return result } + await this.ensureContentReady() this.contentState.takeMarkedPageCount() if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { await this.contentState.refreshPartials() @@ -365,10 +423,12 @@ class IncrementalRuntime { } renderIfDirtyByOutPath = async (outPath: string): Promise => { + await this.ensureContentReady() return this.contentState.renderIfDirtyByOutPath(outPath) } renderByUrlPath = async (urlPath: string): Promise => { + await this.ensureContentReady() return this.contentState.renderByUrlPath(urlPath) } diff --git a/packages/ts-ssg/src/build/incremental/support.ts b/packages/ts-ssg/src/build/incremental/support.ts index 46efb882..58bdf5e9 100644 --- a/packages/ts-ssg/src/build/incremental/support.ts +++ b/packages/ts-ssg/src/build/incremental/support.ts @@ -21,6 +21,8 @@ import { type MdxCodeHighlighter, } from '../../mdx/highlight' import { createHljsHighlighter } from '../../mdx/highlightjs' +import { generatePluginPages } from '../../plugins/generated-pages' +import type { PureStackPlugin } from '../../plugins/plugin' import { assertUniqueContentRoutes, resolveRouteInfo, @@ -31,6 +33,7 @@ import { type BuildManifest, type ContentManifestEntry, manifestConfigFromSiteConfig, + readContentSignature, readSignature, type StylesManifestEntry, } from '../manifest' @@ -138,12 +141,21 @@ async function resolveHighlighter( ) } -/** Discovers the site's pages and rejects two pages sharing one URL. */ -export async function discoverSiteContent(config: SiteConfig) { - const contentFiles = resolveContentFiles( - config, - await discoverContent(config.contentDir), - ) +/** + * Discovers the site's pages, files and plugin-generated ones alike, and + * rejects two pages sharing one URL. + */ +export async function discoverSiteContent( + config: SiteConfig, + plugins: readonly PureStackPlugin[], +) { + const discovered = await discoverContent(config.contentDir) + const files = resolveContentFiles(config, discovered) + const generated = await generatePluginPages(plugins, config, files) + const contentFiles = + generated.length > 0 + ? resolveContentFiles(config, [...discovered, ...generated]) + : files assertUniqueContentRoutes(contentFiles) return contentFiles } @@ -195,7 +207,7 @@ export async function buildManifest( ): Promise { const content: Record = {} for (const file of contentFiles) { - const signature = await readSignature(file.absPath) + const signature = await readContentSignature(file) if (!signature) continue const outPath = resolveOutPath(config.outDir, file) content[file.relPath] = { diff --git a/packages/ts-ssg/src/build/logger.test.ts b/packages/ts-ssg/src/build/logger.test.ts new file mode 100644 index 00000000..8330ccf5 --- /dev/null +++ b/packages/ts-ssg/src/build/logger.test.ts @@ -0,0 +1,19 @@ +import { getLogger, hasLogger } from 'logpot' +import { afterAll, describe, expect, it } from 'vitest' +import { ensureLogger } from './logger' + +describe('ensureLogger', () => { + afterAll(async () => { + await getLogger().close() + }) + + it('creates the default logger once and keeps a logger that exists', async () => { + expect(hasLogger()).toBe(false) + + await ensureLogger() + const logger = getLogger() + await ensureLogger() + + expect(getLogger()).toBe(logger) + }) +}) diff --git a/packages/ts-ssg/src/build/logger.ts b/packages/ts-ssg/src/build/logger.ts new file mode 100644 index 00000000..ea5ed2a8 --- /dev/null +++ b/packages/ts-ssg/src/build/logger.ts @@ -0,0 +1,10 @@ +import { createLogger, hasLogger } from 'logpot' + +/** + * Builds log through logpot. A caller can set up its own logger first; + * otherwise the default console logger is created once and kept, since it + * holds no timers that would keep the process alive. + */ +export async function ensureLogger() { + if (!hasLogger()) await createLogger() +} diff --git a/packages/ts-ssg/src/build/manifest.ts b/packages/ts-ssg/src/build/manifest.ts index 1412cfed..1d5ad7d3 100644 --- a/packages/ts-ssg/src/build/manifest.ts +++ b/packages/ts-ssg/src/build/manifest.ts @@ -5,6 +5,7 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' import { ensureDir } from '@purestack/ts-util-node' import { getLogger } from 'logpot' +import type { ContentFile } from '../discover/content' export const MANIFEST_VERSION = 1 export const MANIFEST_DIRNAME = '.ts-ssg' @@ -133,6 +134,20 @@ export async function readSignature( } } +/** + * A generated page has no file, so its signature comes from its source: its + * size, or 0 for a source function, and the time it was generated. No file + * event ever compares it. + */ +export async function readContentSignature( + file: ContentFile, +): Promise { + if (file.source === undefined) return readSignature(file.absPath) + const size = + typeof file.source === 'string' ? Buffer.byteLength(file.source) : 0 + return { mtimeMs: Date.now(), size } +} + export function signatureEqual( left: FileSignature | undefined, right: FileSignature | null, diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index 9bd84ec0..b171d124 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -18,6 +18,7 @@ import { discoverDefaultFooters, discoverDefaultHeaders, } from '../discover/content' +import { readContentSource } from '../discover/content-source' import { isRegorMdxContentExt } from '../discover/contentExtensions' import { normalizeFrontmatter, @@ -42,6 +43,7 @@ import { readSource, writeHtml } from './io' import { resolveOutPath } from './out-path' import { markContentSource, resolvePageUrls } from './page-urls' import { renderPage } from './renderer' +import type { BuildHooks } from './site' export interface BuildContext { config: SiteConfig @@ -57,6 +59,14 @@ export interface BuildContext { resolveScriptPublicPath?: (sourceRelPath: string) => string } +/** A page's rendered document, before it becomes HTML. */ +export interface PageDocument { + document: Document + file: ResolvedContentFile + frontmatter: PageFrontmatter + urlPath: string +} + export interface PageRenderResult { file: ResolvedContentFile frontmatter: PageFrontmatter @@ -103,16 +113,17 @@ export async function writePage( export async function renderPageFromFile( context: BuildContext, file: ResolvedContentFile, + hooks: Pick = {}, ): Promise { const renderStart = process.hrtime.bigint() const { urlPath } = resolveRouteInfo(file) const outPath = resolveOutPath(context.config.outDir, file) try { - const source = await readSource(file.absPath) + const source = await readContentSource(file) const parsedContent = parseFrontmatterSource(source, file.relPath, { defaultShowToc: context.config.pageToc.enabled, }) - const compiled = compilePageContent(context, file, parsedContent.body) + const compiled = await compilePageContent(context, file, parsedContent.body) const frontmatter = resolvePageFrontmatterTitle( parsedContent.frontmatter, compiled.outline, @@ -147,12 +158,19 @@ export async function renderPageFromFile( outline: compiled.outline, pageInfo, }) - const html = renderPageApp(context, file, htmlShell, { - pageInfo, - navigation, - outline: compiled.outline, - scriptEntrypoints, - }) + const html = await renderPageApp( + context, + file, + htmlShell, + { pageInfo, navigation, outline: compiled.outline, scriptEntrypoints }, + (document) => + hooks.onPageDocument?.(context, { + document, + file, + frontmatter, + urlPath, + }), + ) const renderTimeMs = Number(process.hrtime.bigint() - renderStart) / 1_000_000 return { @@ -220,7 +238,7 @@ async function resolveSpecialHtmlByDirectory( const localizedFile = resolveSpecialContentFile(context.config, file) const source = await readSource(localizedFile.absPath) const parsedContent = parseFrontmatterSource(source, localizedFile.relPath) - const compiled = compilePageContent( + const compiled = await compilePageContent( context, localizedFile, parsedContent.body, @@ -413,21 +431,28 @@ type RenderAppContextInput = { scriptEntrypoints: Set } +/** + * Renders the page's components into its shell. `onDocument` runs on the + * rendered document before its links resolve, so links it adds resolve too. + */ function renderPageApp( context: BuildContext, file: ResolvedContentFile, htmlShell: string, appContext: RenderAppContextInput, + onDocument: (document: Document) => void | Promise, ) { const { scriptEntrypoints, ...baseContext } = appContext return renderApp(htmlShell, { components: context.components, - onRendered: (document) => + onRendered: async (document) => { + await onDocument(document) resolvePageUrls(document, { sourceRelPath: file.relPath, contentRoutes: context.contentRoutes, config: context.config, - }), + }) + }, context: { site: context.config, ...baseContext, diff --git a/packages/ts-ssg/src/build/site.ts b/packages/ts-ssg/src/build/site.ts index 96933c30..f3604a37 100644 --- a/packages/ts-ssg/src/build/site.ts +++ b/packages/ts-ssg/src/build/site.ts @@ -1,9 +1,11 @@ -import type { PageTemplateMap, SiteConfigInput } from '@purestack/ts-common' +import type { SiteConfigInput } from '@purestack/ts-common' import type { ResolvedContentFile } from '../i18n/content' import type { NavigationTree } from '../navigation/navigation' +import type { PureStackPlugin } from '../plugins/plugin' import type { PublishOptions } from './build-config' import { createIncrementalBuilder } from './incremental' -import type { BuildContext, PageRenderResult } from './page' +import { ensureLogger } from './logger' +import type { BuildContext, PageDocument, PageRenderResult } from './page' import type { WriteStylesResult } from './styles' export type { PublishOptions } from './build-config' @@ -34,6 +36,14 @@ export interface BuildHooks { context: BuildContext, file: ResolvedContentFile, ) => void | Promise + /** + * Runs on a page's rendered document, before it becomes HTML and before its + * links resolve, so links it adds resolve like written ones. + */ + onPageDocument?: ( + context: BuildContext, + page: PageDocument, + ) => void | Promise onPageRendered?: ( context: BuildContext, page: PageRenderResult, @@ -55,9 +65,8 @@ export interface BuildHooks { export interface BuildOptions { cleanOutDir?: boolean writeErrorPages?: boolean - hooks?: BuildHooks - components?: Record - templates?: PageTemplateMap + /** Extensions to apply, in order. See {@link PureStackPlugin}. */ + plugins?: PureStackPlugin[] } export interface BuildInput { @@ -67,6 +76,7 @@ export interface BuildInput { } export async function buildSite(input: BuildInput = {}): Promise { + await ensureLogger() const builder = await createIncrementalBuilder(input) return builder.buildAll('full build') } diff --git a/packages/ts-ssg/src/cli-runner.ts b/packages/ts-ssg/src/cli-runner.ts index 607f6017..30823bde 100644 --- a/packages/ts-ssg/src/cli-runner.ts +++ b/packages/ts-ssg/src/cli-runner.ts @@ -4,6 +4,11 @@ import { logError } from '@purestack/ts-util' import { createLogger, getLogger } from 'logpot' import { buildSite } from './build/site' import { SITE_CONFIG_FILENAME } from './config/config' +import { + findProjectConfig, + loadProjectConfig, + withProjectConfig, +} from './config/project-config' import { type DevServerInput, startDevServer } from './dev/server' export async function runCli(args: string[]) { @@ -20,10 +25,13 @@ export async function runCli(args: string[]) { loggerCreated = true if (cli.command === 'serve') { keepLoggerOpen = true - await startDevServer(cli.input) + await startDevServer({ ...cli.input, configFile: cli.configFile }) return } - await buildSite(cli.input.build) + const loaded = cli.configFile + ? await loadProjectConfig(cli.configFile) + : undefined + await buildSite(withProjectConfig(cli.input.build ?? {}, loaded)) } catch (error) { if (error instanceof CliUsageError) { console.error(error.message) @@ -48,6 +56,7 @@ interface CliState { command: CliCommand input: DevServerInput contentDir?: string + configFile?: string } function parseCliArgs(args: string[]): CliState { @@ -120,6 +129,7 @@ function parseCliArgs(args: string[]): CliState { } assertContentConfig(state.contentDir) + state.configFile = findProjectConfig(state.contentDir) return state } @@ -166,7 +176,9 @@ function resolveValueOptions(command: Exclude) { return ['--content'] } -function assertContentConfig(contentDir: string | undefined) { +function assertContentConfig( + contentDir: string | undefined, +): asserts contentDir is string { if (!contentDir) { throw new CliUsageError( `Missing required --content option.\n\n${USAGE}`, diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 45435a2c..1f983df0 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -25,7 +25,7 @@ export function getHead(config?: BasicHeadConfig) { const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.2', + content: 'PureStack v1.1.3', }), h('meta').attr({ name: 'color-scheme', diff --git a/packages/ts-ssg/src/config/project-config.test.ts b/packages/ts-ssg/src/config/project-config.test.ts new file mode 100644 index 00000000..ae261dde --- /dev/null +++ b/packages/ts-ssg/src/config/project-config.test.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { disableLogger, getLogger, type Logger } from 'logpot' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { runCli } from '../cli-runner' +import { makeRepoTempDir } from '../test/repoTempDir' +import { + defineConfig, + findProjectConfig, + loadProjectConfig, + PROJECT_CONFIG_FILENAME, + withProjectConfig, +} from './project-config' + +describe('purestack.config.ts', () => { + let logger: Logger | undefined + let root: string | undefined + + beforeAll(() => { + disableLogger() + logger = getLogger() + }) + + afterAll(async () => { + await logger?.close() + }) + + afterEach(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }) + root = undefined + }) + + async function createProject(files: Record) { + root = await makeRepoTempDir('.tmp-ts-ssg-config-') + for (const [relPath, contents] of Object.entries(files)) { + const filePath = path.join(root, relPath) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, contents, 'utf8') + } + return root + } + + it('returns the config it defines', () => { + const config = { plugins: [] } + + expect(defineConfig(config)).toBe(config) + }) + + it('finds the config next to siteConfig.json, if there is one', async () => { + const projectRoot = await createProject({ + 'with/purestack.config.ts': 'export default {}', + 'without/index.mdx': '# Home', + }) + + expect(findProjectConfig(path.join(projectRoot, 'with'))).toBe( + path.join(projectRoot, 'with', PROJECT_CONFIG_FILENAME), + ) + expect(findProjectConfig(path.join(projectRoot, 'without'))).toBeUndefined() + }) + + it('loads plugins from local TypeScript files and lists them as dependencies', async () => { + const projectRoot = await createProject({ + 'plugins/marker.ts': [ + "const label: string = 'from a local file'", + "export const markerPlugin = { name: 'marker', hooks: { onBuildComplete() {} } }", + 'export { label }', + ].join('\n'), + 'content/purestack.config.ts': [ + "import { label, markerPlugin } from '../plugins/marker'", + 'export default { plugins: [{ ...markerPlugin, name: label }] }', + ].join('\n'), + }) + const configFile = path.join( + projectRoot, + 'content', + PROJECT_CONFIG_FILENAME, + ) + + const loaded = await loadProjectConfig(configFile) + + expect(loaded.config.plugins?.map((plugin) => plugin.name)).toEqual([ + 'from a local file', + ]) + expect(loaded.dependencies.sort()).toEqual( + [configFile, path.join(projectRoot, 'plugins', 'marker.ts')].sort(), + ) + }) + + it.each([ + [ + 'export const plugins = []', + 'must export its config as the default export: export default defineConfig({ plugins: [...] })', + ], + [ + 'export default { plugin: [] }', + 'has an unknown field "plugin". Config fields: plugins.', + ], + ['export default {', 'Could not bundle'], + ["throw new Error('Config failed on purpose.')", 'Could not run'], + ])('rejects %j', async (source, message) => { + const projectRoot = await createProject({ + [PROJECT_CONFIG_FILENAME]: source, + }) + + await expect( + loadProjectConfig(path.join(projectRoot, PROJECT_CONFIG_FILENAME)), + ).rejects.toThrow(message) + }) + + it('adds config plugins after the plugins a build already lists', () => { + const first = { name: 'first' } + const fromConfig = { name: 'from-config' } + + const input = withProjectConfig( + { options: { cleanOutDir: true, plugins: [first] } }, + { filePath: '', dependencies: [], config: { plugins: [fromConfig] } }, + ) + + expect(input.options).toEqual({ + cleanOutDir: true, + plugins: [first, fromConfig], + }) + }) + + it('builds with the config when the CLI finds one', async () => { + const projectRoot = await createProject({ + 'content/siteConfig.json': JSON.stringify({ outDir: '../out' }), + 'content/index.mdx': '# Home', + 'content/purestack.config.ts': [ + 'export default {', + ' plugins: [{', + " name: 'marker',", + ' hooks: {', + ' onPageRendered(_context: unknown, page: { html: string }) {', + " page.html = page.html.replace('', '')", + ' },', + ' },', + ' }],', + '}', + ].join('\n'), + }) + + await runCli(['build', '--content', path.join(projectRoot, 'content')]) + + const html = await fs.readFile( + path.join(projectRoot, 'out', 'index.html'), + 'utf8', + ) + expect(html).toContain('') + }) +}) diff --git a/packages/ts-ssg/src/config/project-config.ts b/packages/ts-ssg/src/config/project-config.ts new file mode 100644 index 00000000..62ce13fd --- /dev/null +++ b/packages/ts-ssg/src/config/project-config.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs' +import { isBuiltin } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { isPlainObject } from '@purestack/ts-util' +import { build, type Plugin } from 'esbuild' +import type { BuildInput } from '../build/site' +import type { PureStackPlugin } from '../plugins/plugin' + +export const PROJECT_CONFIG_FILENAME = 'purestack.config.ts' + +const CONFIG_FIELDS = ['plugins'] +const SKIP_PACKAGE_RESOLUTION = Symbol('skip package resolution') + +/** The code a site adds to PureStack, loaded from `purestack.config.ts`. */ +export interface PureStackConfig { + plugins?: PureStackPlugin[] +} + +export function defineConfig(config: PureStackConfig): PureStackConfig { + return config +} + +export interface LoadedProjectConfig { + filePath: string + config: PureStackConfig + /** Local files the config imports, itself included; edits reload it. */ + dependencies: string[] +} + +export function findProjectConfig(contentDir: string) { + const filePath = path.join(contentDir, PROJECT_CONFIG_FILENAME) + return fs.existsSync(filePath) ? filePath : undefined +} + +/** Adds the config's plugins after any the build input already lists. */ +export function withProjectConfig( + input: BuildInput, + loaded: LoadedProjectConfig | undefined, +): BuildInput { + if (!loaded?.config.plugins) return input + return { + ...input, + options: { + ...input.options, + plugins: [...(input.options?.plugins ?? []), ...loaded.config.plugins], + }, + } +} + +/** + * Bundles the config with the local files it imports and runs it. Packages + * stay external: one PureStack can resolve uses PureStack's own copy, so + * plugins share its registries, and any other resolves from the config. + */ +export async function loadProjectConfig( + filePath: string, +): Promise { + const configDir = path.dirname(filePath) + let bundled: Awaited> + try { + bundled = await bundleConfig(filePath, configDir) + } catch (error) { + throw new Error(`Could not bundle ${filePath}: ${toMessage(error)}`, { + cause: error, + }) + } + let exported: unknown + try { + const code = bundled.outputFiles[0]?.text ?? '' + const module = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ) + exported = module.default + } catch (error) { + throw new Error(`Could not run ${filePath}: ${toMessage(error)}`, { + cause: error, + }) + } + assertProjectConfig(exported, filePath) + return { + filePath, + config: exported, + dependencies: Object.keys(bundled.metafile.inputs).map((input) => + path.resolve(configDir, input), + ), + } +} + +function bundleConfig(filePath: string, configDir: string) { + return build({ + entryPoints: [filePath], + absWorkingDir: configDir, + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node22', + // Resolve the way Node's own `import` does. + conditions: ['node'], + mainFields: ['main'], + sourcemap: 'inline', + metafile: true, + logLevel: 'silent', + plugins: [resolvePackagesToFiles()], + }) +} + +function resolvePackagesToFiles(): Plugin { + return { + name: 'purestack-config-packages', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /.*/ }, async (args) => { + if (args.kind === 'entry-point') return undefined + if (args.pluginData === SKIP_PACKAGE_RESOLUTION) return undefined + if (args.path.startsWith('.') || path.isAbsolute(args.path)) { + return undefined + } + if (isBuiltin(args.path)) return { path: args.path, external: true } + const shared = resolveFromPureStack(args.path) + if (shared) return { path: shared, external: true } + const local = await pluginBuild.resolve(args.path, { + kind: args.kind, + resolveDir: args.resolveDir, + pluginData: SKIP_PACKAGE_RESOLUTION, + }) + if (local.errors.length > 0) return { errors: local.errors } + return { path: pathToFileURL(local.path).href, external: true } + }) + }, + } +} + +function resolveFromPureStack(specifier: string) { + try { + return import.meta.resolve(specifier) + } catch { + return undefined + } +} + +function assertProjectConfig( + value: unknown, + filePath: string, +): asserts value is PureStackConfig { + if (!isPlainObject(value)) { + throw new Error( + `${filePath} must export its config as the default export: export default defineConfig({ plugins: [...] })`, + ) + } + for (const field of Object.keys(value)) { + if (!CONFIG_FIELDS.includes(field)) { + throw new Error( + `${filePath} has an unknown field "${field}". Config fields: ${CONFIG_FIELDS.join(', ')}.`, + ) + } + } +} + +function toMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/ts-ssg/src/dev/server.test.ts b/packages/ts-ssg/src/dev/server.test.ts index 8a125349..ccd76920 100644 --- a/packages/ts-ssg/src/dev/server.test.ts +++ b/packages/ts-ssg/src/dev/server.test.ts @@ -1,10 +1,12 @@ import fs from 'node:fs/promises' import http from 'node:http' import net from 'node:net' -import os from 'node:os' import path from 'node:path' +import { themeSkins } from '@purestack/ts-style' import { disableLogger } from 'logpot' import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { definePlugin } from '../plugins/plugin' +import { makeRepoTempDir } from '../test/repoTempDir' import { type DevServerHandle, startDevServer } from './server' const HOST = '127.0.0.1' @@ -23,10 +25,189 @@ describe('dev server', () => { liveReload?.close() await server?.close() if (root) await fs.rm(root, { recursive: true, force: true }) + liveReload = undefined + server = undefined + root = undefined }) + it('reloads purestack.config.ts when a file it imports changes', async () => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-config-') + const contentDir = path.join(root, 'content') + const markerPath = path.join(root, 'plugins', 'marker.ts') + const configFile = path.join(contentDir, 'purestack.config.ts') + await writeFile(path.join(contentDir, 'index.mdx'), '# Home') + await writeFile(markerPath, "export const marker = 'marker v1'") + await writeFile( + configFile, + [ + "import { marker } from '../plugins/marker'", + 'export default {', + ' plugins: [{', + " name: 'marker',", + ' hooks: {', + ' onPageRendered(_context: unknown, page: { html: string }) {', + // biome-ignore lint/suspicious/noTemplateCurlyInString: Literal TypeScript source for the config file. + " page.html = page.html.replace('', ``)", + ' },', + ' },', + ' }],', + '}', + ].join('\n'), + ) + + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + configFile, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + }, + }, + }) + liveReload = listenForLiveReload(port) + await liveReload.reachVersion(1) + expect(await get(port, '/')).toContain('') + + // The plugin file sits outside the content folder. + await writeFile(markerPath, "export const marker = 'marker v2'") + await liveReload.reachVersion(2) + + expect(await get(port, '/')).toContain('') + }, 20_000) + + it('starts with a plugin skin selected in the site config', async () => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-') + const contentDir = path.join(root, 'content') + await writeFile(path.join(contentDir, 'index.mdx'), '# Home') + + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + style: { theme: { skin: 'dev-plugin-skin' } }, + }, + options: { + plugins: [ + definePlugin({ + name: 'skin', + skins: { + 'dev-plugin-skin': { + create: () => themeSkins.standard.create(), + }, + }, + }), + ], + }, + }, + }) + liveReload = listenForLiveReload(port) + await liveReload.reachVersion(1) + + expect(await get(port, '/')).toContain('Home') + }, 20_000) + + it('lets plugin dev middleware answer requests before the site', async () => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-middleware-') + const contentDir = path.join(root, 'content') + await writeFile(path.join(contentDir, 'index.mdx'), '# Home') + + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + }, + options: { + plugins: [ + definePlugin({ + name: 'headers', + devMiddleware: (_request, response) => { + response.setHeader('x-dev', 'on') + }, + }), + definePlugin({ + name: 'api', + devMiddleware: async (request, response) => { + if (request.url === '/api/broken') throw new Error('No time.') + if (request.url !== '/api/time') return + await new Promise((resolve) => setTimeout(resolve, 10)) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end('{"time":1}') + }, + }), + definePlugin({ + name: 'late', + devMiddleware: (_request, response) => { + response.end('late') + }, + }), + ], + }, + }, + }) + liveReload = listenForLiveReload(port) + await liveReload.reachVersion(1) + const origin = `http://${HOST}:${port}` + + const api = await fetch(`${origin}/api/time`) + expect(await api.json()).toEqual({ time: 1 }) + expect(api.headers.get('x-dev')).toBe('on') + // The first plugin to respond ends the chain, so "late" answers the rest. + expect(await (await fetch(`${origin}/`)).text()).toBe('late') + const broken = await fetch(`${origin}/api/broken`) + expect(broken.status).toBe(500) + }, 20_000) + + it('serves the site when no dev middleware responds', async () => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-middleware-') + const contentDir = path.join(root, 'content') + await writeFile(path.join(contentDir, 'index.mdx'), '# Home') + + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + }, + options: { + plugins: [ + definePlugin({ + name: 'headers', + devMiddleware: (_request, response) => { + response.setHeader('x-dev', 'on') + }, + }), + ], + }, + }, + }) + liveReload = listenForLiveReload(port) + await liveReload.reachVersion(1) + + const page = await fetch(`http://${HOST}:${port}/`) + expect(await page.text()).toContain('Home') + expect(page.headers.get('x-dev')).toBe('on') + }, 20_000) + it('serves the new header on the reload a header edit triggers', async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-ssg-dev-')) + root = await makeRepoTempDir('.tmp-ts-ssg-dev-') const contentDir = path.join(root, 'content') const headerPath = path.join(contentDir, 'guides', 'header.mdx') await writeFile(headerPath, '

Header v1

') diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index 472d7c9f..57dcbbe4 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -1,5 +1,6 @@ import fsPromises from 'node:fs/promises' import http from 'node:http' +import path from 'node:path' import type { I18nConfig } from '@purestack/ts-common' import { logError, stripBasePath, withBasePath } from '@purestack/ts-util' import { getLogger, type Logger } from 'logpot' @@ -8,7 +9,10 @@ import { createIncrementalBuilder, type IncrementalBuilder, } from '../build/incremental' +import { ensureLogger } from '../build/logger' import type { BuildInput } from '../build/site' +import { loadProjectConfig, withProjectConfig } from '../config/project-config' +import { composeDevMiddleware } from '../plugins/plugin' import { broadcastJson, injectLiveReload, @@ -26,7 +30,7 @@ import { serveStaticStream, writeHtmlResponse, } from './static-files' -import { watchTree } from './watch-tree' +import { toPathKey, watchFiles, watchTree } from './watch-tree' export interface DevServerOptions { host?: string @@ -37,6 +41,11 @@ export interface DevServerOptions { export interface DevServerInput extends DevServerOptions { build?: BuildInput + /** + * A `purestack.config.ts` whose plugins the server adds to the build. It + * loads again when the config or a local file it imports changes. + */ + configFile?: string } export interface DevServerHandle { @@ -67,16 +76,19 @@ type RebuildRequestState = { export async function startDevServer( input: DevServerInput = {}, ): Promise { + await ensureLogger() const logger = getLogger() - const baseBuildInput = input.build ?? {} - const buildInput: BuildInput = { - ...baseBuildInput, + const baseBuildInput: BuildInput = { + ...input.build, options: { writeErrorPages: true, - ...(baseBuildInput.options ?? {}), + ...input.build?.options, }, } + const projectConfig = trackProjectConfig(input.configFile) + let buildInput = await projectConfig.load(baseBuildInput) const config = resolveBuildSiteConfig(buildInput) + let devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? []) const log = getLogger() const { host, port, watch, liveReload } = resolveDevServerOptions(input) @@ -134,17 +146,35 @@ export async function startDevServer( } } + // The content watcher already sees config files inside the content folder. + let dependencyWatcher: { close: () => void } | undefined + const watchConfigDependencies = () => { + dependencyWatcher?.close() + dependencyWatcher = watch + ? watchFiles(projectConfig.outside(config.contentDir), (filePath) => { + scheduleRebuild(`config change: ${filePath}`, filePath) + }) + : undefined + } + const displayHost = host === '0.0.0.0' ? LOOPBACK_HOST : host let incremental = await createIncrementalBuilder(buildInput) const rebuild = async ( reason: string, - options?: { recreateBuilder?: boolean }, + options?: { recreateBuilder?: boolean; reloadConfig?: boolean }, ) => { try { - if (options?.recreateBuilder) { + if (options?.reloadConfig) { + buildInput = await projectConfig.load(baseBuildInput) + watchConfigDependencies() + } + if (options?.recreateBuilder || options?.reloadConfig) { incremental = await createIncrementalBuilder(buildInput) } + if (options?.reloadConfig) { + devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? []) + } await incremental.buildAll(reason) if (!initialBuildDone) { log.info('serving at', { @@ -167,6 +197,10 @@ export async function startDevServer( await rebuild(requestState.reason) return } + if (paths.some((filePath) => projectConfig.isDependency(filePath))) { + await rebuild(requestState.reason, { reloadConfig: true }) + return + } let requiresFull = false let touched = false for (const filePath of paths) { @@ -209,6 +243,7 @@ export async function startDevServer( liveReload, // A full rebuild replaces the builder, so requests ask for the current one. getIncremental: () => incremental, + handlePluginRequest: (req, res) => devMiddleware(req, res), clients, getLiveReloadVersion: () => liveReloadVersion, log, @@ -238,6 +273,7 @@ export async function startDevServer( }) log.info('watching content', { contentDir: config.contentDir }) } + watchConfigDependencies() void requestRebuild() @@ -251,6 +287,7 @@ export async function startDevServer( process.off('SIGINT', handleSignal) process.off('SIGTERM', handleSignal) watcher?.close() + dependencyWatcher?.close() await new Promise((resolve) => server.close(() => resolve())) await logger.close() } @@ -268,6 +305,36 @@ export async function startDevServer( return { close: shutdown } } +/** Loads the project config and tells which changed files belong to it. */ +function trackProjectConfig(configFile: string | undefined) { + let dependencies: string[] = [] + let keys = new Set() + return { + async load(input: BuildInput) { + if (!configFile) return input + const loaded = await loadProjectConfig(configFile) + dependencies = loaded.dependencies + keys = new Set(dependencies.map(toPathKey)) + return withProjectConfig(input, loaded) + }, + isDependency(filePath: string) { + return keys.has(toPathKey(filePath)) + }, + outside(dir: string) { + return dependencies.filter((filePath) => !isInsideDir(dir, filePath)) + }, + } +} + +function isInsideDir(dir: string, filePath: string) { + const relative = path.relative(dir, filePath) + return ( + relative.length > 0 && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ) +} + function resolveDevServerOptions( input: DevServerInput, ): ResolvedDevServerOptions { @@ -298,6 +365,11 @@ type DevServerRequestHandlerInput = { i18n: I18nConfig liveReload: boolean getIncremental: () => IncrementalBuilder + /** Resolves true when a plugin's dev middleware responded. */ + handlePluginRequest: ( + req: http.IncomingMessage, + res: http.ServerResponse, + ) => Promise clients: LiveReloadClients getLiveReloadVersion: () => number log: Logger @@ -312,6 +384,7 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { i18n, liveReload, getIncremental, + handlePluginRequest, clients, getLiveReloadVersion, log, @@ -333,6 +406,15 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { return } + try { + if (await handlePluginRequest(req, res)) return + } catch (error) { + logError(log, error, 'dev middleware failed') + if (!res.headersSent) res.writeHead(500) + res.end() + return + } + if (!isRequestUnderBasePath(basePath, pathname)) { res.writeHead(404) res.end('Not found') diff --git a/packages/ts-ssg/src/dev/watch-tree.ts b/packages/ts-ssg/src/dev/watch-tree.ts index 808dd014..3595f5ee 100644 --- a/packages/ts-ssg/src/dev/watch-tree.ts +++ b/packages/ts-ssg/src/dev/watch-tree.ts @@ -36,6 +36,41 @@ export async function watchTree( } } +/** + * Watches individual files through their folders, so an editor that saves by + * replacing the file is still noticed. + */ +export function watchFiles( + filePaths: readonly string[], + onChange: (filePath: string) => void, +) { + const filesByDir = new Map>() + for (const filePath of filePaths) { + const dir = path.dirname(filePath) + const files = filesByDir.get(dir) ?? new Set() + files.add(toPathKey(filePath)) + filesByDir.set(dir, files) + } + const watchers = [...filesByDir].map(([dir, files]) => + fs.watch(dir, (_event, filename) => { + if (!filename) return + const filePath = path.join(dir, filename.toString()) + if (files.has(toPathKey(filePath))) onChange(filePath) + }), + ) + return { + close() { + for (const watcher of watchers) watcher.close() + }, + } +} + +/** A path compared the way the file system does: case-blind on Windows. */ +export function toPathKey(filePath: string) { + const resolved = path.resolve(filePath) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + async function collectDirs(root: string) { const result = [root] const queue = [root] diff --git a/packages/ts-ssg/src/discover/content-source.ts b/packages/ts-ssg/src/discover/content-source.ts new file mode 100644 index 00000000..9237d375 --- /dev/null +++ b/packages/ts-ssg/src/discover/content-source.ts @@ -0,0 +1,8 @@ +import fs from 'node:fs/promises' +import type { ContentFile } from './content' + +/** A page's source: its generated source, or the text of its file. */ +export async function readContentSource(file: ContentFile): Promise { + if (typeof file.source === 'function') return file.source() + return file.source ?? fs.readFile(file.absPath, 'utf8') +} diff --git a/packages/ts-ssg/src/discover/content.ts b/packages/ts-ssg/src/discover/content.ts index 9deb98c4..67ed2ef1 100644 --- a/packages/ts-ssg/src/discover/content.ts +++ b/packages/ts-ssg/src/discover/content.ts @@ -20,6 +20,11 @@ export interface ContentFile { absPath: string relPath: string ext: string + /** + * The source of a page a plugin generates, or a function that returns it. + * Such a page has no file. + */ + source?: string | (() => string | Promise) } export interface StaticAssetFile { diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts index df3b0879..c01cf318 100644 --- a/packages/ts-ssg/src/index.ts +++ b/packages/ts-ssg/src/index.ts @@ -8,7 +8,11 @@ export type { SiteConfigInput, TsSsgContext, } from '@purestack/ts-common' -export type { BuildContext, PageRenderResult } from './build/page' +export type { + BuildContext, + PageDocument, + PageRenderResult, +} from './build/page' export { type BuildCountSummary, type BuildHooks, @@ -21,6 +25,7 @@ export { export type { WriteStylesResult } from './build/styles' export { runCli } from './cli-runner' export { resolveSiteConfig } from './config/config' +export { defineConfig, type PureStackConfig } from './config/project-config' export { type DevServerHandle, type DevServerInput, @@ -46,6 +51,13 @@ export { resolveNavigationConfig, resolvePageNavigation, } from './navigation/navigation' +export { + definePlugin, + type GeneratedPage, + type PageGenerationContext, + type PureStackMarkdown, + type PureStackPlugin, +} from './plugins/plugin' export { defaultTemplates, resolvePageTemplate, diff --git a/packages/ts-ssg/src/mdx/compile.test.ts b/packages/ts-ssg/src/mdx/compile.test.ts new file mode 100644 index 00000000..c0c08ee6 --- /dev/null +++ b/packages/ts-ssg/src/mdx/compile.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { createContentProcessor } from './compile' +import { compileMarkdown } from './md' +import { compileMdx } from './mdx' + +type TreeNode = { + type: string + value?: string + tagName?: string + properties?: Record + children?: TreeNode[] +} + +function walk(node: TreeNode, visit: (node: TreeNode) => void) { + visit(node) + for (const child of node.children ?? []) walk(child, visit) +} + +const shoutText = () => (tree: TreeNode) => { + walk(tree, (node) => { + if (node.type === 'text' && node.value) + node.value = node.value.toUpperCase() + }) +} + +const prefixHeadingIds = () => (tree: TreeNode) => { + walk(tree, (node) => { + if (node.tagName === 'h2') { + node.properties = { ...node.properties, id: 'custom-heading' } + } + }) +} + +describe('content processor', () => { + it('runs remark plugins on the Markdown tree', async () => { + const result = await compileMdx('Plain words.', { + contentProcessor: createContentProcessor([shoutText]), + }) + + expect(result.bodyHtml).toContain('

PLAIN WORDS.

') + }) + + it('runs rehype plugins before the outline is collected', async () => { + const result = await compileMdx('## Install\n\nText.', { + contentProcessor: createContentProcessor([], [prefixHeadingIds]), + }) + + expect(result.bodyHtml).toContain('

Install

') + expect(result.outline).toEqual([ + { id: 'custom-heading', title: 'Install', depth: 2 }, + ]) + }) + + it('awaits asynchronous plugins', async () => { + const delayedClass = () => async (tree: TreeNode) => { + await new Promise((resolve) => setTimeout(resolve, 5)) + walk(tree, (node) => { + if (node.tagName === 'p') node.properties = { className: ['lead'] } + }) + } + + const result = await compileMarkdown('Hello.', { + contentProcessor: createContentProcessor([], [delayedClass]), + }) + + expect(result.bodyHtml).toContain('

Hello.

') + }) + + it('shares one file between remark and rehype plugins', async () => { + const countWords = + () => (tree: TreeNode, file: { data: Record }) => { + let words = 0 + walk(tree, (node) => { + if (node.type === 'text') + words += node.value?.split(/\s+/).length ?? 0 + }) + file.data.words = words + } + const stampWords = + () => (tree: TreeNode, file: { data: Record }) => { + tree.children?.push({ + type: 'element', + tagName: 'footer', + properties: {}, + children: [{ type: 'text', value: `${file.data.words} words` }], + }) + } + + const result = await compileMdx('Three short words', { + contentProcessor: createContentProcessor([countWords], [stampWords]), + }) + + expect(result.bodyHtml).toContain('
3 words
') + }) + + it('passes the source path to plugins, with forward slashes', async () => { + let seenPath: unknown + const readPath = () => (_tree: TreeNode, file: { path?: string }) => { + seenPath = file.path + } + + await compileMdx('Text.', { + sourceRelPath: 'guides\\plugins.mdx', + contentProcessor: createContentProcessor([readPath]), + }) + + expect(seenPath).toBe('guides/plugins.mdx') + }) + + it('keeps Regor component markup intact through the plugins', async () => { + const result = await compileMdx( + 'New\n\nSome text.', + { + contentProcessor: createContentProcessor( + [shoutText], + [prefixHeadingIds], + ), + }, + ) + + expect(result.bodyHtml).toContain('New') + expect(result.bodyHtml).toContain('

SOME TEXT.

') + }) +}) diff --git a/packages/ts-ssg/src/mdx/compile.ts b/packages/ts-ssg/src/mdx/compile.ts index e03037cd..de2ea437 100644 --- a/packages/ts-ssg/src/mdx/compile.ts +++ b/packages/ts-ssg/src/mdx/compile.ts @@ -1,9 +1,10 @@ import type { PageOutlineItem } from '@purestack/ts-common' +import { toPosixPath } from '@purestack/ts-util' import type { Element, Root, Text } from 'hast' import type { Root as MdastRoot } from 'mdast' -import { toHast } from 'mdast-util-to-hast' import rehypeStringify from 'rehype-stringify' -import { unified } from 'unified' +import remarkRehype from 'remark-rehype' +import { type PluggableList, unified } from 'unified' import type { MdxCodeHighlighter } from './highlight' import { applyShikiHighlighting } from './shikiHighlighting' @@ -16,13 +17,39 @@ export interface MdxRenderOptions { highlighter?: MdxCodeHighlighter sourceRelPath?: string compileMdAsMdx?: boolean + /** Turns the Markdown tree into HTML, with any content plugins. */ + contentProcessor?: ContentProcessor } -export function compileAstToHtml( +export type ContentProcessor = ReturnType + +const DEFAULT_CONTENT_PROCESSOR = createContentProcessor() + +/** + * One processor per build: remark plugins on the Markdown tree, then the + * HTML tree, then rehype plugins. A single run shares one file, so remark + * plugins can pass data to rehype plugins through it. + */ +export function createContentProcessor( + remarkPlugins: PluggableList = [], + rehypePlugins: PluggableList = [], +) { + return unified() + .use(remarkPlugins) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypePlugins) + .freeze() +} + +export async function compileAstToHtml( file: MdastRoot, options: MdxRenderOptions, -): MdxCompileResult { - const tree = toHast(file, { allowDangerousHtml: true }) +): Promise { + const processor = options.contentProcessor ?? DEFAULT_CONTENT_PROCESSOR + // Plugins see the same content path on every platform. + const tree: unknown = await processor.run(file, { + path: options.sourceRelPath && toPosixPath(options.sourceRelPath), + }) if (!isHastRoot(tree)) { throw new Error('Content compilation did not produce a HAST root node.') } @@ -59,8 +86,12 @@ function keepCodeLiteral(root: Root) { visit(root) } -function isHastRoot(node: ReturnType): node is Root { - return Boolean(node && node.type === 'root') +function isHastRoot(node: unknown): node is Root { + return ( + typeof node === 'object' && + node !== null && + (node as { type?: unknown }).type === 'root' + ) } function wrapTablesInScrollContainers(root: Root) { diff --git a/packages/ts-ssg/src/mdx/md.test.ts b/packages/ts-ssg/src/mdx/md.test.ts index 6449d385..09c0db66 100644 --- a/packages/ts-ssg/src/mdx/md.test.ts +++ b/packages/ts-ssg/src/mdx/md.test.ts @@ -14,7 +14,7 @@ describe('compileMarkdown', () => { '| level | TRACE |', '| worker | false |', ].join('\n') - const html = renderApp(compileMarkdown(source).bodyHtml, { + const html = await renderApp((await compileMarkdown(source)).bodyHtml, { components: {}, context: createTestContext(), }) @@ -32,16 +32,19 @@ describe('compileMarkdown', () => { const mdx = await resolveMdxBuildOptions({ highlighter: 'highlightjs', }) - const html = renderApp(compileMarkdown(source, mdx).bodyHtml, { - components: {}, - context: createTestContext(), - }) + const html = await renderApp( + (await compileMarkdown(source, mdx)).bodyHtml, + { + components: {}, + context: createTestContext(), + }, + ) expect(html).toContain('
')
     expect(html).toContain('')
   })
 
-  it('collects h1, h2, and h3 headings into the page outline', () => {
+  it('collects h1, h2, and h3 headings into the page outline', async () => {
     const source = [
       '# Getting Started',
       '',
@@ -52,7 +55,7 @@ describe('compileMarkdown', () => {
       '## Configure',
     ].join('\n')
 
-    expect(compileMarkdown(source).outline).toEqual([
+    expect((await compileMarkdown(source)).outline).toEqual([
       {
         id: 'getting-started',
         title: 'Getting Started',
@@ -80,7 +83,7 @@ describe('compileMarkdown', () => {
     ])
   })
 
-  it('does not attach skipped headings to stale ancestors', () => {
+  it('does not attach skipped headings to stale ancestors', async () => {
     const source = [
       '# First Page',
       '',
@@ -91,7 +94,7 @@ describe('compileMarkdown', () => {
       '### Skipped Section',
     ].join('\n')
 
-    expect(compileMarkdown(source).outline).toEqual([
+    expect((await compileMarkdown(source)).outline).toEqual([
       {
         id: 'first-page',
         title: 'First Page',
diff --git a/packages/ts-ssg/src/mdx/md.ts b/packages/ts-ssg/src/mdx/md.ts
index 87ad8d3d..6cf39951 100644
--- a/packages/ts-ssg/src/mdx/md.ts
+++ b/packages/ts-ssg/src/mdx/md.ts
@@ -11,7 +11,7 @@ import {
 export function compileMarkdown(
   source: string,
   options: MdxRenderOptions = {},
-): MdxCompileResult {
+): Promise {
   const file = unified().use(remarkParse).use(remarkGfm).parse(source)
   sanitizeMarkdownHtmlNodes(file)
   return compileAstToHtml(file, options)
diff --git a/packages/ts-ssg/src/mdx/mdx.test.ts b/packages/ts-ssg/src/mdx/mdx.test.ts
index 97adf479..84454866 100644
--- a/packages/ts-ssg/src/mdx/mdx.test.ts
+++ b/packages/ts-ssg/src/mdx/mdx.test.ts
@@ -47,7 +47,7 @@ describe('createMdxHighlighter', () => {
 })
 
 describe('compileMdxToHtml', () => {
-  it('preserves regor directive attributes as raw markup', () => {
+  it('preserves regor directive attributes as raw markup', async () => {
     const source = [
       '',
       '  ',
@@ -56,7 +56,7 @@ describe('compileMdxToHtml', () => {
       '',
     ].join('\n')
 
-    const compiledHtml = compileMdxToHtml(source)
+    const compiledHtml = await compileMdxToHtml(source)
     expect(compiledHtml).toContain('')
     expect(compiledHtml).toContain(':tone="\'accent\'"')
     expect(compiledHtml).toContain('.size="buttonSize"')
@@ -65,21 +65,23 @@ describe('compileMdxToHtml', () => {
     expect(compiledHtml).not.toContain('

{ + it('unwraps standalone opaque markup paragraphs created by markdown parsing', async () => { const source = ['', '', 'Afterward.'].join( '\n', ) - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain('') expect(compiledHtml).not.toContain('

') expect(compiledHtml).toContain('

Afterward.

') }) - it('annotates script components with the MDX source path', () => { - const compiledHtml = compileMdx( - '\n', - { sourceRelPath: 'header.mdx' }, + it('annotates script components with the MDX source path', async () => { + const compiledHtml = ( + await compileMdx( + '\n', + { sourceRelPath: 'header.mdx' }, + ) ).bodyHtml expect(compiledHtml).toContain( @@ -90,18 +92,20 @@ describe('compileMdxToHtml', () => { ) }) - it('unwraps a standalone inline markup island when it is the only paragraph content', () => { - const compiledHtml = compileMdxToHtml('inline') + it('unwraps a standalone inline markup island when it is the only paragraph content', async () => { + const compiledHtml = await compileMdxToHtml('inline') expect(compiledHtml).toContain('inline') expect(compiledHtml).not.toContain('

inline

') }) - it('keeps mixed paragraph text around inline markup islands', () => { - const compiledHtml = compileMdxToHtml('Prefix inline suffix') + it('keeps mixed paragraph text around inline markup islands', async () => { + const compiledHtml = await compileMdxToHtml( + 'Prefix inline suffix', + ) expect(compiledHtml).toContain('

Prefix inline suffix

') }) - it('does not treat code spans or fenced code as regor markup', () => { + it('does not treat code spans or fenced code as regor markup', async () => { const source = [ '``', '', @@ -110,20 +114,22 @@ describe('compileMdxToHtml', () => { '```', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain( '<Btn .size="buttonSize">', ) expect(compiledHtml).toContain('<Btn @click="save">') }) - it('preserves prose spaces around inline code spans', () => { + it('preserves prose spaces around inline code spans', async () => { const source = 'through `BlockCacheLifeTime` and `InactiveBlockCacheCleanupInterval`.' - const compiledHtml = compileMdx(source, { - highlighter: createHljsHighlighter(), - }).bodyHtml + const compiledHtml = ( + await compileMdx(source, { + highlighter: createHljsHighlighter(), + }) + ).bodyHtml expect(compiledHtml).toContain('through and { it('renders a custom markup component at root level', async () => { const source = '\n\nParagraph text.' - const html = renderApp(compileMdxToHtml(source), { + const html = await renderApp(await compileMdxToHtml(source), { components: {}, context: createTestContext(), }) @@ -147,7 +153,7 @@ describe('compileMdxToHtml', () => { ['with a highlighter', createHljsHighlighter()], ])( 'keeps Regor template syntax literal inside Markdown code %s', - (_, highlighter) => { + async (_, highlighter) => { const source = [ 'Total {{ 1 + 1 }} with inline `{{ name }}` code.', '', @@ -155,10 +161,13 @@ describe('compileMdxToHtml', () => { '
  • {{ item }}
  • ', '```', ].join('\n') - const html = renderApp(compileMdx(source, { highlighter }).bodyHtml, { - components: {}, - context: createTestContext(), - }) + const html = await renderApp( + (await compileMdx(source, { highlighter })).bodyHtml, + { + components: {}, + context: createTestContext(), + }, + ) const text = html.replace(/<[^>]+>/g, '') expect(text).toContain('Total 2 with inline') @@ -169,7 +178,7 @@ describe('compileMdxToHtml', () => { it('preserves whitespace inside opaque inline markup blocks', async () => { const source = ['', ' Inline text', ''].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain('\n Inline text\n') expect(compiledHtml).not.toContain('

    ') @@ -186,7 +195,7 @@ describe('compileMdxToHtml', () => { 'Another paragraph', 'spanning two lines.', ].join('\n') - const html = renderApp(compileMdxToHtml(source), { + const html = await renderApp(await compileMdxToHtml(source), { components: {}, context: createTestContext(), }) @@ -209,7 +218,7 @@ describe('compileMdxToHtml', () => { '', '', ].join('\n') - const html = renderApp(compileMdxToHtml(source), { + const html = await renderApp(await compileMdxToHtml(source), { components: {}, context: createTestContext(), }) @@ -229,7 +238,7 @@ describe('compileMdxToHtml', () => { '| Bus | Land |', '| Ship | Sea |', ].join('\n') - const html = renderApp(compileMdxToHtml(source), { + const html = await renderApp(await compileMdxToHtml(source), { components: {}, context: createTestContext(), }) @@ -256,10 +265,10 @@ describe('compileMdxToHtml', () => { ].join('\n') try { - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).not.toContain('

    { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).not.toContain('

    ') expect(compiledHtml).toContain( @@ -306,7 +315,7 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain( ' Paragraph start now end.', ) @@ -324,7 +333,7 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain('

    Keep me

    ') }) @@ -337,7 +346,7 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain(' Prefix now') expect(compiledHtml).not.toContain('

    Prefix now

    ') }) @@ -354,7 +363,7 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).not.toContain('

    { '', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).not.toContain('

    ') expect(compiledHtml).toContain( @@ -410,7 +419,7 @@ describe('compileMdxToHtml', () => { 'Delete item', ].join('\n') - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).toContain( '

    10. Variants with icons

    ', ) @@ -469,11 +478,11 @@ describe('compileMdxToHtml', () => { ].join('\n') try { - const compiledHtml = compileMdxToHtml(source) + const compiledHtml = await compileMdxToHtml(source) expect(compiledHtml).not.toContain('

    ') expect(compiledHtml).not.toContain('

    ') - const html = renderApp(compiledHtml, { + const html = await renderApp(compiledHtml, { components: defineModalComponents(), context: createTestContext(), }) @@ -498,7 +507,7 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const html = compileMdx(source).bodyHtml + const html = (await compileMdx(source)).bodyHtml expect(html).toContain('') expect(html).toContain('') @@ -509,7 +518,7 @@ describe('compileMdxToHtml', () => { it.each(['```', '~~~~'])( 'preserves formatted source tabs with %s fences and no surrounding blank lines', - (fence) => { + async (fence) => { const source = [ ' { 'Still visible.', ].join('\n') - const result = compileMdx(source, { + const result = await compileMdx(source, { sourceRelPath: 'components/buttons.mdx', highlighter: createHljsHighlighter(), }) @@ -588,8 +597,8 @@ describe('compileMdxToHtml', () => { : engine === 'highlightjs' ? createHljsHighlighter() : undefined - const result = compileMdx(source, { highlighter }) - const rendered = renderApp(result.bodyHtml, { + const result = await compileMdx(source, { highlighter }) + const rendered = await renderApp(result.bodyHtml, { components: {}, context: createTestContext(), }) @@ -619,9 +628,11 @@ describe('compileMdxToHtml', () => { '', ].join('\n') - const html = compileMdx(source, { - highlighter: createHljsHighlighter(), - }).bodyHtml + const html = ( + await compileMdx(source, { + highlighter: createHljsHighlighter(), + }) + ).bodyHtml expect(html).toContain('

    ')
    @@ -638,7 +649,7 @@ describe('compileMdxToHtml', () => {
           '',
         ].join('\n')
     
    -    const html = compileMdx(source).bodyHtml
    +    const html = (await compileMdx(source)).bodyHtml
     
         expect(html).toContain('')
         expect(html).toContain(
    @@ -655,9 +666,11 @@ describe('compileMdxToHtml', () => {
           '',
         ].join('\n')
     
    -    const html = compileMdx(source, {
    -      highlighter: createHljsHighlighter(),
    -    }).bodyHtml
    +    const html = (
    +      await compileMdx(source, {
    +        highlighter: createHljsHighlighter(),
    +      })
    +    ).bodyHtml
     
         expect(html).toContain(' {
       const parser = unified().use(remarkParse).use(remarkGfm)
       const masked = maskRegorMarkup(source, (text) => parser.parse(text), {
         sourceRelPath: options.sourceRelPath,
    @@ -41,6 +41,6 @@ export function compileMdx(
       return compileAstToHtml(file, options)
     }
     
    -export function compileMdxToHtml(source: string): string {
    -  return compileMdx(source).bodyHtml
    +export async function compileMdxToHtml(source: string): Promise {
    +  return (await compileMdx(source)).bodyHtml
     }
    diff --git a/packages/ts-ssg/src/navigation/meta.ts b/packages/ts-ssg/src/navigation/meta.ts
    index cc18b925..0c257f18 100644
    --- a/packages/ts-ssg/src/navigation/meta.ts
    +++ b/packages/ts-ssg/src/navigation/meta.ts
    @@ -1,4 +1,4 @@
    -import fs from 'node:fs/promises'
    +import { readContentSource } from '../discover/content-source'
     import { parseFrontmatterSource } from '../frontmatter/frontmatter'
     import { resolveRouteFileInfo } from '../routing/route'
     import type { ContentMeta, NavigationContentFile } from './model'
    @@ -15,7 +15,7 @@ export async function loadContentMeta(
     ): Promise {
       const result: ContentMeta[] = []
       for (const file of files) {
    -    const raw = await fs.readFile(file.absPath, 'utf8')
    +    const raw = await readContentSource(file)
         const parsed = parseFrontmatterSource(raw, file.relPath)
         const frontmatter = parsed.frontmatter
         const route = resolveRouteFileInfo(file)
    diff --git a/packages/ts-ssg/src/plugins/generated-pages.test.ts b/packages/ts-ssg/src/plugins/generated-pages.test.ts
    new file mode 100644
    index 00000000..fd63a2f3
    --- /dev/null
    +++ b/packages/ts-ssg/src/plugins/generated-pages.test.ts
    @@ -0,0 +1,296 @@
    +import fs from 'node:fs/promises'
    +import path from 'node:path'
    +import { disableLogger, getLogger, type Logger } from 'logpot'
    +import { afterAll, beforeAll, describe, expect, it } from 'vitest'
    +import { readManifest } from '../build/manifest'
    +import { buildSite } from '../build/site'
    +import { resolveSiteConfig } from '../config/config'
    +import { readContentSource } from '../discover/content-source'
    +import { parseFrontmatterSource } from '../frontmatter/frontmatter'
    +import { resolveContentFiles } from '../i18n/content'
    +import { makeRepoTempDir } from '../test/repoTempDir'
    +import { generatePluginPages } from './generated-pages'
    +import { definePlugin, type PureStackPlugin } from './plugin'
    +
    +const config = resolveSiteConfig({ rootDir: process.cwd() })
    +const files = resolveContentFiles(config, [
    +  { absPath: '', relPath: 'blog/first.mdx', ext: '.mdx' },
    +])
    +
    +function generate(...plugins: PureStackPlugin[]) {
    +  return generatePluginPages(plugins, config, files)
    +}
    +
    +/** Builds one page per tag found in the blog's frontmatter. */
    +const tagPagesPlugin = definePlugin({
    +  name: 'tags',
    +  async pages({ files: contentFiles }) {
    +    const posts = new Map()
    +    for (const file of contentFiles) {
    +      if (!file.relPath.replaceAll('\\', '/').startsWith('blog/')) continue
    +      const source = await fs.readFile(file.absPath, 'utf8')
    +      const { frontmatter } = parseFrontmatterSource(source, file.relPath)
    +      for (const tag of (frontmatter.tags as string[] | undefined) ?? []) {
    +        posts.set(tag, [...(posts.get(tag) ?? []), String(frontmatter.title)])
    +      }
    +    }
    +    return [...posts].map(([tag, titles]) => ({
    +      path: `tags/${tag}.mdx`,
    +      source: [
    +        '---',
    +        `title: ${tag}`,
    +        '---',
    +        ...titles.map((t) => `- ${t}`),
    +      ].join('\n'),
    +    }))
    +  },
    +})
    +
    +describe('generated pages', () => {
    +  let logger: Logger | undefined
    +
    +  beforeAll(() => {
    +    disableLogger()
    +    logger = getLogger()
    +  })
    +
    +  afterAll(async () => {
    +    await logger?.close()
    +  })
    +
    +  it('turns generated pages into content files with their source', async () => {
    +    const generated = await generate({
    +      name: 'index',
    +      pages: ({ files: contentFiles }) => [
    +        { path: 'blog/index.mdx', source: `${contentFiles.length} posts` },
    +      ],
    +    })
    +
    +    expect(generated).toEqual([
    +      {
    +        absPath: path.join(config.contentDir, 'blog', 'index.mdx'),
    +        relPath: path.join('blog', 'index.mdx'),
    +        ext: '.mdx',
    +        source: '1 posts',
    +      },
    +    ])
    +  })
    +
    +  it.each([
    +    [() => 'nope', 'Plugin "bad" needs `pages` to return a list of pages.'],
    +    [
    +      () => ['page.mdx'],
    +      'Plugin "bad" needs each generated page to be an object with path and source.',
    +    ],
    +    [
    +      () => [{ path: 'a.mdx', source: '', title: 'A' }],
    +      'Plugin "bad" has an unknown generated page field "title". Page fields: path, source.',
    +    ],
    +    [
    +      () => [{ path: ' ', source: '' }],
    +      'Plugin "bad" needs each generated page to have a path.',
    +    ],
    +    [
    +      () => [{ path: 'a.mdx' }],
    +      'Plugin "bad" needs the generated page "a.mdx" to have a source: its text, or a function that returns it.',
    +    ],
    +    [
    +      () => [{ path: '../outside.mdx', source: '' }],
    +      'Plugin "bad" needs the generated page "../outside.mdx" to stay inside the content folder.',
    +    ],
    +    [
    +      () => [{ path: '/abs.mdx', source: '' }],
    +      'Plugin "bad" needs the generated page "/abs.mdx" to stay inside the content folder.',
    +    ],
    +    [
    +      () => [{ path: 'feed.xml', source: '' }],
    +      'Plugin "bad" needs the generated page "feed.xml" to end in .md, .mdx, or .rmdx.',
    +    ],
    +    [
    +      () => [{ path: 'guides/header.mdx', source: '' }],
    +      'Plugin "bad" cannot generate "guides/header.mdx"; headers and footers are written as files.',
    +    ],
    +    [
    +      () => [{ path: 'blog/first.mdx', source: '' }],
    +      'Plugin "bad" generates "blog/first.mdx", which is already a content file.',
    +    ],
    +    [
    +      () => {
    +        throw new Error('Feed unavailable.')
    +      },
    +      'Plugin "bad" failed in pages: Feed unavailable.',
    +    ],
    +  ])(
    +    'rejects a generator that returns or throws %#',
    +    async (pages, message) => {
    +      await expect(
    +        generate({
    +          name: 'bad',
    +          pages: pages as unknown as PureStackPlugin['pages'],
    +        }),
    +      ).rejects.toThrow(message)
    +    },
    +  )
    +
    +  it('keeps a source function and calls it only when the text is read', async () => {
    +    let calls = 0
    +    const [page] = await generate({
    +      name: 'lazy',
    +      pages: () => [
    +        {
    +          path: 'status.mdx',
    +          source: async () => {
    +            calls += 1
    +            return '# Status'
    +          },
    +        },
    +      ],
    +    })
    +
    +    expect(calls).toBe(0)
    +    expect(await readContentSource(page)).toBe('# Status')
    +    expect(calls).toBe(1)
    +  })
    +
    +  it.each([
    +    [
    +      () => {
    +        throw new Error('Store offline.')
    +      },
    +      'Plugin "bad" failed in the source of "status.mdx": Store offline.',
    +    ],
    +    [
    +      () => 42,
    +      'Plugin "bad" needs the source of "status.mdx" to return a string.',
    +    ],
    +  ])(
    +    'names the plugin when a source function fails %#',
    +    async (source, message) => {
    +      const [page] = await generate({
    +        name: 'bad',
    +        pages: () => [
    +          { path: 'status.mdx', source: source as unknown as () => string },
    +        ],
    +      })
    +
    +      await expect(readContentSource(page)).rejects.toThrow(message)
    +    },
    +  )
    +
    +  it('generates once per build and reads a source function only for navigation and rendering', async () => {
    +    const root = await makeRepoTempDir('.tmp-ts-ssg-generated-')
    +    try {
    +      const contentDir = path.join(root, 'content')
    +      const outDir = path.join(root, 'out')
    +      await fs.mkdir(contentDir, { recursive: true })
    +      await fs.writeFile(path.join(contentDir, 'index.mdx'), '# Home')
    +      const reads: string[] = []
    +      let generations = 0
    +      const statusPlugin = definePlugin({
    +        name: 'status',
    +        pages: () => {
    +          generations += 1
    +          return [
    +            {
    +              path: 'status.mdx',
    +              source: () => {
    +                reads.push('status')
    +                return ['---', 'title: Status', '---', 'All systems go.'].join(
    +                  '\n',
    +                )
    +              },
    +            },
    +          ]
    +        },
    +      })
    +
    +      const build = (mode: 'none' | 'auto') =>
    +        buildSite({
    +          siteConfig: {
    +            rootDir: root,
    +            contentDir,
    +            outDir,
    +            navigation: { mode },
    +          },
    +          options: { plugins: [statusPlugin] },
    +        })
    +
    +      // Without navigation, only rendering needs the text.
    +      await build('none')
    +      expect(generations).toBe(1)
    +      expect(reads).toHaveLength(1)
    +      const statusHtml = await fs.readFile(
    +        path.join(outDir, 'status', 'index.html'),
    +        'utf8',
    +      )
    +      expect(statusHtml).toContain('All systems go.')
    +
    +      // Navigation reads the page's frontmatter from the same function.
    +      generations = 0
    +      reads.length = 0
    +      await build('auto')
    +      expect(generations).toBe(1)
    +      expect(reads).toHaveLength(2)
    +      const homeHtml = await fs.readFile(
    +        path.join(outDir, 'index.html'),
    +        'utf8',
    +      )
    +      expect(homeHtml).toMatch(/]*href="\/status\/"[^>]*>[\s\S]*?Status/)
    +    } finally {
    +      await fs.rm(root, { recursive: true, force: true })
    +    }
    +  })
    +
    +  it('rejects two plugins generating the same page', async () => {
    +    const page = { path: 'about.mdx', source: '' }
    +
    +    await expect(
    +      generate(
    +        { name: 'first', pages: () => [page] },
    +        { name: 'second', pages: () => [page] },
    +      ),
    +    ).rejects.toThrow('Plugins "first" and "second" both generate "about.mdx".')
    +  })
    +
    +  it('builds generated pages like files, with links, navigation, and a manifest entry', async () => {
    +    const root = await makeRepoTempDir('.tmp-ts-ssg-generated-')
    +    try {
    +      const contentDir = path.join(root, 'content')
    +      const outDir = path.join(root, 'out')
    +      await fs.mkdir(path.join(contentDir, 'blog'), { recursive: true })
    +      await fs.writeFile(
    +        path.join(contentDir, 'blog', 'first.mdx'),
    +        [
    +          '---',
    +          'title: First post',
    +          'tags: [regor]',
    +          '---',
    +          '[More on Regor](../tags/regor)',
    +        ].join('\n'),
    +      )
    +      await fs.writeFile(path.join(contentDir, 'index.mdx'), '# Home')
    +
    +      await buildSite({
    +        siteConfig: { rootDir: root, contentDir, outDir },
    +        options: { plugins: [tagPagesPlugin] },
    +      })
    +
    +      const tagHtml = await fs.readFile(
    +        path.join(outDir, 'tags', 'regor', 'index.html'),
    +        'utf8',
    +      )
    +      expect(tagHtml).toContain('
  • First post
  • ') + const postHtml = await fs.readFile( + path.join(outDir, 'blog', 'first', 'index.html'), + 'utf8', + ) + expect(postHtml).toContain('href="/tags/regor/"') + const manifest = await readManifest(outDir) + expect(manifest?.content[path.join('tags', 'regor.mdx')]?.outPath).toBe( + path.join(outDir, 'tags', 'regor', 'index.html'), + ) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ts-ssg/src/plugins/generated-pages.ts b/packages/ts-ssg/src/plugins/generated-pages.ts new file mode 100644 index 00000000..6a6cc409 --- /dev/null +++ b/packages/ts-ssg/src/plugins/generated-pages.ts @@ -0,0 +1,148 @@ +import path from 'node:path' +import type { SiteConfig } from '@purestack/ts-common' +import { isPlainObject } from '@purestack/ts-util' +import { + type ContentFile, + isDefaultFooterFile, + isDefaultHeaderFile, +} from '../discover/content' +import { isContentExt } from '../discover/contentExtensions' +import type { ResolvedContentFile } from '../i18n/content' +import type { GeneratedPage, PureStackPlugin } from './plugin' + +const GENERATED_PAGE_FIELDS = ['path', 'source'] + +/** + * Runs every plugin's page generator. Each page becomes a content file that + * carries its source, so the rest of the build treats it like a file. The + * checks guard plugins written without types. + */ +export async function generatePluginPages( + plugins: readonly PureStackPlugin[], + config: SiteConfig, + files: readonly ResolvedContentFile[], +): Promise { + const owners = new Map( + files.map((file) => [toPosixPath(file.relPath), undefined]), + ) + const generated: ContentFile[] = [] + for (const plugin of plugins) { + if (!plugin.pages) continue + let pages: GeneratedPage[] + try { + pages = await plugin.pages({ config, files }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Plugin "${plugin.name}" failed in pages: ${message}`, { + cause: error, + }) + } + if (!Array.isArray(pages)) { + throw new Error( + `Plugin "${plugin.name}" needs \`pages\` to return a list of pages.`, + ) + } + for (const page of pages) { + const { relPath, source } = readGeneratedPage(plugin.name, page) + if (owners.has(relPath)) { + const owner = owners.get(relPath) + throw new Error( + owner + ? `Plugins "${owner}" and "${plugin.name}" both generate "${relPath}".` + : `Plugin "${plugin.name}" generates "${relPath}", which is already a content file.`, + ) + } + owners.set(relPath, plugin.name) + const osRelPath = relPath.split('/').join(path.sep) + generated.push({ + absPath: path.join(config.contentDir, osRelPath), + relPath: osRelPath, + ext: path.posix.extname(relPath), + source: + typeof source === 'function' + ? readPluginSource(plugin.name, relPath, source) + : source, + }) + } + } + return generated +} + +/** Calls a page's source function, naming the plugin when it fails. */ +function readPluginSource( + pluginName: string, + relPath: string, + source: () => string | Promise, +) { + return async () => { + let text: string + try { + text = await source() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Plugin "${pluginName}" failed in the source of "${relPath}": ${message}`, + { cause: error }, + ) + } + if (typeof text !== 'string') { + throw new Error( + `Plugin "${pluginName}" needs the source of "${relPath}" to return a string.`, + ) + } + return text + } +} + +function readGeneratedPage(pluginName: string, page: GeneratedPage) { + const fail = (problem: string): never => { + throw new Error(`Plugin "${pluginName}" ${problem}`) + } + if (!isPlainObject(page)) { + return fail( + 'needs each generated page to be an object with path and source.', + ) + } + for (const field of Object.keys(page)) { + if (!GENERATED_PAGE_FIELDS.includes(field)) { + fail( + `has an unknown generated page field "${field}". Page fields: ${GENERATED_PAGE_FIELDS.join(', ')}.`, + ) + } + } + const { path: pagePath, source } = page + if (typeof pagePath !== 'string' || !pagePath.trim()) { + return fail('needs each generated page to have a path.') + } + if (typeof source !== 'string' && typeof source !== 'function') { + return fail( + `needs the generated page "${pagePath}" to have a source: its text, or a function that returns it.`, + ) + } + const relPath = path.posix.normalize(toPosixPath(pagePath)) + if ( + path.posix.isAbsolute(relPath) || + path.win32.isAbsolute(pagePath) || + relPath === '..' || + relPath.startsWith('../') + ) { + fail( + `needs the generated page "${pagePath}" to stay inside the content folder.`, + ) + } + if (!isContentExt(path.posix.extname(relPath))) { + fail( + `needs the generated page "${pagePath}" to end in .md, .mdx, or .rmdx.`, + ) + } + if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { + fail( + `cannot generate "${pagePath}"; headers and footers are written as files.`, + ) + } + return { relPath, source } +} + +function toPosixPath(value: string) { + return value.replaceAll('\\', '/') +} diff --git a/packages/ts-ssg/src/plugins/plugin.test.ts b/packages/ts-ssg/src/plugins/plugin.test.ts new file mode 100644 index 00000000..13bb67c2 --- /dev/null +++ b/packages/ts-ssg/src/plugins/plugin.test.ts @@ -0,0 +1,468 @@ +import fs from 'node:fs/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import path from 'node:path' +import { h } from '@purestack/ts-html' +import { registerSkin, themeSkins } from '@purestack/ts-style' +import { disableLogger, getLogger, type Logger } from 'logpot' +import { defineComponent, html } from 'regor' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildSite } from '../build/site' +import { resolveSiteConfig } from '../config/config' +import { makeRepoTempDir } from '../test/repoTempDir' +import { + assertValidPlugins, + composeDevMiddleware, + composePluginHooks, + definePlugin, + type PureStackPlugin, + resolvePluginComponents, + resolvePluginTemplates, + withPluginSkins, +} from './plugin' + +const standardSkin = { + create: () => themeSkins.standard.create(), +} + +describe('plugins', () => { + let logger: Logger | undefined + + beforeAll(() => { + disableLogger() + logger = getLogger() + }) + + afterAll(async () => { + await logger?.close() + }) + + it('returns the plugin it defines', () => { + const plugin = { name: 'docs' } + + expect(definePlugin(plugin)).toBe(plugin) + }) + + describe('hooks', () => { + it('runs every plugin handler in plugin order, one after another', async () => { + const calls: string[] = [] + const hooks = composePluginHooks([ + { + name: 'first', + hooks: { + onBuildComplete: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + calls.push('first') + }, + }, + }, + { name: 'quiet' }, + { + name: 'second', + hooks: { onBuildComplete: () => void calls.push('second') }, + }, + ]) + + await hooks.onBuildComplete?.({} as never, { outDir: '', pages: 0 }) + + expect(calls).toEqual(['first', 'second']) + expect(hooks.onPageStart).toBeUndefined() + }) + + it('calls each hook as a method of its plugin hooks', async () => { + let receiver: unknown + const pluginHooks: PureStackPlugin['hooks'] = { + onBuildComplete() { + receiver = this + }, + } + const hooks = composePluginHooks([ + { name: 'methods', hooks: pluginHooks }, + ]) + + await hooks.onBuildComplete?.({} as never, { outDir: '', pages: 0 }) + + expect(receiver).toBe(pluginHooks) + }) + + it('names the plugin whose hook failed and keeps the cause', async () => { + const failure = new Error('boom') + const hooks = composePluginHooks([ + { name: 'ok', hooks: { onPageRendered: () => {} } }, + { + name: 'broken', + hooks: { + onPageRendered: () => { + throw failure + }, + }, + }, + ]) + + const error = await Promise.resolve( + hooks.onPageRendered?.({} as never, {} as never), + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe( + 'Plugin "broken" failed in onPageRendered: boom', + ) + expect((error as Error).cause).toBe(failure) + }) + }) + + describe('dev middleware', () => { + const respondWhenEnded = () => { + const response = { headersSent: false, writableEnded: false } + return { + response: response as unknown as ServerResponse, + end: () => { + response.writableEnded = true + }, + } + } + const request = {} as IncomingMessage + + it('runs each plugin in order until one responds', async () => { + const calls: string[] = [] + const { response, end } = respondWhenEnded() + const handle = composeDevMiddleware([ + { name: 'first', devMiddleware: () => void calls.push('first') }, + { name: 'quiet' }, + { + name: 'second', + devMiddleware: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + calls.push('second') + end() + }, + }, + { name: 'third', devMiddleware: () => void calls.push('third') }, + ]) + + expect(await handle(request, response)).toBe(true) + expect(calls).toEqual(['first', 'second']) + }) + + it('reports a request no plugin responded to', async () => { + const handle = composeDevMiddleware([ + { name: 'quiet', devMiddleware: () => {} }, + ]) + + expect(await handle(request, respondWhenEnded().response)).toBe(false) + }) + + it('names the plugin whose middleware failed and keeps the cause', async () => { + const failure = new Error('boom') + const handle = composeDevMiddleware([ + { + name: 'broken', + devMiddleware: () => { + throw failure + }, + }, + ]) + + const error = await handle(request, respondWhenEnded().response).catch( + (caught: unknown) => caught, + ) + + expect((error as Error).message).toBe( + 'Plugin "broken" failed in devMiddleware: boom', + ) + expect((error as Error).cause).toBe(failure) + }) + }) + + describe('merging by name', () => { + it('builds components from the resolved site config', () => { + const config = resolveSiteConfig({ + rootDir: process.cwd(), + siteTitle: 'Docs', + }) + const components = resolvePluginComponents( + [ + { + name: 'titled', + components: (siteConfig) => ({ + siteTitle: { title: siteConfig.siteTitle }, + }), + }, + { name: 'other', components: () => ({ badge: {} }) }, + ], + config, + ) + + expect(components).toEqual({ siteTitle: { title: 'Docs' }, badge: {} }) + }) + + it.each([ + [ + 'skin', + (plugins: PureStackPlugin[]) => withPluginSkins(plugins, () => {}), + { skins: { shared: standardSkin } }, + ], + [ + 'component', + (plugins: PureStackPlugin[]) => + resolvePluginComponents( + plugins, + resolveSiteConfig({ rootDir: process.cwd() }), + ), + { components: () => ({ shared: {} }) }, + ], + [ + 'template', + (plugins: PureStackPlugin[]) => resolvePluginTemplates(plugins), + { templates: { shared: () => h('html') } }, + ], + ])('rejects two plugins defining the same %s', (kind, resolve, fields) => { + expect(() => + resolve([ + { name: 'alpha', ...fields }, + { name: 'beta', ...fields }, + ]), + ).toThrow(`Plugins "alpha" and "beta" both define the ${kind} "shared".`) + }) + + it('rejects a components function that returns something else', () => { + expect(() => + resolvePluginComponents( + [{ name: 'broken', components: () => [] as never }], + resolveSiteConfig({ rootDir: process.cwd() }), + ), + ).toThrow( + 'Plugin "broken" needs `components` to return an object of components.', + ) + }) + }) + + describe('validation', () => { + const check = (plugins: unknown) => () => + assertValidPlugins(plugins as PureStackPlugin[]) + + it('accepts a complete plugin', () => { + expect( + check([ + { + name: 'complete', + skins: { complete: standardSkin }, + components: () => ({}), + templates: { landing: () => h('html') }, + hooks: { onBuildComplete: () => {} }, + }, + ]), + ).not.toThrow() + }) + + it('accepts a hook turned off with undefined, but still checks its name', () => { + expect( + check([{ name: 'conditional', hooks: { onPageRendered: undefined } }]), + ).not.toThrow() + expect( + check([{ name: 'conditional', hooks: { onPageRender: undefined } }]), + ).toThrow('Plugin "conditional" has an unknown hook "onPageRender".') + }) + + it.each([ + [{}, 'Plugin 1 needs a name.'], + [{ name: ' ' }, 'Plugin 1 needs a name.'], + [null, 'Plugin 1 must be an object.'], + [ + { name: 'typo', hook: {} }, + 'Plugin "typo" has an unknown field "hook". Plugin fields: name, skins, components, templates, markdown, pages, devMiddleware, hooks.', + ], + [ + { name: 'typo', hooks: { onPageRender: () => {} } }, + 'Plugin "typo" has an unknown hook "onPageRender". Hooks: onConfigResolved, onContentDiscovered, onNavigationBuilt, onPageStart, onPageDocument, onPageRendered, onPageWritten, onStylesWritten, onBuildComplete.', + ], + [ + { name: 'bad', hooks: { onBuildComplete: 'later' } }, + 'Plugin "bad" needs the hook "onBuildComplete" to be a function.', + ], + [ + { name: 'bad', hooks: [] }, + 'Plugin "bad" needs `hooks` to be an object of hooks.', + ], + [ + { name: 'bad', components: { card: {} } }, + 'Plugin "bad" needs `components` to be a function that returns components.', + ], + [ + { name: 'bad', templates: { landing: '' } }, + 'Plugin "bad" needs the template "landing" to be a function.', + ], + [ + { name: 'bad', skins: { brand: {} } }, + 'Plugin "bad" needs the skin "brand" to have a create() function.', + ], + [ + { name: 'bad', pages: [] }, + 'Plugin "bad" needs `pages` to be a function that returns pages.', + ], + [ + { name: 'bad', devMiddleware: {} }, + 'Plugin "bad" needs `devMiddleware` to be a function that handles requests.', + ], + [ + { name: 'bad', markdown: [] }, + 'Plugin "bad" needs `markdown` to be an object of remark and rehype plugins.', + ], + [ + { name: 'typo', markdown: { remark: [] } }, + 'Plugin "typo" has an unknown markdown field "remark". Markdown fields: remarkPlugins, rehypePlugins.', + ], + [ + { name: 'bad', markdown: { rehypePlugins: () => {} } }, + 'Plugin "bad" needs `markdown.rehypePlugins` to be a list of plugins.', + ], + [ + { name: 'brand', skins: { standard: standardSkin } }, + 'Plugin "brand" cannot replace the built-in skin "standard". Give its skin another name.', + ], + ])('rejects %j', (plugin, message) => { + expect(check([plugin])).toThrow(message) + }) + + it('rejects a plugin list that is not an array', () => { + expect(check({ name: 'docs' })).toThrow( + '`plugins` must be an array of plugins.', + ) + }) + + it('rejects repeated plugins', () => { + expect(check([{ name: 'docs' }, { name: 'docs' }])).toThrow( + 'The plugin "docs" is listed more than once.', + ) + }) + }) + + describe('skins', () => { + it('registers plugin skins only while the site config resolves', () => { + const plugin = { name: 'temp', skins: { 'temp-skin': standardSkin } } + + const registered = withPluginSkins( + [plugin], + () => 'temp-skin' in themeSkins, + ) + + expect(registered).toBe(true) + expect('temp-skin' in themeSkins).toBe(false) + }) + + it('restores a same-named skin registered outside plugins, even after a failure', () => { + const outside = { create: () => themeSkins.standard.create() } + registerSkin('shared-skin', outside) + try { + const plugin = { + name: 'shadow', + skins: { 'shared-skin': standardSkin }, + } + + expect(() => + withPluginSkins([plugin], () => { + expect(themeSkins['shared-skin']).toBe(standardSkin) + throw new Error('Resolution failed.') + }), + ).toThrow('Resolution failed.') + expect(themeSkins['shared-skin']).toBe(outside) + } finally { + delete themeSkins['shared-skin'] + } + }) + }) + + it('builds a site from every kind of plugin extension', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-plugin-') + try { + const contentDir = path.join(root, 'content') + const outDir = path.join(root, 'out') + await fs.mkdir(contentDir, { recursive: true }) + await fs.writeFile( + path.join(contentDir, 'index.mdx'), + [ + '---', + 'template: landing', + '---', + '', + '', + 'Page note.', + ].join('\n'), + ) + await fs.writeFile(path.join(contentDir, 'header.mdx'), 'Header note.') + const markParagraphs = + () => + (tree: { children?: unknown[] }): void => { + const visit = (node: { + tagName?: string + properties?: object + children?: unknown[] + }) => { + if (node.tagName === 'p') node.properties = { className: ['noted'] } + for (const child of node.children ?? []) visit(child as never) + } + visit(tree) + } + const calls: string[] = [] + const plugin = definePlugin({ + name: 'landing', + skins: { 'plugin-skin': standardSkin }, + components: () => ({ + greeting: defineComponent<{ name?: string }>( + html`

    Hello {{ name }}

    `, + { props: ['name'] }, + ), + }), + templates: { + landing: ({ head, bodyHtml, headerHtml }) => + h('html').push( + head, + h('body') + .attr({ class: 'landing' }) + .push(h('').raw(headerHtml ?? '')) + .raw(bodyHtml), + ), + }, + markdown: { rehypePlugins: [markParagraphs] }, + hooks: { + onConfigResolved: () => { + calls.push('config') + }, + onPageWritten: (_context, page) => { + calls.push(`written:${page.urlPath}`) + }, + }, + }) + + await buildSite({ + siteConfig: { + rootDir: root, + contentDir, + outDir, + style: { theme: { skin: 'plugin-skin' } }, + }, + options: { plugins: [plugin] }, + }) + + const pageHtml = await fs.readFile( + path.join(outDir, 'index.html'), + 'utf8', + ) + expect(pageHtml).toContain('') + expect(pageHtml).toContain('Hello Ada') + // Content plugins reach pages and shared partials alike. + expect(pageHtml).toContain('

    Page note.

    ') + expect(pageHtml).toContain('

    Header note.

    ') + // An unknown skin would have failed config resolution before this point. + expect(calls).toEqual(['config', 'written:/']) + // The skin belonged to that build; a later one cannot select it. + expect(() => + resolveSiteConfig({ + rootDir: root, + style: { theme: { skin: 'plugin-skin' } }, + }), + ).toThrow('Unknown theme skin "plugin-skin"') + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ts-ssg/src/plugins/plugin.ts b/packages/ts-ssg/src/plugins/plugin.ts new file mode 100644 index 00000000..eb0d77c7 --- /dev/null +++ b/packages/ts-ssg/src/plugins/plugin.ts @@ -0,0 +1,352 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { PageTemplateMap, SiteConfig } from '@purestack/ts-common' +import { registerSkin, type ThemeSkin, themeSkins } from '@purestack/ts-style' +import { isPlainObject } from '@purestack/ts-util' +import type { PluggableList } from 'unified' +import type { BuildHooks } from '../build/site' +import type { ResolvedContentFile } from '../i18n/content' +import { createContentProcessor } from '../mdx/compile' + +/** + * A named bundle of extensions for a PureStack site. Plugins compose in + * order: skins, components, and templates merge by name, and each lifecycle + * hook runs every plugin's handler in turn. + */ +export interface PureStackPlugin { + /** Names the plugin in conflict and hook error messages. */ + name: string + /** Theme skins the site config can select with `style.theme.skin`. */ + skins?: Record + /** Regor components, built from the resolved site config. */ + components?: (config: SiteConfig) => Record + /** Page templates, selected by the `template` frontmatter field. */ + templates?: PageTemplateMap + /** Remark and rehype plugins, run on every page and shared partial. */ + markdown?: PureStackMarkdown + /** + * Pages without files, such as tag or index pages. Runs whenever the + * site's content or assets change, so pages can follow other pages. + */ + pages?: ( + context: PageGenerationContext, + ) => GeneratedPage[] | Promise + /** + * Sees every request the development server receives, before the site is + * served. Respond to handle it; otherwise the next plugin, then the site, + * handles it. Builds ignore it. + */ + devMiddleware?: ( + request: IncomingMessage, + response: ServerResponse, + ) => void | Promise + /** Build lifecycle hooks. */ + hooks?: BuildHooks +} + +export interface PageGenerationContext { + config: SiteConfig + /** The site's content files; generated pages are not among them. */ + files: readonly ResolvedContentFile[] +} + +export interface GeneratedPage { + /** + * The content path the page takes, such as `blog/tags/regor.mdx`. Routes, + * links, navigation, and partials treat it like a file at that path. + */ + path: string + /** + * Frontmatter and Markdown or Regor MDX, as a file would hold, or a + * function that returns it. PureStack never keeps what the function + * returns and calls it whenever it needs the text, so the plugin decides + * what to cache. + */ + source: string | (() => string | Promise) +} + +export interface PureStackMarkdown { + /** Transform the Markdown tree, after Regor markup is restored. */ + remarkPlugins?: PluggableList + /** Transform the HTML tree, before the outline and code highlighting. */ + rehypePlugins?: PluggableList +} + +export function definePlugin(plugin: PureStackPlugin): PureStackPlugin { + return plugin +} + +const PLUGIN_FIELDS = [ + 'name', + 'skins', + 'components', + 'templates', + 'markdown', + 'pages', + 'devMiddleware', + 'hooks', +] +const MARKDOWN_FIELDS = ['remarkPlugins', 'rehypePlugins'] + +const HOOK_NAMES = [ + 'onConfigResolved', + 'onContentDiscovered', + 'onNavigationBuilt', + 'onPageStart', + 'onPageDocument', + 'onPageRendered', + 'onPageWritten', + 'onStylesWritten', + 'onBuildComplete', +] as const satisfies readonly (keyof BuildHooks)[] + +// A built-in skin also backs the default theme, so a plugin replacing one +// would apply only where a page selects it by name. +const BUILT_IN_SKIN_NAMES = new Set(Object.keys(themeSkins)) + +/** + * Checks the shape of every plugin before the build uses it, so a typo such + * as `onPageRender` fails with the plugin's name instead of being ignored. + */ +export function assertValidPlugins(plugins: readonly PureStackPlugin[]) { + if (!Array.isArray(plugins)) { + throw new Error('`plugins` must be an array of plugins.') + } + const names = new Set() + plugins.forEach((plugin: unknown, index) => { + if (!isPlainObject(plugin)) { + throw new Error(`Plugin ${index + 1} must be an object.`) + } + const { name } = plugin + if (typeof name !== 'string' || !name.trim()) { + throw new Error(`Plugin ${index + 1} needs a name.`) + } + if (names.has(name)) { + throw new Error(`The plugin "${name}" is listed more than once.`) + } + names.add(name) + assertPluginFields(name, plugin) + }) +} + +function assertPluginFields(name: string, plugin: Record) { + const fail = (problem: string): never => { + throw new Error(`Plugin "${name}" ${problem}`) + } + for (const field of Object.keys(plugin)) { + if (!PLUGIN_FIELDS.includes(field)) { + fail( + `has an unknown field "${field}". Plugin fields: ${PLUGIN_FIELDS.join(', ')}.`, + ) + } + } + const { + skins, + components, + templates, + markdown, + pages, + devMiddleware, + hooks, + } = plugin + if (pages !== undefined && typeof pages !== 'function') { + fail('needs `pages` to be a function that returns pages.') + } + if (devMiddleware !== undefined && typeof devMiddleware !== 'function') { + fail('needs `devMiddleware` to be a function that handles requests.') + } + if (markdown !== undefined) { + if (!isPlainObject(markdown)) { + fail('needs `markdown` to be an object of remark and rehype plugins.') + } + for (const [field, list] of Object.entries(markdown as object)) { + if (!MARKDOWN_FIELDS.includes(field)) { + fail( + `has an unknown markdown field "${field}". Markdown fields: ${MARKDOWN_FIELDS.join(', ')}.`, + ) + } + if (list !== undefined && !Array.isArray(list)) { + fail(`needs \`markdown.${field}\` to be a list of plugins.`) + } + } + } + if (skins !== undefined) { + if (!isPlainObject(skins)) fail('needs `skins` to be an object of skins.') + for (const [skinName, skin] of Object.entries(skins as object)) { + if (!isPlainObject(skin) || typeof skin.create !== 'function') { + fail(`needs the skin "${skinName}" to have a create() function.`) + } + if (BUILT_IN_SKIN_NAMES.has(skinName)) { + fail( + `cannot replace the built-in skin "${skinName}". Give its skin another name.`, + ) + } + } + } + if (components !== undefined && typeof components !== 'function') { + fail('needs `components` to be a function that returns components.') + } + if (templates !== undefined) { + if (!isPlainObject(templates)) { + fail('needs `templates` to be an object of templates.') + } + for (const [templateName, template] of Object.entries( + templates as object, + )) { + if (typeof template !== 'function') { + fail(`needs the template "${templateName}" to be a function.`) + } + } + } + if (hooks !== undefined) { + if (!isPlainObject(hooks)) fail('needs `hooks` to be an object of hooks.') + for (const [hookName, hook] of Object.entries(hooks as object)) { + if (!(HOOK_NAMES as readonly string[]).includes(hookName)) { + fail( + `has an unknown hook "${hookName}". Hooks: ${HOOK_NAMES.join(', ')}.`, + ) + } + // Hooks are optional, so `undefined` turns one off conditionally. + if (hook !== undefined && typeof hook !== 'function') { + fail(`needs the hook "${hookName}" to be a function.`) + } + } + } +} + +/** + * Runs `resolve` with every plugin skin registered. Skins are looked up only + * while the site config resolves, so they are removed afterwards and never + * outlast the build that added them. + */ +export function withPluginSkins( + plugins: readonly PureStackPlugin[], + resolve: () => T, +): T { + const skins = mergeByName(plugins, 'skin', (plugin) => plugin.skins) + const replaced = new Map() + for (const [name, skin] of Object.entries(skins)) { + replaced.set(name, themeSkins[name]) + registerSkin(name, skin) + } + try { + return resolve() + } finally { + for (const [name, previous] of replaced) { + if (previous) registerSkin(name, previous) + else delete themeSkins[name] + } + } +} + +export function resolvePluginComponents( + plugins: readonly PureStackPlugin[], + config: SiteConfig, +) { + return mergeByName(plugins, 'component', (plugin) => { + const components = plugin.components?.(config) + if (components !== undefined && !isPlainObject(components)) { + throw new Error( + `Plugin "${plugin.name}" needs \`components\` to return an object of components.`, + ) + } + return components + }) +} + +export function resolvePluginTemplates( + plugins: readonly PureStackPlugin[], +): PageTemplateMap { + return mergeByName(plugins, 'template', (plugin) => plugin.templates) +} + +/** The build's content processor, with every plugin's remark and rehype plugins. */ +export function resolvePluginContentProcessor( + plugins: readonly PureStackPlugin[], +) { + return createContentProcessor( + plugins.flatMap((plugin) => plugin.markdown?.remarkPlugins ?? []), + plugins.flatMap((plugin) => plugin.markdown?.rehypePlugins ?? []), + ) +} + +/** + * Runs each plugin's dev middleware in order until one responds. Returns + * whether a plugin handled the request. + */ +export function composeDevMiddleware(plugins: readonly PureStackPlugin[]) { + const handlers = plugins.flatMap((plugin) => + plugin.devMiddleware ? [{ plugin, handler: plugin.devMiddleware }] : [], + ) + return async (request: IncomingMessage, response: ServerResponse) => { + for (const { plugin, handler } of handlers) { + try { + await handler(request, response) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Plugin "${plugin.name}" failed in devMiddleware: ${message}`, + { cause: error }, + ) + } + if (response.headersSent || response.writableEnded) return true + } + return false + } +} + +/** One set of hooks that runs each plugin's handlers in plugin order. */ +export function composePluginHooks( + plugins: readonly PureStackPlugin[], +): BuildHooks { + const hooks: BuildHooks = {} + for (const name of HOOK_NAMES) { + const handlers = plugins.flatMap((plugin) => { + const handler = plugin.hooks?.[name] + return handler ? [{ plugin, handler }] : [] + }) + if (handlers.length === 0) continue + Object.assign(hooks, { + [name]: async (...args: unknown[]) => { + for (const { plugin, handler } of handlers) { + try { + // Called as a method of the plugin's hooks, as a build calls it. + await (handler as (...hookArgs: unknown[]) => unknown).apply( + plugin.hooks, + args, + ) + } catch (error) { + const message = + error instanceof Error ? error.message : String(error) + throw new Error( + `Plugin "${plugin.name}" failed in ${name}: ${message}`, + { cause: error }, + ) + } + } + }, + }) + } + return hooks +} + +function mergeByName( + plugins: readonly PureStackPlugin[], + kind: string, + pick: (plugin: PureStackPlugin) => Record | undefined, +) { + const merged: Record = {} + const owners = new Map() + for (const plugin of plugins) { + for (const [name, value] of Object.entries(pick(plugin) ?? {})) { + const owner = owners.get(name) + if (owner) { + throw new Error( + `Plugins "${owner}" and "${plugin.name}" both define the ${kind} "${name}".`, + ) + } + owners.set(name, plugin.name) + merged[name] = value + } + } + return merged +} diff --git a/packages/ts-ssg/src/regor/initBuiltinComponents.ts b/packages/ts-ssg/src/regor/initBuiltinComponents.ts index 9ffd1c89..3485ca4b 100644 --- a/packages/ts-ssg/src/regor/initBuiltinComponents.ts +++ b/packages/ts-ssg/src/regor/initBuiltinComponents.ts @@ -1,5 +1,6 @@ +import { AsyncLocalStorage } from 'node:async_hooks' import { defineComponents, registerStyles } from '@purestack/ts-components' -import { ensureDomGlobals } from '@purestack/ts-minidom' +import { ensureDomGlobals, useDomScope } from '@purestack/ts-minidom' import { componentRegistry } from '@purestack/ts-render' import { registerNormalizeStyles, @@ -11,6 +12,10 @@ import { defineScriptComponents } from '../../../ts-components/src/standard/page import { registerDocLayoutStyles } from '../templates/docLayoutStyles' import { registerMarkdownStyles } from '../templates/markdownStyles' +// Each page renders in a DOM of its own, which async hooks keep while they +// await, so renders in `serve` can overlap. +const pageDomScope = new AsyncLocalStorage() + export interface BuiltinComponentInitOptions { includeShikiStyles?: boolean } @@ -19,6 +24,7 @@ export function initBuiltinComponents( options: BuiltinComponentInitOptions = {}, ) { ensureDomGlobals() + useDomScope(pageDomScope) styleBuilder.reset() componentRegistry.clear() registerNormalizeStyles() diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index 1e068442..feb302a6 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index a0da7da0..22d4fbe7 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index 406ca260..aa9fe8d8 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.2", + "version": "1.1.3", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts",