From 705997bdc172f3a4aa94ab1a403704d82c304db5 Mon Sep 17 00:00:00 2001
From: Ahmed Yasin Koculu
Date: Mon, 5 Oct 2026 09:39:03 +0200
Subject: [PATCH 1/2] Improve extensibility with plugin architecture.
---
frontend/README.md | 10 +-
frontend/docs/siteGuide.ts | 11 +-
.../components/site/consent/consent.mdx | 6 +-
.../components/site/consent/preview.ts | 6 +-
.../components/site/nav-menu/nav-menu.mdx | 6 +-
.../components/site/nav-menu/preview.ts | 6 +-
.../components/site/page-toc/page-toc.mdx | 6 +-
.../components/site/page-toc/preview.ts | 6 +-
.../components/site/sign-in/preview.ts | 6 +-
.../components/site/sign-in/sign-in.mdx | 6 +-
.../components/site/top-bar/preview.ts | 6 +-
.../components/site/top-bar/top-bar.mdx | 6 +-
frontend/purestack.studio/guides/_nav.json | 3 +-
frontend/purestack.studio/guides/index.mdx | 7 +-
frontend/purestack.studio/guides/plugins.mdx | 551 ++++++++++++++++++
.../purestack.studio/guides/purestack-cli.mdx | 7 +-
frontend/purestack.studio/guides/themes.mdx | 44 +-
.../purestack.studio/guides/typography.mdx | 12 +-
frontend/purestack.studio/purestack.config.ts | 6 +
frontend/studio.ts | 92 ---
frontend/studioPlugin.ts | 62 ++
frontend/theme/studioSkin.ts | 14 +-
package.json | 6 +-
packages/purestack/README.md | 25 +-
.../src/standard/btn/btn.test.ts | 40 +-
.../src/standard/btnGroup/btnGroup.test.ts | 24 +-
.../standard/classicLogo/classicLogo.test.ts | 16 +-
.../src/standard/consent/consent.test.ts | 4 +-
.../standard/contactForm/contactForm.test.ts | 4 +-
.../src/standard/flex/flex.test.ts | 20 +-
.../src/standard/footer/footer.test.ts | 8 +-
.../src/standard/grid/grid.test.ts | 33 +-
.../src/standard/icon/icon.test.ts | 20 +-
.../src/standard/landing/landing.test.ts | 12 +-
.../src/standard/logo/logo.test.ts | 8 +-
.../src/standard/modal/modal.test.ts | 12 +-
.../src/standard/navMenu/navMenu.test.ts | 22 +-
.../standard/pageScript/pageScript.test.ts | 55 +-
.../src/standard/pageToc/pageToc.test.ts | 12 +-
.../src/standard/signIn/signIn.test.ts | 22 +-
.../src/standard/tabs/tabs.test.ts | 12 +-
.../standard/themeToggle/themeToggle.test.ts | 4 +-
.../src/standard/topBar/topBar.test.ts | 4 +-
packages/ts-minidom/README.md | 26 +-
packages/ts-minidom/src/createDom.test.ts | 111 ++++
packages/ts-minidom/src/createDom.ts | 208 ++++---
packages/ts-minidom/src/index.ts | 8 +-
packages/ts-minidom/src/minidom.ts | 7 -
packages/ts-render/README.md | 12 +-
packages/ts-render/src/componentRegistry.ts | 9 -
packages/ts-render/src/renderApp.ts | 62 +-
packages/ts-ssg/README.md | 150 +++--
packages/ts-ssg/src/build/build-config.ts | 7 +-
.../src/build/incremental/change-applier.ts | 2 +
.../src/build/incremental/content-state.ts | 60 +-
.../src/build/incremental/incremental.md | 37 +-
.../src/build/incremental/incremental.test.ts | 294 +++++++++-
.../ts-ssg/src/build/incremental/index.ts | 106 +++-
.../ts-ssg/src/build/incremental/support.ts | 26 +-
packages/ts-ssg/src/build/logger.test.ts | 19 +
packages/ts-ssg/src/build/logger.ts | 10 +
packages/ts-ssg/src/build/manifest.ts | 15 +
packages/ts-ssg/src/build/page.ts | 47 +-
packages/ts-ssg/src/build/site.ts | 20 +-
packages/ts-ssg/src/cli-runner.ts | 18 +-
.../ts-ssg/src/config/project-config.test.ts | 151 +++++
packages/ts-ssg/src/config/project-config.ts | 162 +++++
packages/ts-ssg/src/dev/server.test.ts | 185 +++++-
packages/ts-ssg/src/dev/server.ts | 96 ++-
packages/ts-ssg/src/dev/watch-tree.ts | 35 ++
.../ts-ssg/src/discover/content-source.ts | 8 +
packages/ts-ssg/src/discover/content.ts | 5 +
packages/ts-ssg/src/index.ts | 14 +-
packages/ts-ssg/src/mdx/compile.test.ts | 124 ++++
packages/ts-ssg/src/mdx/compile.ts | 45 +-
packages/ts-ssg/src/mdx/md.test.ts | 21 +-
packages/ts-ssg/src/mdx/md.ts | 2 +-
packages/ts-ssg/src/mdx/mdx.test.ts | 115 ++--
packages/ts-ssg/src/mdx/mdx.ts | 6 +-
packages/ts-ssg/src/navigation/meta.ts | 4 +-
.../src/plugins/generated-pages.test.ts | 296 ++++++++++
.../ts-ssg/src/plugins/generated-pages.ts | 148 +++++
packages/ts-ssg/src/plugins/plugin.test.ts | 468 +++++++++++++++
packages/ts-ssg/src/plugins/plugin.ts | 352 +++++++++++
.../ts-ssg/src/regor/initBuiltinComponents.ts | 8 +-
85 files changed, 4063 insertions(+), 678 deletions(-)
create mode 100644 frontend/purestack.studio/guides/plugins.mdx
create mode 100644 frontend/purestack.studio/purestack.config.ts
delete mode 100644 frontend/studio.ts
create mode 100644 frontend/studioPlugin.ts
create mode 100644 packages/ts-minidom/src/createDom.test.ts
create mode 100644 packages/ts-ssg/src/build/logger.test.ts
create mode 100644 packages/ts-ssg/src/build/logger.ts
create mode 100644 packages/ts-ssg/src/config/project-config.test.ts
create mode 100644 packages/ts-ssg/src/config/project-config.ts
create mode 100644 packages/ts-ssg/src/discover/content-source.ts
create mode 100644 packages/ts-ssg/src/mdx/compile.test.ts
create mode 100644 packages/ts-ssg/src/plugins/generated-pages.test.ts
create mode 100644 packages/ts-ssg/src/plugins/generated-pages.ts
create mode 100644 packages/ts-ssg/src/plugins/plugin.test.ts
create mode 100644 packages/ts-ssg/src/plugins/plugin.ts
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..51e4bdd8 100644
--- a/package.json
+++ b/package.json
@@ -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/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('