Skip to content

Commit 9da64d0

Browse files
committed
Improve plugin capabilities.
1 parent 94167a7 commit 9da64d0

40 files changed

Lines changed: 2059 additions & 467 deletions

‎frontend/purestack.studio/guides/extending/plugins.mdx‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ The stylesheet provider initializes before component factories, then all config
109109

110110
Metadata defaults compose through `headConfig({ site, pageInfo })` in plugin order, before explicit frontmatter `head` overrides and before head markup is rendered. It returns `BasicHeadConfig` or a promise. The SEO plugin uses this contribution for Open Graph, Twitter, canonical URLs, and translated alternate links; page templates receive the completed `headConfig`. Basic titles, descriptions, explicit head settings, and `index: false` enforcement remain in core.
111111

112-
Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin registers automatic GA4 consent services during `onConfigResolved`, before later config hooks and rendering. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted.
112+
Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin contributes automatic GA4 consent services during configuration resolution, before any factory or lifecycle hook runs. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted.
113113

114114
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:
115115

@@ -130,6 +130,51 @@ The highlighting plugin selects the configured backend. Configure native backend
130130

131131
The Pagefind plugin supplies a `search` provider. To use another engine, replace `purestack:pagefind` or disable the default set and install your own provider. The builder supplies excluded output paths and the rendered-page list for lazy requests, and manages invalidation on edits and deletions. Sitemap and robots generation remain built in.
132132

133+
## Configure a plugin
134+
135+
Plugins contribute configuration through `config.resolve({ config, input, file })` and `config.finalize({ config, input, file })`. Both are optional and synchronous. Each returns an object to merge into the config; do not mutate the inputs. All resolvers run first in plugin order, applying configuration overrides. Then all finalizers run in plugin order, deriving dependent settings from the completed overrides. Finalizers should derive settings rather than change resolver inputs. Both phases finish before highlighters, Markdown processors, styles, search providers, components, and templates initialize. The analytics plugin finalizes its automatic GA4 service here, so later site resolvers can enable analytics, change its ID or category, or provide a manual service. The existing `onConfigResolved` hook still runs after factories, so it can customize initialized components and templates. Publishing applies its output-directory and minification overrides after configuration composition.
136+
137+
Custom site settings belong under `extensions` in `siteConfig.json`. File values merge with programmatic input, which takes precedence. The standard JSON schema allows plugin namespaces here while continuing to reject unknown top-level fields. Each plugin owns validation, defaults, and normalization for its namespace. Other namespaces are preserved.
138+
139+
```json
140+
{ "extensions": { "brand": { "label": "Acme" } } }
141+
```
142+
143+
Use TypeScript module augmentation for typed settings:
144+
145+
```ts
146+
import { definePlugin } from 'purestack'
147+
148+
declare module '@purestack/ts-common' {
149+
interface SiteExtensions {
150+
brand?: { label: string }
151+
}
152+
}
153+
154+
export const brandPlugin = definePlugin({
155+
name: 'brand',
156+
config: {
157+
resolve({ config }) {
158+
const brand = config.extensions.brand
159+
if (brand !== undefined &&
160+
(brand === null || typeof brand !== 'object' ||
161+
typeof brand.label !== 'string')) {
162+
throw new Error('extensions.brand requires a string label.')
163+
}
164+
return { extensions: { brand: { label: brand?.label ?? 'Docs' } } }
165+
},
166+
},
167+
components(config) {
168+
// config.extensions.brand contains the normalized settings here.
169+
return {}
170+
},
171+
})
172+
```
173+
174+
Raw `resolveSiteConfig(input)` resolves core settings and extension input. CLI and programmatic builds additionally compose their selected plugins' configuration contributions. Configuration contributions are synchronous and may run whenever a project configuration is resolved; keep file writes and other side effects in lifecycle hooks.
175+
176+
Builder style state is isolated across asynchronous operations, including concurrent sites. Its owned theme options remain linked to `context.config.style.theme`, so in-place config-hook changes and `themes.setOptions` update page colors, CSS, and generated favicons consistently. Head, metadata, template, Markdown, provider, and lifecycle errors identify their owning plugin and preserve the original cause. Markdown wrappers preserve unified presets, duplicate-plugin option merging, and callback transforms.
177+
133178
## How the config loads
134179

