Skip to content

Commit 94167a7

Browse files
committed
Markdown table presentation, Consent, analytics, and auth-state hints, ts-seo plugin
1 parent 581ff28 commit 94167a7

40 files changed

Lines changed: 1136 additions & 518 deletions

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,26 @@ The badge appears on the page. Change its `tone` in `plugins/site.ts` while the
9191

9292
## Standard plugins
9393

94-
PureStack includes five plugins automatically in `build`, `serve`, and `publish`:
94+
PureStack includes nine plugins automatically in `build`, `serve`, and `publish`:
9595

9696
| Package | Factory | Provides |
9797
| --- | --- | --- |
9898
| `@purestack/ts-components` | `componentsPlugin()` | Standard components, icons, and component styles |
99-
| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, favicons, and theme switching |
100-
| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, navigation, code copying, search, TOC, Tabs, and Modal runtimes |
99+
| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, favicons, theme switching, and Markdown table presentation |
100+
| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Menus, navigation, code copying, search, TOC, Tabs, and Modal runtimes |
101+
| `@purestack/ts-page-scripts` | `authStateHintPlugin()` | Early signed-in state hint |
102+
| `@purestack/ts-page-scripts` | `consentPlugin()` | Consent browser runtime |
103+
| `@purestack/ts-page-scripts` | `analyticsPlugin()` | Direct or consent-managed GA4 integration |
104+
| `@purestack/ts-seo` | `seoPlugin()` | Social previews, canonical URLs, and translated alternate links |
101105
| `@purestack/ts-highlight` | `highlightPlugin()` | Shiki or highlight.js code highlighting |
102106
| `@purestack/ts-pagefind` | `pagefindPlugin()` | Full-build and lazy development search indexing |
103107

104108
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.
105109

110+
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.
111+
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.
113+
106114
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:
107115

108116
```ts
@@ -114,7 +122,7 @@ export default defineConfig({
114122
})
115123
```
116124

117-
This keeps the layouts and components while omitting page runtimes, code highlighting, and search indexing. Theme switching and favicon generation belong to the theme plugin. Tabs and Modal scripts belong to the page scripts plugin, including automatic component embeds and explicit frontmatter settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need.
125+
This keeps the layouts and components while omitting automatic social metadata, page runtimes, code highlighting, and search indexing. Theme switching, favicon generation, and Markdown table wrappers belong to the theme plugin. Tabs and Modal scripts belong to the page scripts plugin, including automatic component embeds and explicit frontmatter settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need.
118126

119127
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`.
120128

@@ -210,6 +218,8 @@ Besides the prepared `head` and the page's `bodyHtml`, a template receives the p
210218

211219
## Transform Markdown
212220

221+
Ordinary `rehypePlugins` run before `rehypePostPlugins`, which apply final presentation before outline collection and code highlighting. The theme plugin uses this final stage for `div.table-scroll`, so tables introduced by custom transforms receive the same scrolling behavior as source tables. Both stages run on pages and shared Markdown partials.
222+
213223
`markdown` adds <a href="https://github.com/remarkjs/remark" target="_blank" rel="noopener">remark</a> plugins, which change the Markdown tree, and <a href="https://github.com/rehypejs/rehype" target="_blank" rel="noopener">rehype</a> plugins, which change the HTML tree. Every page, header, and footer goes through these steps:
214224

215225
1. PureStack reads the Markdown. Regor markup, such as `<Badge tone="success">`, stays raw HTML.
@@ -534,8 +544,9 @@ Every field except `name` is optional.
534544
| `components` | `(config) => ({ [name]: component })` |
535545
| `templates` | Templates by name, each `(input) => TSNode<'html'>` |
536546
| `head` | `(input) => TSNode<'head'>`, or a promise; composes head markup before the page template |
547+
| `headConfig` | `({ site, pageInfo }) => BasicHeadConfig`, or a promise; metadata defaults before explicit page overrides |
537548
| `styles` | `(config) => SiteStyles`; one stylesheet provider, initialized before component factories |
538-
| `markdown` | `{ remarkPlugins?: PluggableList, rehypePlugins?: PluggableList }` |
549+
| `markdown` | `PureStackMarkdown`: remark, rehype, final rehype transforms, and highlighter providers |
539550
| `pages` | `({ config, files }) => { path, source }[]`, or a promise of them; `source` is the text, or a function that returns it |
540551
| `hooks` | Any of the hooks above |
541552
| `devMiddleware` | `(request, response) => void`, or a promise |

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export type {
3939
PageNavigationLinks,
4040
} from './navMenu-types'
4141
export type {
42+
PageHeadConfigInput,
43+
PageHeadConfigTemplate,
4244
PageHeadTemplate,
4345
PageInfo,
4446
PageTemplate,

‎packages/ts-common/src/page-template-types.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@ export interface PageTranslationInfo {
2020
relPath: string
2121
}
2222

23-
export interface PageTemplateInput {
23+
export interface PageHeadConfigInput {
24+
site: SiteConfig
25+
pageInfo: PageInfo
26+
}
27+
28+
/** Supplies head defaults before explicit page frontmatter overrides. */
29+
export type PageHeadConfigTemplate = (
30+
input: PageHeadConfigInput,
31+
) => BasicHeadConfig | Promise<BasicHeadConfig>
32+
33+
export interface PageTemplateInput extends PageHeadConfigInput {
2434
head: TSNode<'head'>
2535
bodyHtml: string
2636
headConfig?: BasicHeadConfig
2737
styleLinks?: ThemeStylesheetLink[]
2838
templateName: string
29-
site: SiteConfig
3039
navigation?: PageNavigation
3140
outline?: PageOutlineItem[]
32-
pageInfo: PageInfo
3341
headerHtml?: string
3442
footerHtml?: string
3543
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,27 @@ also be imported from this package:
1616
import { pageScriptsPlugin } from '@purestack/ts-page-scripts'
1717
```
1818

