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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
## Change
## Summary

Describe the problem and resulting behavior. Link a related issue if applicable.
Summarize the change.

## Validation
## Changes

Describe the changes.

Describe the checks you ran and include screenshots for visible UI changes.
## Validation

- [ ] Relevant tests and documentation updated.
- [ ] CI passes.
- [ ] Required generated assets regenerated manually, if applicable.
Describe the validation.
30 changes: 30 additions & 0 deletions frontend/purestack.studio/guides/extending/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ 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.

## Standard plugins

PureStack includes three plugins automatically in `build`, `serve`, and `publish`:

| Package | Factory | Provides |
| --- | --- | --- |
| `@purestack/ts-components` | `componentsPlugin()` | Standard components, icons, and component styles |
| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, and theme switching |
| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, navigation, code copying, search, and TOC runtimes |

The stylesheet provider initializes before component factories, then all config hooks run in plugin order. Names do not affect initialization. Components belong to each build, so hooks can override them through `context.components`. The theme and page runtime plugins supply head templates that run before the selected page template, preserving its ability to inspect or change the completed head. The lifecycle hook set is unchanged.

To choose the standard integrations yourself, disable the automatic set and list the plugins you want. The factories are exported from `purestack`, `@purestack/ts-ssg`, and their individual packages:

```ts
import { componentsPlugin, defineConfig, themePlugin } from 'purestack'

export default defineConfig({
defaultPlugins: false,
plugins: [themePlugin(), componentsPlugin()],
})
```

This keeps the layouts and components while omitting the page runtimes. Theme switching belongs to the theme plugin; component-specific Tabs and Modal embeds still follow the page's `embed` settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need.

An explicitly listed standard plugin replaces the automatic instance by name and keeps its initialization position. You can also use `createDefaultPlugins()` to obtain the complete set. In programmatic builds, use `options.defaultPlugins` alongside `options.plugins`.

