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(
'Custom artwork ',
{ 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
Confirm ',
{
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(
'Custom shell
',
{
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('