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
145 changes: 76 additions & 69 deletions packages/ts-ssg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
})
```

Expand All @@ -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,
})
Expand Down Expand Up @@ -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:

Expand All @@ -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('</body>', '<!-- PureStack --></body>')
},
}

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:

Expand Down Expand Up @@ -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

Expand Down
60 changes: 33 additions & 27 deletions packages/ts-ssg/src/build/incremental/content-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
import { resolveOutPath } from '../out-path'
import {
type BuildContext,
buildPage,
renderPageFromFile,
resolveHeaderFooterHtml,
resolvePagePartials,
Expand All @@ -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<void>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -228,17 +236,15 @@ 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
}

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()
Expand Down
Loading
Loading