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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ PureGate
purestack-setup
*.vsix
.codex/*
/.tmp
/.tmp*
/frontend/out/
1 change: 1 addition & 0 deletions packages/ts-ssg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ Tracked:
In dev/watch mode:

- file changes apply incrementally when safe,
- a page affected by a shared change, such as a header, footer, or navigation edit, renders again on its next request, before it is served,
- site config changes trigger full rebuild,
- lazy route render can happen on first request for missing HTML route,
- live reload is served over SSE (`/__ts-ssg/events`).
Expand Down
13 changes: 4 additions & 9 deletions packages/ts-ssg/src/build/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path'

import { disableLogger, getLogger, type Logger } from 'logpot'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { makeRepoTempDir } from '../test/repoTempDir'

import {
type CopyStaticAssetsResult,
Expand Down Expand Up @@ -133,9 +134,7 @@ describe('static assets', () => {
})

it('minifies compiled ts assets when requested', async () => {
const root = await fs.mkdtemp(
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
)
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
const outDir = path.join(root, 'dist')
try {
const entryPath = path.join(root, 'login.ts')
Expand Down Expand Up @@ -171,9 +170,7 @@ describe('static assets', () => {
})

it('bundles source-condition packages after stripping unused Regor template tags', async () => {
const root = await fs.mkdtemp(
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
)
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
const outDir = path.join(root, 'dist')
try {
await writeFile(
Expand Down Expand Up @@ -257,9 +254,7 @@ describe('static assets', () => {
})

it('removes stale cache-keyed script siblings after writing a new bundle', async () => {
const root = await fs.mkdtemp(
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
)
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
const outDir = path.join(root, 'dist')
try {
const entryPath = path.join(root, 'login.ts')
Expand Down
15 changes: 4 additions & 11 deletions packages/ts-ssg/src/build/incremental/change-applier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class IncrementalChangeApplier {
changedAssets: 0,
deletedPages: 0,
deletedAssets: 0,
markedPages: 0,
reason,
}
}
Expand Down Expand Up @@ -127,14 +128,7 @@ export class IncrementalChangeApplier {
}

if (this.input.config.navigation.mode !== 'none') {
const contentFiles =
await this.input.contentState.refreshNavigationAndMarkDirty()
await this.input.contentState.rebuildContentRelPaths(
contentFiles.map((file) => file.relPath),
state.result,
)
await this.input.persistManifest()
return
await this.input.contentState.refreshNavigation()
}

if (state.assetEntry) {
Expand All @@ -158,16 +152,15 @@ export class IncrementalChangeApplier {
signature: FileSignature | null,
) {
if (!signature) {
await this.input.contentState.refreshNavigationAndMarkDirty()
await this.input.contentState.refreshNavigation()
await this.input.contentState.removeContentEntryForDeletedSource(
relPath,
result,
)
return
}

const contentFiles =
await this.input.contentState.refreshNavigationAndMarkDirty()
const contentFiles = await this.input.contentState.refreshNavigation()
await this.input.contentState.rebuildNavigatedContent({
contentFiles,
relPath,
Expand Down
62 changes: 53 additions & 9 deletions packages/ts-ssg/src/build/incremental/content-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type BuildContext,
buildPage,
renderPageFromFile,
resolveHeaderFooterHtml,
resolvePagePartials,
writePage,
writePageError,
} from '../page'
Expand Down Expand Up @@ -57,6 +59,7 @@ interface RebuildSingleContentInput {

export class IncrementalContentState {
private readonly dirtyPages = new Set<string>()
private markedPages = 0
private readonly renderInFlight = new Map<string, Promise<boolean>>()
private readonly contentIndex: ManifestContentIndex

Expand All @@ -76,6 +79,13 @@ export class IncrementalContentState {
this.dirtyPages.clear()
}

/** Pages marked to render on their next request since the last call. */
takeMarkedPageCount() {
const count = this.markedPages
this.markedPages = 0
return count
}