19-
It preserves site configuration, page layout gates, consent-aware analytics,
19+
It preserves site configuration, page layout gates,
2020
and the site's base path. Theme startup belongs to `@purestack/ts-theme`;
2121
the plugin adds component-specific Tabs/Modal embeds through `onPageDocument`. Recorded component placement overrides frontmatter, and Tabs remains before Modal when both target the same parent.
2222

2323
To omit the page runtimes, set `defaultPlugins: false` in `purestack.config.ts`
2424
or build options and list the other plugins you need.
2525

26+
Three separate plugins from this package are also enabled by default:
27+
28+
| Factory | Plugin name | Responsibility |
29+
| --- | --- | --- |
30+
| `authStateHintPlugin()` | `purestack:auth-state-hint` | Apply the signed-in hint before stylesheets load, when auth is enabled |
31+
| `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` |
33+
34+
Each factory can be selected independently of `pageScriptsPlugin()`. Omitting
35+
analytics removes automatic GA4 service registration as well as direct scripts;
36+
explicit user-defined consent services are preserved. When consent is enabled,
37+
analytics never bypasses it, including when the consent runtime plugin is omitted.
38+
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.
41+
2642
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.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type {
2+
ConsentCategory,
3+
ConsentService,
4+
Ga4Config,
5+
PageTemplateInput,
6+
SiteConfig,
7+
} from '@purestack/ts-common'
8+
import { h } from '@purestack/ts-html'
9+
import { buildGa4Script } from './buildGa4Script'
10+
11+
/** Owns direct GA4 scripts and its optional consent-managed service. */
12+
export function analyticsPlugin() {
13+
return {
14+
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+
)
23+
},
24+
},
25+
head({ head, site }: PageTemplateInput) {
26+
const ga4 = site.analytics.ga4
27+
if (ga4.enabled && ga4.measurementId && !site.consent.enabled) {
28+
const payload = buildGa4Script(ga4)
29+
head.push(
30+
h('script').attr({ async: 'true', src: payload.src }),
31+
h('script').raw(payload.init),
32+
)
33+
}
34+
return head
35+
},
36+
}
37+
}
38+
39+
function appendGa4ConsentService(
40+
services: ConsentService[],
41+
categories: ConsentCategory[],
42+
ga4?: Ga4Config,
43+
) {
44+
if (!ga4?.enabled || !ga4.measurementId) return services
45+
const hasService = services.some((entry) => entry.id === ga4.serviceId)
46+
if (hasService) return services
47+
const hasCategory = categories.some(
48+
(entry) => entry.id === ga4.consentCategory,
49+
)
50+
if (!hasCategory) {
51+
throw new Error(
52+
`analytics.ga4.consentCategory "${ga4.consentCategory}" does not exist in consent.categories.`,
53+
)
54+
}
55+
return [
56+
...services,
57+
{
58+
id: ga4.serviceId,
59+
category: ga4.consentCategory,
60+
label: 'Google Analytics 4',
61+
ga4MeasurementId: ga4.measurementId,
62+
scripts: buildGa4ConsentScripts(ga4),
63+
},
64+
]
65+
}
66+
67+
function buildGa4ConsentScripts(config: Ga4Config) {
68+
const payload = buildGa4Script(config)
69+
return [{ src: payload.src, async: true }, { content: payload.init }]
70+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { PageTemplateInput } from '@purestack/ts-common'
2+
import { h } from '@purestack/ts-html'
3+
import { buildAuthStateHintScript } from './buildAuthStateHintScript'
4+
5+
export function authStateHintPlugin() {
6+
return {
7+
name: 'purestack:auth-state-hint',
8+
head({ head, site }: PageTemplateInput) {
9+
if (site.auth.enabled && site.auth.signedInStorageKey) {
10+
const hint = buildAuthStateHintScript(site.auth.signedInStorageKey)
11+
if (hint) {
12+
const script = h('script').raw(hint)
13+
if (
14+
!head.insertBefore('link', script, { rel: 'stylesheet' }) &&
15+
!head.insertBefore('script', script)
16+
)
17+
head.push(script)
18+
}
19+
}
20+
return head
21+
},
22+
}
23+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { PageTemplateInput } from '@purestack/ts-common'
2+
import { h } from '@purestack/ts-html'
3+
import { buildConsentScript } from './buildConsentScript'
4+
5+
export function consentPlugin() {
6+
return {
7+
name: 'purestack:consent',
8+
head({ head, site }: PageTemplateInput) {
9+
if (site.consent.enabled)
10+
head.push(h('script').raw(buildConsentScript(site.consent)))
11+
return head
12+
},
13+
}
14+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
export { analyticsPlugin } from './analyticsPlugin'
2+
export { authStateHintPlugin } from './authStateHintPlugin'
13
export { buildAuthStateHintScript } from './buildAuthStateHintScript'
24
export { buildConsentScript } from './buildConsentScript'
35
export { buildThemeSwitchScript } from './buildThemeSwitchScript'
6+
export { consentPlugin } from './consentPlugin'
47
export { buildCodeCopyScript } from './embed/codeCopy.embed'
58
export { buildMenuRuntimeScript } from './embed/menuRuntime.embed'
69
export { buildModalScript } from './embed/modal.embed'

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

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import type { PageFrontmatter, RuntimeEmbeds } from '@purestack/ts-common'
22
import { isTocEnabled, type PageTemplateInput } from '@purestack/ts-common'
33
import { h } from '@purestack/ts-html'
4-
import { buildAuthStateHintScript } from './buildAuthStateHintScript'
5-
import { buildConsentScript } from './buildConsentScript'
6-
import { buildGa4Script } from './buildGa4Script'
74
import { buildCodeCopyScript } from './embed/codeCopy.embed'
85
import { buildMenuRuntimeScript } from './embed/menuRuntime.embed'
96
import { buildNavMenuScript } from './embed/navMenu.embed'
@@ -41,28 +38,6 @@ function renderScriptsHead({
4138
pageInfo,
4239
outline,
4340
}: PageTemplateInput) {
44-
if (config.auth.enabled && config.auth.signedInStorageKey) {
45-
const hint = buildAuthStateHintScript(config.auth.signedInStorageKey)
46-
if (hint) {
47-
const auth = h('script').raw(hint)
48-
// The hint must run before stylesheet loading can delay other scripts.
49-
if (
50-
!head.insertBefore('link', auth, { rel: 'stylesheet' }) &&
51-
!head.insertBefore('script', auth)
52-
)
53-
head.push(auth)
54-
}
55-
}
56-
if (config.consent.enabled)
57-
head.push(h('script').raw(buildConsentScript(config.consent)))
58-
const ga4 = config.analytics.ga4
59-
if (ga4.enabled && ga4.measurementId && !config.consent.enabled) {
60-
const payload = buildGa4Script(ga4)
61-
head.push(
62-
h('script').attr({ async: 'true', src: payload.src }),
63-
h('script').raw(payload.init),
64-
)
65-
}
6641
head.push(h('script').raw(buildMenuRuntimeScript()))
6742
if (pageInfo.frontmatter.layout.showNav !== false)
6843
head.push(h('script').raw(buildNavMenuScript()))

‎packages/ts-page-scripts/src/runtimeScripts.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,21 @@ import { appendRuntimeScripts } from './runtimeScripts'
66

77
describe('appendRuntimeScripts', () => {
88
it('supports standalone rendering through the generic renderer callback', async () => {
9+
const context: Partial<PageContext> = { recordRuntimeEmbed: () => {} }
910
const output = await renderApp(
1011
'<html><head></head><body><p>Standalone</p></body></html>',
1112
{
1213
components: {},
13-
context: { recordRuntimeEmbed: () => {} } as PageContext,
14+
context: context as PageContext,
1415
onRendered: (document, runtimeEmbeds) =>
1516
appendRuntimeScripts(document, runtimeEmbeds, { tabs: 'body' }),
1617
},
1718
)
1819
expect(output).toContain('<p>Standalone</p>')
1920
expect(output).toContain('tsSsgTabs')
2021
})
21-
it('preserves script order and lets recorded placement override frontmatter', async () => {
22-
await runInDom('<html><head></head><body></body></html>', (document) => {
22+
it('preserves script order and lets recorded placement override frontmatter', () => {
23+
runInDom('<html><head></head><body></body></html>', (document) => {
2324
appendRuntimeScripts(
2425
document,
2526
new Map([
@@ -39,8 +40,8 @@ describe('appendRuntimeScripts', () => {
3940
})
4041
})
4142

42-
it('supports explicit frontmatter embeds without rendered components', async () => {
43-
await runInDom('<html><head></head><body></body></html>', (document) => {
43+
it('supports explicit frontmatter embeds without rendered components', () => {
44+
runInDom('<html><head></head><body></body></html>', (document) => {
4445
appendRuntimeScripts(document, new Map(), { tabs: 'body', modal: 'body' })
4546
const scripts = [...document.body.querySelectorAll('script')]
4647
expect(scripts).toHaveLength(2)

0 commit comments

Comments
 (0)