diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index d91bb0f6..b9402171 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -28,10 +28,12 @@ const contentDir = path.join(rootDir, 'content') const outDir = path.join(rootDir, 'dist', 'site') await buildSite({ - rootDir, - contentDir, - outDir, - siteTitle: 'My Docs', + siteConfig: { + rootDir, + contentDir, + outDir, + siteTitle: 'My Docs', + }, }) ``` @@ -42,9 +44,13 @@ import path from 'node:path' import { startDevServer } from '@purestack/ts-ssg' await startDevServer({ - rootDir: process.cwd(), - contentDir: path.join(process.cwd(), 'content'), - outDir: path.join(process.cwd(), 'dist', 'site'), + build: { + siteConfig: { + rootDir: process.cwd(), + contentDir: path.join(process.cwd(), 'content'), + outDir: path.join(process.cwd(), 'dist', 'site'), + }, + }, host: '127.0.0.1', port: 4173, }) @@ -306,57 +312,42 @@ Built-in templates: - `doc` (default) - `splash` -Provide custom templates via `buildSite({ templates })`: +Provide custom templates through `options.templates` and select one with the page's `template` frontmatter: ```ts import { h, type TSNode } from '@purestack/ts-html' -import type { PageTemplateMap } from '@purestack/ts-ssg' +import { buildSite, type PageTemplateMap } from '@purestack/ts-ssg' const templates: PageTemplateMap = { - product: ({ head, bodyHtml }) => + product: ({ head, bodyHtml, headerHtml, footerHtml }) => h('html').push( head, - h('body').push(h('main').attr({ class: 'product' }).raw(bodyHtml)), + h('body').push( + h('').raw(headerHtml ?? ''), + h('main').attr({ class: 'product' }).raw(bodyHtml), + h('').raw(footerHtml ?? ''), + ), ) as TSNode<'html'>, } + +await buildSite({ siteConfig: { contentDir: './content' }, options: { templates } }) ``` +A template receives a `PageTemplateInput`: the prepared `head`, the compiled `bodyHtml`, the page's nearest `headerHtml` and `footerHtml`, `site` config, `navigation`, `outline`, and `pageInfo`, whose `frontmatter` keeps any custom fields the page defines. + ## Components and Regor MDX -Built-in component sets are initialized automatically each build: - -- alert: `alertBox` -- card grid: `card`, `cardGrid` -- consent: `consent` -- contact: `contactForm` -- expandable panel: `expandablePanel` -- panel: `panel` -- footer: `siteFooter` -- top bar: `topBar` -- logo: `siteLogo` -- navigation: `navMenu`, `navList`, `navItem` -- page links: `pageLinks` -- page toc: `pageToc` -- pricing: `pricingTable`, `pricingPlan`, `pricingFeature` -- search: `searchBox` -- tabs: `tabs`, `tabPane` -- theme toggle: `themeToggle` - -Important rendering constraint: Regor components are rendered statically. Component state/events are not runtime-hydrated. +Every component in `@purestack/ts-components` is registered for each build, so content can use them directly. The [component reference](https://purestack.studio/components/) documents each one. -## Theming +Register your own Regor components through `options.components`, or assign `context.components` in the `onConfigResolved` hook. -Built-in skins export: +Components render to static HTML at build time. For behavior in the browser, load a page script with `PageScript` or mount a browser-side Regor app with `RegorApp`. -- `standard` +## Theming -Theme utilities: +The built-in skin is `standard`. Select a skin with `style.theme.skin` in `siteConfig.json`. -- `builtInSkins` -- `themes.resolve(...)` -- `themes.setOptions(...)` -- `themes.getOptions()` -- `styleBuilder` +Skins come from `@purestack/ts-style`: `themeSkins` holds the registered skins, and `registerSkin(name, skin)` adds your own before the build starts. See the [Themes guide](https://purestack.studio/guides/themes/) for creating one. `style.themes` controls generated files: @@ -370,27 +361,45 @@ Theme utilities: - Regor MDX (`.mdx`, `.rmdx`): `remark-parse` + `remark-gfm` with Regor component markup preservation - HTML output via HAST + rehype - H2/H3 outline extraction for page TOC -- Optional Shiki highlighting +- Code highlighting with highlight.js or Shiki -`BuildInput.mdx` options: +`siteConfig.mdx` options: -- `highlighter`: custom highlighter (`codeToHtml`) -- `themes`: `{ light, dark }` for Shiki -- `langs`: language list for Shiki +- `highlighter`: `"highlightjs"` (default) or `"shiki"` - `disableHighlighter`: skip highlighting +- `compileMdAsMdx`: compile `.md` files as Regor MDX (default `true`); set `false` to keep `.md` as plain Markdown ## Build Hooks -Hook into build lifecycle with `BuildHooks`: +Pass `BuildHooks` through `options.hooks` to `buildSite` or `startDevServer`. Hooks, templates, and components need the programmatic API; the CLI reads only `siteConfig.json`. + +```ts +import { type BuildHooks, startDevServer } from '@purestack/ts-ssg' -- `onConfigResolved` -- `onContentDiscovered` -- `onNavigationBuilt` -- `onPageStart` -- `onPageRendered` -- `onPageWritten` -- `onStylesWritten` -- `onBuildComplete` +const hooks: BuildHooks = { + onPageRendered(context, page) { + page.html = page.html.replace('', '') + }, +} + +await startDevServer({ build: { siteConfig: { contentDir: './content' }, options: { hooks } } }) +``` + +Every page render, in a full build or a dev server re-render, runs the page hooks: + +- `onPageStart(context, file)`: before the page renders. +- `onPageRendered(context, page)`: after it renders; changes to `page.html` are written. +- `onPageWritten(context, page)`: after the HTML file is written. + +Each full build also runs, in order: + +- `onConfigResolved(context)`: before any output; register components on `context.components` here. +- `onContentDiscovered(context, files)`: after content discovery, before pages render. +- `onNavigationBuilt(context, navigation)` +- `onStylesWritten(context, result)` +- `onBuildComplete(context, result)` + +The dev server runs a full build when it starts and when `siteConfig.json` changes; other edits re-render only the affected pages. Build option: @@ -551,27 +560,25 @@ Behavior: ## API Surface -Primary exports: +Functions: -- `buildSite` -- `startDevServer` -- `resolveConfig` (`resolveSiteConfig`) +- `buildSite`, `startDevServer`, `runCli` +- `resolveSiteConfig` - `normalizeFrontmatter`, `parseFrontmatterSource` - `buildNavigation`, `resolveNavigationConfig`, `resolvePageNavigation` -- `createMdxHighlighter` -- `componentRegistry` +- `createMdxHighlighter`, `DEFAULT_MDX_CODE_LANGS`, `DEFAULT_MDX_CODE_THEMES` - `defaultTemplates`, `resolvePageTemplate` -- `builtInSkins`, `themes`, `styleBuilder` -Useful types: +Types: + +- Build: `BuildInput`, `BuildOptions`, `BuildResult`, `BuildCountSummary`, `PublishOptions` +- Hooks: `BuildHooks`, `BuildContext`, `ResolvedContentFile`, `PageRenderResult`, `NavigationTree`, `WriteStylesResult` +- Templates: `PageTemplateMap`, `PageTemplate`, `PageTemplateInput`, `PageInfo`, `PageFrontmatter` +- Config and components: `SiteConfig`, `SiteConfigInput`, `TsSsgContext` +- Dev server: `DevServerInput`, `DevServerOptions`, `DevServerHandle` +- Highlighting: `MdxCodeHighlighter`, `MdxCodeLangs`, `MdxCodeThemes` -- `BuildInput`, `BuildResult`, `BuildHooks` -- `SiteConfig`, `PartialConfig` -- `DevServerInput`, `DevServerHandle` -- `PageFrontmatter` -- `NavigationConfig`, `NavigationTree`, `NavItem` -- `ThemeOptions`, `ThemePalette` -- `TsSsgContext` +Related packages: `@purestack/ts-html` builds template markup (`h`), `@purestack/ts-style` provides skins (`registerSkin`, `themeSkins`), and `@purestack/ts-components` provides the built-in components. ## Build Result Shape diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 5a9d483f..1f70bfe6 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -16,7 +16,6 @@ import { import { resolveOutPath } from '../out-path' import { type BuildContext, - buildPage, renderPageFromFile, resolveHeaderFooterHtml, resolvePagePartials, @@ -36,6 +35,7 @@ import type { IncrementalBuildResult } from './types' interface IncrementalContentStateInput { config: SiteConfig context: BuildContext + hooks: BuildHooks log: Logger onPageBuilt: (relPath: string, scriptEntrypoints: string[]) => void persistManifest: () => Promise @@ -86,32 +86,41 @@ export class IncrementalContentState { return count } - async renderAllPages(contentFiles: ResolvedContentFile[], hooks: BuildHooks) { + async renderAllPages(contentFiles: ResolvedContentFile[]) { let pages = 0 for (const file of contentFiles) { - try { - await hooks.onPageStart?.(this.input.context, file) - const page = await renderPageFromFile(this.input.context, file) - this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) - await hooks.onPageRendered?.(this.input.context, page) - await writePage(page, this.input.config.html.minify) - await hooks.onPageWritten?.(this.input.context, page) - pages += 1 - } catch (error) { - if (this.input.context.writeErrorPages !== true) { - throw error - } - this.input.log.error('page build failed', { - relPath: file.relPath, - error: error instanceof Error ? error.message : String(error), - }) - const page = await writePageError(this.input.context, file, error) - this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) - } + if (await this.writePageWithHooks(file)) pages += 1 } return pages } + /** + * Renders and writes one page inside its page hooks. Full builds and + * incremental renders both come through here, so `build` and `serve` + * produce the same output. Returns false when an error page was written. + */ + private async writePageWithHooks(file: ResolvedContentFile) { + const { context, hooks, config } = this.input + try { + await hooks.onPageStart?.(context, file) + const page = await renderPageFromFile(context, file) + this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) + await hooks.onPageRendered?.(context, page) + await writePage(page, config.html.minify) + await hooks.onPageWritten?.(context, page) + return true + } catch (error) { + if (context.writeErrorPages !== true) throw error + this.input.log.error('page build failed', { + relPath: file.relPath, + error: error instanceof Error ? error.message : String(error), + }) + const page = await writePageError(context, file, error) + this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) + return false + } + } + async renderIfDirtyByOutPath(outPath: string) { const relPath = this.contentIndex.getRelPathByOutPath(outPath) if (!relPath) return false @@ -218,8 +227,7 @@ export class IncrementalContentState { const contentFile = contentFiles.find((file) => file.relPath === relPath) ?? this.toResolvedContentFile(relPath, ext) - const page = await buildPage(this.input.context, contentFile) - this.input.onPageBuilt(contentFile.relPath, page.scriptEntrypoints) + await this.writePageWithHooks(contentFile) this.upsertContentManifestEntry(relPath, contentFile.ext, signature) result.changedPages += 1 this.dirtyPages.delete(relPath) @@ -228,8 +236,7 @@ export class IncrementalContentState { async rebuildSingleContent(input: RebuildSingleContentInput) { const { relPath, ext, signature, result } = input const contentFile = this.toResolvedContentFile(relPath, ext) - const page = await buildPage(this.input.context, contentFile) - this.input.onPageBuilt(contentFile.relPath, page.scriptEntrypoints) + await this.writePageWithHooks(contentFile) this.upsertContentManifestEntry(relPath, contentFile.ext, signature) result.changedPages += 1 } @@ -237,8 +244,7 @@ export class IncrementalContentState { async renderAndPersistRelPath(relPath: string, signature: FileSignature) { const ext = this.resolveContentExt(relPath) const contentFile = this.toResolvedContentFile(relPath, ext) - const page = await buildPage(this.input.context, contentFile) - this.input.onPageBuilt(contentFile.relPath, page.scriptEntrypoints) + await this.writePageWithHooks(contentFile) this.upsertContentManifestEntry(relPath, contentFile.ext, signature) this.dirtyPages.delete(relPath) await this.input.persistManifest() diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index ddcac405..66522229 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../../config/config' import { makeRepoTempDir } from '../../test/repoTempDir' import { createEmptyManifest, readManifest, writeManifest } from '../manifest' +import type { BuildHooks } from '../site' import { createIncrementalBuilder } from './index' async function withTempDir(worker: (dir: string) => Promise) { @@ -430,6 +431,7 @@ describe('incremental builder', () => { base: string, mode: 'auto' | 'hybrid' | 'none', files: Record, + hooks: BuildHooks = {}, ) { const contentDir = path.join(base, 'content') const outDir = path.join(base, 'out') @@ -449,7 +451,7 @@ describe('incremental builder', () => { outDir, navigation: { mode }, }, - options: { writeErrorPages: true }, + options: { writeErrorPages: true, hooks }, }) await builder.buildAll('initial') const outPath = (urlPath: string) => @@ -765,4 +767,91 @@ describe('incremental builder', () => { }) }) }) + + describe('page hooks', () => { + function recordPageHooks() { + const calls: string[] = [] + const record = (name: string, relPath: string) => { + calls.push(`${name}:${relPath.replaceAll('\\', '/')}`) + } + const hooks: BuildHooks = { + onPageStart: (_context, file) => record('start', file.relPath), + onPageRendered: (_context, page) => { + record('rendered', page.file.relPath) + page.html = page.html.replace('', '') + }, + onPageWritten: (_context, page) => record('written', page.file.relPath), + } + return { calls, hooks } + } + + it('runs the page hooks for every render, not only full builds', async () => { + await withTempDir(async (base) => { + const { calls, hooks } = recordPageHooks() + const site = await createSite( + base, + 'none', + { + 'header.mdx': '

Header v1

', + 'index.mdx': '# Home', + 'guides/a.mdx': '# A', + }, + hooks, + ) + expect(calls).toEqual( + expect.arrayContaining([ + 'start:index.mdx', + 'rendered:index.mdx', + 'written:index.mdx', + 'start:guides/a.mdx', + 'rendered:guides/a.mdx', + 'written:guides/a.mdx', + ]), + ) + + calls.length = 0 + await site.change('guides/a.mdx', '# A, edited') + expect(calls).toEqual([ + 'start:guides/a.mdx', + 'rendered:guides/a.mdx', + 'written:guides/a.mdx', + ]) + expect(await site.read('/guides/a/')).toContain('') + + calls.length = 0 + await site.change('header.mdx', '

Header v2

') + expect(calls).toEqual([]) + expect(await site.renderIfDirty('/')).toBe(true) + expect(calls).toEqual([ + 'start:index.mdx', + 'rendered:index.mdx', + 'written:index.mdx', + ]) + const html = await site.read('/') + expect(html).toContain('Header v2') + expect(html).toContain('') + }) + }) + + it('writes an error page when a page hook fails during a re-render', async () => { + await withTempDir(async (base) => { + let failing = false + const site = await createSite( + base, + 'none', + { 'index.mdx': '# Home' }, + { + onPageRendered: () => { + if (failing) throw new Error('Hook failed on purpose.') + }, + }, + ) + + failing = true + await site.change('index.mdx', '# Home, edited') + + expect(await site.read('/')).toContain('Hook failed on purpose.') + }) + }) + }) }) diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index abc1714c..dd6e7c3f 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -177,6 +177,7 @@ class IncrementalRuntime { this.contentState = new IncrementalContentState({ config: options.config, context: options.context, + hooks: options.hooks, log: options.log, onPageBuilt: (relPath, scriptEntrypoints) => this.scriptEntrypoints.setPageEntrypoints(relPath, scriptEntrypoints), @@ -230,10 +231,7 @@ class IncrementalRuntime { this.log.info('build started', { reason }) const prepared = await this.prepareBuild(hooks) this.scriptEntrypoints.clearPageEntrypoints() - const pages = await this.contentState.renderAllPages( - prepared.contentFiles, - hooks, - ) + const pages = await this.contentState.renderAllPages(prepared.contentFiles) const scriptAssetFiles = await this.scriptEntrypoints.syncState({ result: this.changeApplier.createResult(reason), persist: false, diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index a0771efc..9bd84ec0 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -74,15 +74,6 @@ export interface PageRenderResult { scriptEntrypoints: string[] } -export async function buildPage( - context: BuildContext, - file: ResolvedContentFile, -): Promise { - const page = await renderPageFromFile(context, file) - await writePage(page, context.config.html.minify) - return page -} - export async function writePage( page: PageRenderResult, minify: boolean, diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts index ecebba67..df3b0879 100644 --- a/packages/ts-ssg/src/index.ts +++ b/packages/ts-ssg/src/index.ts @@ -1,10 +1,24 @@ +export type { + PageFrontmatter, + PageInfo, + PageTemplate, + PageTemplateInput, + PageTemplateMap, + SiteConfig, + SiteConfigInput, + TsSsgContext, +} from '@purestack/ts-common' +export type { BuildContext, PageRenderResult } from './build/page' export { + type BuildCountSummary, type BuildHooks, type BuildInput, + type BuildOptions, type BuildResult, buildSite, type PublishOptions, } from './build/site' +export type { WriteStylesResult } from './build/styles' export { runCli } from './cli-runner' export { resolveSiteConfig } from './config/config' export { @@ -17,6 +31,7 @@ export { normalizeFrontmatter, parseFrontmatterSource, } from './frontmatter/frontmatter' +export type { ResolvedContentFile } from './i18n/content' export { createMdxHighlighter, DEFAULT_MDX_CODE_LANGS,