## 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: [...] })`.
Expand Down Expand Up @@ -496,9 +523,12 @@ Every field except `name` is optional.
| Field | Type |
| --- | --- |
| `name` | `string`, unique among the site's plugins |
| `defaults` | `boolean`; makes component and template contributions overridable defaults without changing initialization order |
| `skins` | Skins by name, each `{ create(): ThemeSkinPair }` |
| `components` | `(config) => ({ [name]: component })` |
| `templates` | Templates by name, each `(input) => TSNode<'html'>` |
| `head` | `(input) => TSNode<'head'>`, or a promise; composes head markup before the page template |
| `styles` | `(config) => SiteStyles`; one stylesheet provider, initialized before component factories |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion frontend/purestack.studio/siteConfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "../../packages/ts-ssg/schema/siteConfig.schema.json",
"$schema": "https://raw.githubusercontent.com/PureStackStudio/PureStack/refs/heads/main/packages/ts-ssg/schema/siteConfig.schema.json",
"siteTitle": "PureStack",
"favicon": "tabler:stack-2",
"style": {
Expand Down
3 changes: 3 additions & 0 deletions packages/ts-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
PageFrontmatter,
ParsedFrontmatterSource,
} from './frontmatter-types'
export { isTocEnabled } from './isTocEnabled'
export type {
ClassicLogoConfig,
LogoAppearance,
Expand All @@ -32,6 +33,7 @@ export type {
PageNavigationLinks,
} from './navMenu-types'
export type {
PageHeadTemplate,
PageInfo,
PageTemplate,
PageTemplateInput,
Expand Down Expand Up @@ -62,6 +64,7 @@ export type {
SiteStyleConfig,
SiteStyleConfigInput,
} from './site-config-types'
export type { SiteStyles, WriteStylesResult } from './site-styles'
export type {
PageContext,
PageOutlineItem,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PageFrontmatter, PageOutlineItem } from '@purestack/ts-common'
import type { PageFrontmatter } from './frontmatter-types'
import type { PageOutlineItem } from './ts-ssg-context'

export function isTocEnabled(
frontmatter: PageFrontmatter,
Expand Down
5 changes: 5 additions & 0 deletions packages/ts-common/src/page-template-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ export type PageTemplate = (
) => TSNode<'html'> | Promise<TSNode<'html'>>

export type PageTemplateMap = Record<string, PageTemplate>

/** Composes head markup before the page template receives it. */
export type PageHeadTemplate = (
input: PageTemplateInput,
) => TSNode<'head'> | Promise<TSNode<'head'>>
18 changes: 18 additions & 0 deletions packages/ts-common/src/site-styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ThemeStylesheetLink } from '@purestack/ts-style'

export interface WriteStylesResult {
revision: number
outPath: string
outputs: string[]
signature: string
}

/** A plugin's stylesheet output, initialized before component factories. */
export interface SiteStyles {
readonly revision: number
links(cacheKey?: string): ThemeStylesheetLink[]
write(input: {
outDir: string
cacheKey?: string
}): Promise<WriteStylesResult>
}
13 changes: 13 additions & 0 deletions packages/ts-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,16 @@ by the static site generator and available to custom renderers.
`registerStyles()` adds its styles to PureStack's theme builder. Individual
component definitions and style registration functions are also exported when
you need a smaller selection.

`componentsPlugin()` integrates these components, SVG icons, and their styles
as default contributions scoped to each build. It is enabled by default by
`@purestack/ts-ssg` and can also be imported from this package:

```ts
import { componentsPlugin } from '@purestack/ts-components'
```

Extension component factories and config hooks can override standard components
through `context.components` and register styles. When choosing plugins explicitly with `defaultPlugins: false`,
include `@purestack/ts-theme` to emit CSS, or supply your own style writer through
`context.styles`.
1 change: 1 addition & 0 deletions packages/ts-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@purestack/ts-css": "workspace:^",
"@purestack/ts-page-scripts": "workspace:^",
"@purestack/ts-style": "workspace:^",
"@purestack/ts-svg-icons": "workspace:^",
"@purestack/ts-util": "workspace:^",
"regor": "^1.7.5"
},
Expand Down
1 change: 1 addition & 0 deletions packages/ts-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
TsSsgTabsApi,
} from '@purestack/ts-page-scripts'
export { defineComponents } from './defineComponents'
export { componentsPlugin } from './plugin'
export { registerStyles } from './registerStyles'
export type { AlertBox } from './standard/alertBox/alertBox'
export { defineAlertComponents } from './standard/alertBox/alertBox'
Expand Down
17 changes: 17 additions & 0 deletions packages/ts-components/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { registerSemanticToneUtilityStyles } from '@purestack/ts-style'
import { getSvgIcon } from '@purestack/ts-svg-icons'
import { defineComponents } from './defineComponents'
import { registerStyles } from './registerStyles'

/** The standard server-rendered components and their shared CSS. */
export function componentsPlugin() {
return {
name: 'purestack:components',
defaults: true,
components(): Record<string, object> {
registerStyles()
registerSemanticToneUtilityStyles()
return defineComponents(getSvgIcon)
},
}
}
16 changes: 16 additions & 0 deletions packages/ts-html/src/tsNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ import { describe, expect, it } from 'vitest'
import { h } from './tsNode'