async renderAllPages(contentFiles: ResolvedContentFile[], hooks: BuildHooks) {
let pages = 0
for (const file of contentFiles) {
Expand Down Expand Up @@ -148,17 +158,48 @@ export class IncrementalContentState {
if (filesChanged) this.markAllPagesDirty(contentRoutes.pages)
}

async refreshNavigationAndMarkDirty() {
/**
* Rebuilds navigation. Every page shows it, so all pages become dirty when
* it changes; an edit that leaves it alone touches no other page.
*/
async refreshNavigation() {
const { config, context } = this.input
const contentFiles = await this.refreshContent()
this.input.context.navigation = await buildNavigation(
this.input.config.contentDir,
const navigation = await buildNavigation(
config.contentDir,
contentFiles,
this.input.config.navigation,
config.navigation,
)
this.markAllPagesDirty(contentFiles)
if (JSON.stringify(navigation) !== JSON.stringify(context.navigation)) {
context.navigation = navigation
this.markAllPagesDirty(contentFiles)
}
return contentFiles
}

/**
* Compiles the header and footer partials again. Only pages whose nearest
* header or footer changed become dirty, so an edit in one folder leaves
* pages elsewhere alone.
*/
async refreshPartials() {
const { context } = this.input
const pages = context.contentRoutes.pages
const before = pages.map((page) => resolvePagePartials(context, page))
await resolveHeaderFooterHtml(context)
this.markPagesDirty(
pages
.filter((page, index) => {
const after = resolvePagePartials(context, page)
return (
after.headerHtml !== before[index].headerHtml ||
after.footerHtml !== before[index].footerHtml
)
})
.map((page) => page.relPath),
)
}

async removeContentEntryForDeletedSource(
relPath: string,
result: IncrementalBuildResult,
Expand Down Expand Up @@ -259,11 +300,14 @@ export class IncrementalContentState {
)
}

private markAllPagesDirty(contentFiles: ResolvedContentFile[]) {
private markAllPagesDirty(contentFiles: readonly ResolvedContentFile[]) {
this.dirtyPages.clear()
for (const file of contentFiles) {
this.dirtyPages.add(file.relPath)
}
this.markPagesDirty(contentFiles.map((file) => file.relPath))
}

private markPagesDirty(relPaths: readonly string[]) {
for (const relPath of relPaths) this.dirtyPages.add(relPath)
this.markedPages += relPaths.length
}

private async renderPageByRelPath(relPath: string, onlyIfDirty: boolean) {
Expand Down
60 changes: 24 additions & 36 deletions packages/ts-ssg/src/build/incremental/incremental.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,44 +52,32 @@ The output is deterministic with `generatedAt` being the only volatile field.

## `applyChange(filePath)`

Handles a single filesystem change event. It is structured as a fast path with
early exits:
Handles a single filesystem change event. The changed page renders right
away; other affected pages are marked dirty and render on their next request
(`renderIfDirtyByOutPath`). `IncrementalBuildResult.markedPages` counts them,
so the dev server can reload the browser.

1. **Ignore changes outside `contentDir`**: relative path starting with `..`.
2. **Config file change**: if `isSiteConfigFile` ➜ mark `fullRebuild = true`.
3. **Missing file (deletion)**:
- If a content entry exists and navigation is enabled ➜ rebuild navigation
for the affected subtree, update manifest, return.
- Otherwise delete the output(s), remove manifest entries, update counters.
4. **Content change**:
- If the file is content (or was previously treated as content):
- No signature change ➜ no-op.
- With navigation enabled ➜ rebuild navigation subtree.
- Otherwise rebuild the single page and update its manifest entry.
5. **Asset change**:
- If signature unchanged ➜ no-op.
- Otherwise copy static asset, update manifest entry.

The intent is to keep work proportional to the change while preserving
navigation correctness when navigation is derived from content.

## Navigation-sensitive rebuilds

When navigation is enabled, a single content change can affect:

- The changed page.
- Its ancestor folders (navigation nodes).
- Any siblings within the affected folder scope.

`rebuildNavigationForChange` therefore:

1. Re-discovers content and rebuilds the full navigation tree.
2. Computes affected folders up to `navigationConfig.maxDepth`.
3. Rebuilds all pages in those folders.
4. Deletes pages in those folders that no longer exist.

This favors correctness over minimal work whenever navigation structure is
content-derived.
2. **`siteConfig.json`** ➜ `fullRebuild = true`. Config shapes every output.
3. **Header or footer partial** (edit, add, or delete) ➜ compile the partials
again and mark only the pages whose nearest header or footer HTML changed.
4. **`_nav.json`** ➜ rebuild navigation; mark every page only if it changed.
5. **Content change**: no signature change ➜ no-op. Otherwise render the page,
refresh the content index and navigation, and mark pages as below.
6. **Content deletion**: remove the output and manifest entry, then refresh.
7. **Asset change**: copy the asset and update its manifest entry. A `.ts`
change rebuilds the script bundles that import it.

## What marks other pages

- **Navigation changed** ➜ every page, since every page shows navigation.
Navigation is compared as data, so an edit that leaves titles, order, and
structure alone marks nothing.
- **A page or asset was added or removed** ➜ every page, since any content URL
may now resolve differently or fail.
- **A header or footer changed** ➜ the pages that show it.
- **A script bundle's hashed name changed** ➜ the pages that load it, rendered
right away.

## Manifest assembly

Expand Down
Loading
Loading