135180
- **Where it lives.** The CLI looks for `purestack.config.ts` in the folder passed to `--content`. It must default-export `defineConfig({ plugins: [...] })`.
@@ -541,6 +586,7 @@ Every field except `name` is optional.
541586
| `name` | `string`, unique among the site's plugins |
542587
| `defaults` | `boolean`; makes component and template contributions overridable defaults without changing initialization order |
543588
| `skins` | Skins by name, each `{ create(): ThemeSkinPair }` |
589+
| `config` | `{ resolve?: SiteConfigResolver, finalize?: SiteConfigResolver }`; overrides, then derived settings, before factories |
544590
| `components` | `(config) => ({ [name]: component })` |
545591
| `templates` | Templates by name, each `(input) => TSNode<'html'>` |
546592
| `head` | `(input) => TSNode<'head'>`, or a promise; composes head markup before the page template |

‎packages/ts-common/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ export type {
6464
PreviewConfig,
6565
RobotsConfig,
6666
SiteConfig,
67+
SiteConfigContributions,
6768
SiteConfigInput,
69+
SiteConfigResolution,
70+
SiteConfigResolver,
71+
SiteExtensions,
6872
SiteHtmlConfig,
6973
SiteMdxConfig,
7074
SitemapConfig,

‎packages/ts-common/src/site-config-types.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { SiteLogoConfig } from './logo-types'
99
import type { NavigationConfig } from './navMenu-types'
1010

1111
export interface SiteConfig {
12+
/** Plugin-owned settings, normalized by their configuration contributions. */
13+
extensions: SiteExtensions
1214
rootDir: string
1315
contentDir: string
1416
outDir: string
@@ -33,6 +35,11 @@ export interface SiteConfig {
3335
mdx: SiteMdxConfig
3436
}
3537

38+
/** Plugins may augment this interface with optional, typed namespaces. */
39+
export interface SiteExtensions {
40+
[plugin: string]: unknown
41+
}
42+
3643
export interface DocLayoutConfig {
3744
/** CSS length reserved above desktop navigation and TOC sidebars. */
3845
sidebarTop: string
@@ -147,3 +154,20 @@ export type SiteStyleConfigInput = DeepPartial<
147154
export type SiteConfigInput = DeepPartial<Omit<SiteConfig, 'style'>> & {
148155
style?: SiteStyleConfigInput
149156
}
157+
158+
/** Inputs for a pure configuration contribution, before build factories run. */
159+
export interface SiteConfigResolution {
160+
config: SiteConfig
161+
input: SiteConfigInput
162+
file: SiteConfigInput
163+
}
164+
165+
export type SiteConfigResolver = (
166+
source: SiteConfigResolution,
167+
) => DeepPartial<SiteConfig>
168+
169+
/** All overrides resolve before any plugin derives dependent settings. */
170+
export interface SiteConfigContributions {
171+
resolve?: SiteConfigResolver
172+
finalize?: SiteConfigResolver
173+
}

‎packages/ts-common/src/site-styles.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface WriteStylesResult {
77
signature: string
88
}
99

10-
/** A plugin's stylesheet output, initialized before component factories. */
10+
/** A site's stylesheet output, initialized after config contributions and before component factories. */
1111
export interface SiteStyles {
1212
readonly revision: number
1313
links(cacheKey?: string): ThemeStylesheetLink[]

‎packages/ts-css/src/baseStyle.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,24 @@ import { hyphenizeCss } from './hyphenize'
66

77
export abstract class RootStyle {
88
static nextId = 1
9-
static revision = 0
9+
#revision = { value: 0 }
10+
/** Changes anywhere in this style tree, independent of other trees. */
11+
get revision() {
12+
return this.#revision.value
13+
}
14+
protected changed() {
15+
this.#revision.value += 1
16+
}
17+
protected shareRevision(child: RootStyle) {
18+
this.#revision.value += child.#revision.value
19+
child.#revision = this.#revision
20+
}
1021
id: number
1122
selector: string
1223
constructor(selector?: string) {
1324
this.id = BaseStyle.nextId++
1425
this.selector = selector ?? ''
15-
RootStyle.revision += 1
26+
this.changed()
1627
}
1728
abstract toCSS(): string
1829
}
@@ -23,15 +34,19 @@ export class BaseStyle<T extends RootStyle> extends RootStyle {
2334
#createT: (selector?: string) => T
2435
constructor(createT: (selector?: string) => T, selector?: string) {
2536
super(selector)
26-
this.#createT = createT
37+
this.#createT = (selector) => {
38+
const child = createT(selector)
39+
this.shareRevision(child)
40+
return child
41+
}
2742
}
2843

2944
set(key: string, value: string | number) {
3045
key = hyphenizeCss(key)
3146
value = autoVar(value)
3247
if (this.props.get(key) === value) return this
3348
this.props.set(key, value)
34-
RootStyle.revision += 1
49+
this.changed()
3550
return this
3651
}
3752

@@ -92,7 +107,7 @@ export class BaseStyle<T extends RootStyle> extends RootStyle {
92107
this.#asBaseStyle(css).props.forEach((value, key) => {
93108
if (this.props.get(key) === value) return
94109
this.props.set(key, value)
95-
RootStyle.revision += 1
110+
this.changed()
96111
})
97112
return this
98113
}

‎packages/ts-page-scripts/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ Three separate plugins from this package are also enabled by default:
2929
| --- | --- | --- |
3030
| `authStateHintPlugin()` | `purestack:auth-state-hint` | Apply the signed-in hint before stylesheets load, when auth is enabled |
3131
| `consentPlugin()` | `purestack:consent` | Initialize the consent browser runtime when consent is enabled |
32-
| `analyticsPlugin()` | `purestack:analytics` | Add direct GA4 scripts without consent, or register the consent-managed GA4 service during `onConfigResolved` |
32+
| `analyticsPlugin()` | `purestack:analytics` | Add direct GA4 scripts without consent, or contribute the consent-managed GA4 service during configuration resolution |
3333

3434
Each factory can be selected independently of `pageScriptsPlugin()`. Omitting
3535
analytics removes automatic GA4 service registration as well as direct scripts;
3636
explicit user-defined consent services are preserved. When consent is enabled,
3737
analytics never bypasses it, including when the consent runtime plugin is omitted.
3838
The raw `resolveSiteConfig` result contains only explicitly configured consent
39-
services; automatic GA4 registration occurs when the analytics plugin initializes,
40-
before later config hooks and page rendering.
39+
services; automatic GA4 registration runs in `config.finalize`, after all
40+
configuration overrides and before
41+
styles, components, highlighters, search providers, and config hooks initialize.
42+
Omitting the analytics plugin also omits this contribution.
4143

4244
For standalone `renderApp` calls, use `appendRuntimeScripts(document, runtimeEmbeds, frontmatter.embed)` from the renderer's `onRendered(document, runtimeEmbeds)` callback. The renderer itself has no knowledge of individual runtimes.

‎packages/ts-page-scripts/src/analyticsPlugin.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
ConsentService,
44
Ga4Config,
55
PageTemplateInput,
6-
SiteConfig,
6+
SiteConfigResolution,
77
} from '@purestack/ts-common'
88
import { h } from '@purestack/ts-html'
99
import { buildGa4Script } from './buildGa4Script'
@@ -12,14 +12,19 @@ import { buildGa4Script } from './buildGa4Script'
1212
export function analyticsPlugin() {
1313
return {
1414
name: 'purestack:analytics',
15-
hooks: {
16-
onConfigResolved({ config }: { config: SiteConfig }) {
17-
if (config.consent.enabled)
18-
config.consent.services = appendGa4ConsentService(
19-
config.consent.services,
20-
config.consent.categories,
21-
config.analytics.ga4,
22-
)
15+
config: {
16+
finalize({ config }: SiteConfigResolution) {
17+
return {
18+
consent: {
19+
services: config.consent.enabled
20+
? appendGa4ConsentService(
21+
config.consent.services,
22+
config.consent.categories,
23+
config.analytics.ga4,
24+
)
25+
: config.consent.services,
26+
},
27+
}
2328
},
2429
},
2530
head({ head, site }: PageTemplateInput) {

‎packages/ts-seo/src/preview.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@ import { describe, expect, it } from 'vitest'
22
import { resolvePreviewConfig } from './preview'
33

44
describe('social preview defaults', () => {
5+
it.each([
6+
[' https://example.com/// ', 'https://example.com/guide/'],
7+
[`https://example.com/${'/'.repeat(20_000)}`, 'https://example.com/guide/'],
8+
[
9+
`https://example.com/${'/'.repeat(20_000)}tail`,
10+
'https://example.com/guide/',
11+
],
12+
['/'.repeat(20_000), undefined],
13+
[' ', undefined],
14+
])(
15+
'normalizes base URLs with whitespace and repeated slashes (%#)',
16+
(baseUrl, expected) => {
17+
expect(
18+
resolvePreviewConfig({}, { baseUrl, urlPath: '/guide/' }).canonicalUrl,
19+
).toBe(expected)
20+
},
21+
)
22+
523
it('preserves title fallbacks, absolute URLs, image dimensions, and Twitter defaults', () => {
624
const head = resolvePreviewConfig(
725
{ title: 'Getting Started', description: 'Build your first thing.' },

‎packages/ts-seo/src/preview.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ function resolveAbsoluteUrl(
156156

157157
function normalizeBaseUrl(value: string | undefined): string | undefined {
158158
if (typeof value !== 'string') return undefined
159-
const trimmed = value.trim().replace(/\/+$/, '')
160-
return trimmed.length > 0 ? trimmed : undefined
159+
const trimmed = value.trim()
160+
let end = trimmed.length
161+
while (end > 0 && trimmed[end - 1] === '/') end--
162+
return end > 0 ? trimmed.slice(0, end) : undefined
161163
}
162164

163165
function pickString(...values: Array<string | undefined>): string | undefined {

‎packages/ts-ssg/README.md‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ Plugins can supply a `head` template, with the same input as a page template and
334334

335335
Metadata defaults compose through `headConfig({ site, pageInfo })` in plugin order, before explicit frontmatter `head` overrides and before head markup is rendered. It returns `BasicHeadConfig` or a promise. The SEO plugin uses this contribution for Open Graph, Twitter, canonical URLs, and translated alternate links; page templates receive the completed `headConfig`. Basic titles, descriptions, explicit head settings, and `index: false` enforcement remain in core.
336336

337-
Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin registers automatic GA4 consent services during `onConfigResolved`, before later config hooks and rendering. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted.
337+
Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin contributes automatic GA4 consent services during configuration resolution, before any factory or lifecycle hook runs. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted.
338338

339339
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, template, and default highlighter 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.
340340

@@ -407,6 +407,51 @@ Plugins apply in order:
407407

408408
Each plugin is checked when the build starts. An unknown field or hook name, such as `onPageRender`, a value of the wrong type, two plugins sharing a name, or two plugins defining the same skin, component, template, or generated page fails the build with the plugin's name. For a single hook, pass an inline plugin: `{ name: 'site', hooks: { … } }`.
409409

410+
### Plugin configuration
411+
412+
Plugins contribute configuration through `config.resolve({ config, input, file })` and `config.finalize({ config, input, file })`. Both are optional and synchronous. Each returns an object to merge into the config; do not mutate the inputs. All resolvers run first in plugin order, applying configuration overrides. Then all finalizers run in plugin order, deriving dependent settings from the completed overrides. Finalizers should derive settings rather than change resolver inputs. Both phases finish before highlighters, Markdown processors, styles, search providers, components, and templates initialize. The analytics plugin finalizes its automatic GA4 service here, so later site resolvers can enable analytics, change its ID or category, or provide a manual service. The existing `onConfigResolved` hook still runs after factories, so it can customize initialized components and templates. Publishing applies its output-directory and minification overrides after configuration composition.
413+
414+
Custom site settings belong under `extensions` in `siteConfig.json`. File values merge with programmatic input, which takes precedence. The standard JSON schema allows plugin namespaces here while continuing to reject unknown top-level fields. Each plugin owns validation, defaults, and normalization for its namespace. Other namespaces are preserved.
415+
416+
```json
417+
{ "extensions": { "brand": { "label": "Acme" } } }
418+
```
419+
420+
Use TypeScript module augmentation for typed settings:
421+
422+
```ts
423+
import { definePlugin } from 'purestack'
424+
425+
declare module '@purestack/ts-common' {
426+
interface SiteExtensions {
427+
brand?: { label: string }
428+
}
429+
}
430+
431+
export const brandPlugin = definePlugin({
432+
name: 'brand',
433+
config: {
434+
resolve({ config }) {
435+
const brand = config.extensions.brand
436+
if (brand !== undefined &&
437+
(brand === null || typeof brand !== 'object' ||
438+
typeof brand.label !== 'string')) {
439+
throw new Error('extensions.brand requires a string label.')
440+
}
441+
return { extensions: { brand: { label: brand?.label ?? 'Docs' } } }
442+
},
443+
},
444+
components(config) {
445+
// config.extensions.brand contains the normalized settings here.
446+
return {}
447+
},
448+
})
449+
```
450+
451+
Raw `resolveSiteConfig(input)` resolves core settings and extension input. CLI and programmatic builds additionally compose their selected plugins' configuration contributions. Configuration contributions are synchronous and may run whenever a project configuration is resolved; keep file writes and other side effects in lifecycle hooks.
452+
453+
Builder style state is isolated across asynchronous operations, including concurrent sites. Its owned theme options remain linked to `context.config.style.theme`, so in-place config-hook changes and `themes.setOptions` update page colors, CSS, and generated favicons consistently. Head, metadata, template, Markdown, provider, and lifecycle errors identify their owning plugin and preserve the original cause. Markdown wrappers preserve unified presets, duplicate-plugin option merging, and callback transforms.
454+
410455
### Code highlighting
411456

412457
Use `markdown.resolveHighlighter(lang)` to select a code-to-HTML callback for a language:

0 commit comments

Comments
 (0)