describe('TSNode', () => {
it('inserts before the matching stylesheet while leaving other links alone', () => {
const head = h('head').push(
h('link').attr({ rel: 'icon', href: '/favicon.svg' }),
h('').push(h('link').attr({ rel: 'stylesheet', href: '/site.css' })),
)
expect(
head.insertBefore('link', h('script').raw('hint()'), {
rel: 'stylesheet',
}),
).toBe(true)
expect(head.toHtml()).toBe(
'<head><link rel="icon" href="/favicon.svg"/><script>hint()</script><link rel="stylesheet" href="/site.css"/></head>',
)
expect(head.insertBefore('meta', h('script').raw('missing()'))).toBe(false)
expect(head.toHtml()).not.toContain('missing()')
})
describe('toHtml', () => {
it('renders an empty tag', () => {
expect(h('div').toHtml()).toBe('<div></div>')
Expand Down
22 changes: 22 additions & 0 deletions packages/ts-html/src/tsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ export class TSNode<K extends HtmlTag> {
return this
}

/** Inserts before the first child matching a tag and attributes; returns whether it matched. */
insertBefore(
tag: HtmlTag,
node: TSNode<''>,
attributes: Attributes = {},
): boolean {
for (let i = 0; i < this.#children.length; i++) {
const child = this.#children[i]
if (
child.#tag === tag &&
Object.entries(attributes).every(
([key, value]) => child.#attributes[key] === value,
)
) {
this.#children.splice(i, 0, node)
return true
}
if (child.insertBefore(tag, node, attributes)) return true
}
return false
}

/**
* Selects the first matching tag in a depth-first traversal (including this node),
* replaces it using the provided callback, and returns this root.
Expand Down
15 changes: 15 additions & 0 deletions packages/ts-page-scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,18 @@ dialogs, search, and other page features.
The site generator uses these builders when it adds interactive components to
static HTML. The package also exports TypeScript types for the browser APIs
exposed by those scripts, such as `TsSsgTabsApi` and `TsSsgModalApi`.

`pageScriptsPlugin()` supplies a head template that adds page runtimes before
the selected page template runs. It is enabled by default by `@purestack/ts-ssg` and can
also be imported from this package:

```ts
import { pageScriptsPlugin } from '@purestack/ts-page-scripts'
```

It preserves site configuration, page layout gates, consent-aware analytics,
and the site's base path. Theme startup belongs to `@purestack/ts-theme`;
component-specific Tabs/Modal embeds remain managed by the renderer.

To omit the page runtimes, set `defaultPlugins: false` in `purestack.config.ts`
or build options and list the other plugins you need.
3 changes: 2 additions & 1 deletion packages/ts-page-scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"test": "vitest"
},
"dependencies": {
"@purestack/ts-common": "workspace:^"
"@purestack/ts-common": "workspace:^",
"@purestack/ts-html": "workspace:^"
}
}
1 change: 1 addition & 0 deletions packages/ts-page-scripts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { buildNavMenuScript } from './embed/navMenu.embed'
export { buildPagefindSearchScript } from './embed/pagefindSearch.embed'
export { buildPageTocScript } from './embed/pageToc.embed'
export { buildTabsScript } from './embed/tabs.embed'
export { pageScriptsPlugin } from './plugin'
export type {
TsSsgMenusApi,
TsSsgModalApi,
Expand Down
63 changes: 63 additions & 0 deletions packages/ts-page-scripts/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { isTocEnabled, type PageTemplateInput } from '@purestack/ts-common'
import { h } from '@purestack/ts-html'
import { buildAuthStateHintScript } from './buildAuthStateHintScript'
import { buildConsentScript } from './buildConsentScript'
import { buildGa4Script } from './buildGa4Script'
import { buildCodeCopyScript } from './embed/codeCopy.embed'
import { buildMenuRuntimeScript } from './embed/menuRuntime.embed'
import { buildNavMenuScript } from './embed/navMenu.embed'
import { buildPagefindSearchScript } from './embed/pagefindSearch.embed'
import { buildPageTocScript } from './embed/pageToc.embed'

/** The browser behaviors that accompany the default PureStack components. */
export function pageScriptsPlugin() {
return { name: 'purestack:page-scripts', head: renderScriptsHead }
}

function renderScriptsHead({
head,
site: config,
pageInfo,
outline,
}: PageTemplateInput) {
if (config.auth.enabled && config.auth.signedInStorageKey) {
const hint = buildAuthStateHintScript(config.auth.signedInStorageKey)
if (hint) {
const auth = h('script').raw(hint)
// The hint must run before stylesheet loading can delay other scripts.
if (
!head.insertBefore('link', auth, { rel: 'stylesheet' }) &&
!head.insertBefore('script', auth)
)
head.push(auth)
}
}
if (config.consent.enabled)
head.push(h('script').raw(buildConsentScript(config.consent)))
const ga4 = config.analytics.ga4
if (ga4.enabled && ga4.measurementId && !config.consent.enabled) {
const payload = buildGa4Script(ga4)
head.push(
h('script').attr({ async: 'true', src: payload.src }),
h('script').raw(payload.init),
)
}
head.push(h('script').raw(buildMenuRuntimeScript()))
if (pageInfo.frontmatter.layout.showNav !== false)
head.push(h('script').raw(buildNavMenuScript()))
head.push(h('script').raw(buildCodeCopyScript()))
if (config.pagefind.enabled) {
if (config.basePath)
head.push(
h('script').raw(
`window.tsSsgPagefindBasePath=${JSON.stringify(config.basePath)};`,
),
)
head.push(
h('script').attr({ type: 'module' }).raw(buildPagefindSearchScript()),
)
}
if (isTocEnabled(pageInfo.frontmatter, outline))
head.push(h('script').raw(buildPageTocScript()))
return head
}
6 changes: 5 additions & 1 deletion packages/ts-render/src/renderApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { componentRegistry } from './componentRegistry'
export interface RenderAppOptions<TContext extends PageContext> {
/** Components for this render, on top of the registered ones. */
components: unknown
/** Whether this render also inherits the shared component registry. */
inheritComponents?: boolean
context: TContext
/**
* Runs on the rendered document, before it becomes HTML. It may be async:
Expand Down Expand Up @@ -48,7 +50,9 @@ export const renderApp = <TContext extends PageContext>(
createApp(
{
components: {
...componentRegistry.getAll(),
...(options.inheritComponents === false
? {}
: componentRegistry.getAll()),
...(isPlainObject(options.components) ? options.components : {}),
},
pageContext,
Expand Down
27 changes: 27 additions & 0 deletions packages/ts-ssg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,33 @@ order, inherit into child folders, and skip external URLs.

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/extending/plugins/) walks through each part.

Three plugins provide the standard site features and are enabled automatically for builds, publishing, and the development server:

| Package | Factory | Features |
| --- | --- | --- |
| `@purestack/ts-components` | `componentsPlugin()` | Standard Regor components, SVG icons, and component styles |
| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, and theme switching |
| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, code copying, search UI, and table of contents runtimes |

These factories and `createDefaultPlugins()` are also exported from `@purestack/ts-ssg` and `purestack`. The stylesheet provider initializes before component factories. Components and templates resolve before every plugin's `onConfigResolved` hook runs in plugin order. Names do not change that order. Components are scoped to each build; config hooks can override them through `context.components`.

Plugins can supply a `head` template, with the same input as a page template and a `TSNode<'head'>` result. Head templates compose in plugin order before the page template, so page templates can inspect or change the complete head. The lifecycle hook set is unchanged.

The theme plugin supplies `styles(config)`, which returns the `SiteStyles` writer used for full builds and lazy development assets. Only one plugin can supply it. Component and template contributions marked `defaults: true` may be overridden by ordinary contributions; this flag controls merge precedence and never schedules hooks or factories. Conflicts within either group remain errors.

To choose the integrations explicitly, set `defaultPlugins: false` in `purestack.config.ts` (or `options.defaultPlugins` programmatically):

```ts
import { componentsPlugin, defineConfig, themePlugin } from '@purestack/ts-ssg'

export default defineConfig({
defaultPlugins: false,
plugins: [themePlugin(), componentsPlugin()],
})
```

This keeps the standard layouts and components while omitting the page runtimes. Theme switching stays with the theme plugin, and component-specific Tabs/Modal embeds still follow each page's `embed` settings. Without the theme plugin, provide templates through another plugin. Extension templates and components can still override the standard ones. Explicitly listing a standard plugin while defaults are enabled replaces that plugin by name without adding a second instance; it retains its initialization position. Duplicate names within the supplied plugin list are errors.

A site lists its plugins in `purestack.config.ts`, next to `siteConfig.json`, and the CLI loads it:

```ts
Expand Down
1 change: 1 addition & 0 deletions packages/ts-ssg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@purestack/ts-render": "workspace:^",
"@purestack/ts-style": "workspace:^",
"@purestack/ts-svg-icons": "workspace:^",
"@purestack/ts-theme": "workspace:^",
"@purestack/ts-util": "workspace:^",
"@purestack/ts-util-node": "workspace:^",
"esbuild": "^0.28.0",
Expand Down
Loading
Loading