From 5bebb7919f8f8dc447505c6b42435a31f4b6ffb8 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 20 Oct 2026 21:33:54 +0200 Subject: [PATCH] Add shared dependency bundler. --- .../guides/getting-started/site-config.mdx | 1 + package.json | 2 +- packages/purestack/package.json | 2 +- packages/purestack/src/index.ts | 2 +- packages/ts-common/package.json | 2 +- packages/ts-common/src/site-config-types.ts | 2 + packages/ts-components/package.json | 2 +- packages/ts-css/package.json | 2 +- packages/ts-highlight/package.json | 2 +- packages/ts-html/package.json | 2 +- packages/ts-images/package.json | 2 +- packages/ts-minidom/package.json | 2 +- packages/ts-page-scripts/package.json | 2 +- packages/ts-pagefind/package.json | 2 +- packages/ts-render/package.json | 2 +- packages/ts-seo/package.json | 2 +- packages/ts-ssg-vscode/package.json | 2 +- packages/ts-ssg/README.md | 12 + packages/ts-ssg/package.json | 2 +- packages/ts-ssg/schema/siteConfig.schema.json | 9 + packages/ts-ssg/src/build/assets.test.ts | 408 +++++++++++++++++- packages/ts-ssg/src/build/assets.ts | 48 ++- .../src/build/incremental/change-applier.ts | 8 +- .../src/build/incremental/incremental.test.ts | 154 +++++++ .../ts-ssg/src/build/incremental/index.ts | 7 +- .../build/incremental/script-entry-manager.ts | 24 +- packages/ts-ssg/src/build/shared-libraries.ts | 227 ++++++++++ packages/ts-ssg/src/config/config.test.ts | 51 +++ packages/ts-ssg/src/config/core-config.ts | 14 + packages/ts-ssg/src/config/head.ts | 2 +- packages/ts-ssg/src/dev/server.test.ts | 84 ++++ packages/ts-style/package.json | 2 +- packages/ts-svg-icons/package.json | 2 +- packages/ts-theme/package.json | 2 +- packages/ts-util-node/package.json | 2 +- packages/ts-util/package.json | 2 +- 36 files changed, 1056 insertions(+), 37 deletions(-) create mode 100644 packages/ts-ssg/src/build/shared-libraries.ts diff --git a/frontend/purestack.studio/guides/getting-started/site-config.mdx b/frontend/purestack.studio/guides/getting-started/site-config.mdx index 69b5070e..24d4de13 100644 --- a/frontend/purestack.studio/guides/getting-started/site-config.mdx +++ b/frontend/purestack.studio/guides/getting-started/site-config.mdx @@ -63,6 +63,7 @@ With the folder layout above, `index.mdx` renders at `/`, and `guides/getting-st | `basePath` | Public mount path, such as `/docs`, used in generated URLs and the dev server. It does not add a `docs` folder inside the output directory. | | `html.minify` | Minifies generated HTML during a normal build; defaults to `false`. | | `scripts.cacheBusting` | Adds a build cache key to emitted local TypeScript script filenames; defaults to `true`. | +| `scripts.sharedLibraries` | Package import specifiers shared across page scripts. Regor is always included. List subpaths explicitly; generated modules use content-hashed filenames under `assets/runtime/`. | For example, with `"outDir": "../dist/site"` in `content/siteConfig.json`, the build writes into `dist/site` beside `content`. `"basePath": "/docs"` makes the home page publicly available at `/docs/`, while its output file remains `dist/site/index.html`. Host that output at `/docs/`; changing the config alone cannot change a host's routing. diff --git a/package.json b/package.json index 5a2742e0..9da6bcc4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 9177e502..a3780c2d 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 1cb584d5..6e739fc6 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.18' +export const version: string = '1.1.19' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 89427e25..6ac05a57 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-common/src/site-config-types.ts b/packages/ts-common/src/site-config-types.ts index 4a3a38d0..777c0989 100644 --- a/packages/ts-common/src/site-config-types.ts +++ b/packages/ts-common/src/site-config-types.ts @@ -73,6 +73,8 @@ export interface AuthConfig { export interface SiteScriptsConfig { cacheBusting: boolean + /** Package import specifiers emitted as shared browser modules. Regor is always included. */ + sharedLibraries: string[] } export interface SiteStyleConfig { diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 95abe872..95ab2f27 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index f5215a3b..1c9e1000 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-highlight/package.json b/packages/ts-highlight/package.json index 299055c7..9482aa4b 100644 --- a/packages/ts-highlight/package.json +++ b/packages/ts-highlight/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-highlight", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-highlight.mjs", "types": "./dist/ts-highlight.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index e85b4201..0e5934b6 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-images/package.json b/packages/ts-images/package.json index 68513afa..77ab6ac5 100644 --- a/packages/ts-images/package.json +++ b/packages/ts-images/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-images", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-images.mjs", "types": "./dist/ts-images.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 21783db0..936ff89e 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 5bbafaba..933f98de 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-pagefind/package.json b/packages/ts-pagefind/package.json index 5c1366ab..01f14e29 100644 --- a/packages/ts-pagefind/package.json +++ b/packages/ts-pagefind/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-pagefind", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-pagefind.mjs", "types": "./dist/ts-pagefind.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index ab17f039..639d3a5d 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-seo/package.json b/packages/ts-seo/package.json index 28aa48de..4d37b1af 100644 --- a/packages/ts-seo/package.json +++ b/packages/ts-seo/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-seo", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-seo.mjs", "types": "./dist/ts-seo.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 82a95d6d..2cf0c178 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.18", + "version": "1.1.19", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 9b67a782..8ac5caec 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -627,6 +627,18 @@ Add your own Regor components with a plugin's `components` field. A component re 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`. +TypeScript page scripts share Regor automatically, including imports from their component dependencies. Add other browser ESM libraries through `scripts.sharedLibraries` in `siteConfig.json`: + +```json +{ + "scripts": { + "sharedLibraries": ["my-library", "my-library/extra"] + } +} +``` + +List exact package import specifiers, including any subpaths used by page scripts. Regor is always included. Shared entries build together so their common dependencies also share one module instance. Generated modules live under `assets/runtime/` with content-hashed filenames; their URLs stay the same when only a page script changes. Page scripts continue to build independently. + ## Theming The built-in skin is `standard`. Select a skin with `style.theme.skin` in `siteConfig.json`. diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index a52ec79d..5ac61142 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/schema/siteConfig.schema.json b/packages/ts-ssg/schema/siteConfig.schema.json index d6589515..8bcb5da5 100644 --- a/packages/ts-ssg/schema/siteConfig.schema.json +++ b/packages/ts-ssg/schema/siteConfig.schema.json @@ -521,6 +521,15 @@ "type": "boolean", "description": "When true, emitted local TypeScript script filenames include a build cache key so browsers fetch fresh bundles after rebuilds.", "default": true + }, + "sharedLibraries": { + "type": "array", + "description": "Package import specifiers emitted together as shared browser modules under assets/runtime. List subpath imports explicitly. Regor is always included.", + "items": { + "type": "string", + "pattern": "^(?:@[\\w~-]+/)?[\\w~-][\\w.~-]*(?:/[\\w~-][\\w.~-]*)*$" + }, + "default": ["regor"] } } }, diff --git a/packages/ts-ssg/src/build/assets.test.ts b/packages/ts-ssg/src/build/assets.test.ts index 944d7688..fede1d34 100644 --- a/packages/ts-ssg/src/build/assets.test.ts +++ b/packages/ts-ssg/src/build/assets.test.ts @@ -1,9 +1,12 @@ +import { execFile } from 'node:child_process' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' import { disableLogger, getLogger, type Logger } from 'logpot' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { makeRepoTempDir } from '../test/repoTempDir' import { @@ -12,6 +15,7 @@ import { copyStaticAssets, resolveStaticOutPath, } from './assets' +import { SharedLibraryCache } from './shared-libraries' async function writeFile(filePath: string, contents: string) { await fs.mkdir(path.dirname(filePath), { recursive: true }) @@ -164,6 +168,408 @@ describe('static assets', () => { expect(output).not.toContain('function greet') expect(output).not.toContain('\n ') expect(output).toContain('console.log') + await expect( + fs.stat(path.join(outDir, 'assets', 'runtime')), + ).rejects.toMatchObject({ + code: 'ENOENT', + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('shares reactive identity across independently built and rebuilt scripts', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-assets-') + const outDir = path.join(root, 'output with spaces') + try { + await writeFile( + path.join(root, 'first.ts'), + "import { sref } from 'regor'\nexport { sref }\nexport const value = sref(1)", + ) + await writeFile( + path.join(root, 'shared.ts'), + "export { sref, isRef } from 'regor'", + ) + await writeFile( + path.join(root, 'nested', 'second.ts'), + "export { sref, isRef } from '../shared'", + ) + const files = ['first.ts', 'nested/second.ts'].map((relPath) => ({ + absPath: path.join(root, relPath), + relPath, + ext: '.ts', + })) + const batch = await copyStaticAssets(root, outDir, { files }) + expect(batch.tsDependencyIndex['nested/second.ts']).toContain('shared.ts') + await writeFile(path.join(outDir, 'package.json'), '{"type":"module"}') + const firstPath = path.join(outDir, 'first.js') + const secondPath = path.join(outDir, 'nested', 'second.js') + const runtimeDir = path.join(outDir, 'assets', 'runtime') + const runtimeFiles = await fs.readdir(runtimeDir) + expect(runtimeFiles).toHaveLength(1) + const runtimePath = path.join(runtimeDir, runtimeFiles[0]) + const before = await fs.stat(runtimePath) + + const rebuilt = await copyStaticAsset(root, outDir, files[1], { + minifyScripts: true, + scriptCacheKey: 'rebuilt', + failOnError: true, + }) + expect(await fs.readdir(runtimeDir)).toEqual(runtimeFiles) + expect((await fs.stat(runtimePath)).mtimeMs).toBe(before.mtimeMs) + await expect(fs.stat(secondPath)).rejects.toMatchObject({ + code: 'ENOENT', + }) + for (const scriptPath of [firstPath, rebuilt.outPath]) { + const code = await fs.readFile(scriptPath, 'utf8') + expect(code).not.toContain('regor.runtime') + expect(code).toContain(runtimeFiles[0]) + } + + // A fresh process gives the emitted modules their own realm, just as a + // browser does, without reusing the test runner's Regor runtime. + await promisify(execFile)(process.execPath, [ + '--input-type=module', + '-e', + [ + "import assert from 'node:assert/strict'", + `const first = await import(${JSON.stringify(pathToFileURL(firstPath).href)})`, + `const second = await import(${JSON.stringify(pathToFileURL(rebuilt.outPath).href)})`, + 'assert.equal(first.sref, second.sref)', + 'assert.equal(second.isRef(first.value), true)', + ].join('\n'), + ]) + + // A clean build must restore the shared file. + await fs.rm(runtimePath) + await copyStaticAsset(root, outDir, files[0], { failOnError: true }) + await expect(fs.stat(runtimePath)).resolves.toBeTruthy() + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('reuses one library build across concurrent entries and invalidates only library changes', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-library-cache-') + const outDir = path.join(root, 'out') + try { + await writeFile( + path.join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { paths: { 'fixture-library': ['./shared.ts'] } }, + }), + ) + const libraryPath = path.join(root, 'shared.ts') + await writeFile(libraryPath, 'export const value = { count: 1 }') + const files = ['first.ts', 'nested/second.ts'].map((relPath) => ({ + absPath: path.join(root, relPath), + relPath, + ext: '.ts', + })) + for (const file of files) + await writeFile(file.absPath, "export { value } from 'fixture-library'") + const reserveOutput = vi.fn() + const cache = new SharedLibraryCache({ + contentDir: root, + outDir, + libraries: ['fixture-library'], + reserveOutput, + }) + const buildEntries = () => + Promise.all( + files.map((file) => + copyStaticAsset(root, outDir, file, { + sharedLibraryCache: cache, + failOnError: true, + }), + ), + ) + const first = await buildEntries() + const runtimeDir = path.join(outDir, 'assets', 'runtime') + const outputCount = (await fs.readdir(runtimeDir)).length + expect(reserveOutput).toHaveBeenCalledTimes(outputCount) + for (const result of first) + expect(result.dependencyRelPaths).toContain('shared.ts') + const before = await fs.readFile(first[0].outPath, 'utf8') + + cache.invalidate(files[0].absPath) + await buildEntries() + expect(reserveOutput).toHaveBeenCalledTimes(outputCount) + + await writeFile(libraryPath, 'export const value = { count: 2 }') + cache.invalidate(libraryPath) + await buildEntries() + expect(reserveOutput).toHaveBeenCalledTimes(outputCount * 2) + expect(await fs.readFile(first[0].outPath, 'utf8')).not.toBe(before) + + await writeFile( + path.join(root, 'replacement.ts'), + 'export const value = { count: 3 }', + ) + const configPath = path.join(root, 'tsconfig.json') + await writeFile( + configPath, + JSON.stringify({ + compilerOptions: { + paths: { 'fixture-library': ['./replacement.ts'] }, + }, + }), + ) + cache.invalidate(configPath) + const replaced = await buildEntries() + expect(reserveOutput).toHaveBeenCalledTimes(outputCount * 3) + for (const result of replaced) { + expect(result.dependencyRelPaths).toContain('replacement.ts') + expect(result.dependencyRelPaths).not.toContain('shared.ts') + } + + cache.invalidate() + // Clear only this fixture's generated runtime, as a clean build does. + expect(path.relative(root, runtimeDir)).toBe( + path.join('out', 'assets', 'runtime'), + ) + await fs.rm(runtimeDir, { recursive: true }) + await buildEntries() + expect(reserveOutput).toHaveBeenCalledTimes(outputCount * 4) + expect(await fs.readdir(runtimeDir)).toHaveLength(outputCount) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it.each(['truncated', 'same-size'])( + 'repairs %s shared-runtime output on the next build', + async (damage) => { + const root = await makeRepoTempDir('.tmp-ts-ssg-library-repair-') + const outDir = path.join(root, 'out') + try { + const file = { + absPath: path.join(root, 'entry.ts'), + relPath: 'entry.ts', + ext: '.ts', + } + await writeFile(file.absPath, "export { sref } from 'regor'") + const cache = new SharedLibraryCache({ contentDir: root, outDir }) + const options = { sharedLibraryCache: cache, failOnError: true } + await copyStaticAsset(root, outDir, file, options) + const runtimeDir = path.join(outDir, 'assets', 'runtime') + const runtimePath = path.join( + runtimeDir, + (await fs.readdir(runtimeDir))[0], + ) + const original = await fs.readFile(runtimePath) + await fs.writeFile( + runtimePath, + damage === 'truncated' + ? 'partial' + : Buffer.alloc(original.length, 'x'), + ) + cache.invalidate() + await copyStaticAsset(root, outDir, file, options) + expect(await fs.readFile(runtimePath)).toEqual(original) + expect( + (await fs.readdir(runtimeDir)).filter((name) => + name.endsWith('.tmp'), + ), + ).toEqual([]) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }, + ) + + it('preserves the previous file and retries when atomic runtime publication fails', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-library-publication-') + const outDir = path.join(root, 'out') + try { + const file = { + absPath: path.join(root, 'entry.ts'), + relPath: 'entry.ts', + ext: '.ts', + } + await writeFile(file.absPath, "export { sref } from 'regor'") + const cache = new SharedLibraryCache({ contentDir: root, outDir }) + const options = { sharedLibraryCache: cache, failOnError: true } + await copyStaticAsset(root, outDir, file, options) + const runtimeDir = path.join(outDir, 'assets', 'runtime') + const runtimePath = path.join( + runtimeDir, + (await fs.readdir(runtimeDir))[0], + ) + const original = await fs.readFile(runtimePath) + await fs.writeFile(runtimePath, 'partial') + cache.invalidate() + const rename = vi + .spyOn(fs, 'rename') + .mockRejectedValueOnce(new Error('Interrupted publication')) + try { + await expect( + copyStaticAsset(root, outDir, file, options), + ).rejects.toThrow('Interrupted publication') + } finally { + rename.mockRestore() + } + expect(await fs.readFile(runtimePath, 'utf8')).toBe('partial') + expect( + (await fs.readdir(runtimeDir)).filter((name) => name.endsWith('.tmp')), + ).toEqual([]) + await copyStaticAsset(root, outDir, file, options) + expect(await fs.readFile(runtimePath)).toEqual(original) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('retries a failed shared-library build after its source is fixed', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-library-retry-') + const outDir = path.join(root, 'out') + try { + await writeFile( + path.join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { paths: { 'fixture-library': ['./shared.ts'] } }, + }), + ) + await writeFile(path.join(root, 'shared.ts'), 'export const value = ;') + const file = { + absPath: path.join(root, 'entry.ts'), + relPath: 'entry.ts', + ext: '.ts', + } + await writeFile(file.absPath, "export { value } from 'fixture-library'") + const cache = new SharedLibraryCache({ + contentDir: root, + outDir, + libraries: ['fixture-library'], + }) + const options = { sharedLibraryCache: cache, failOnError: true } + await expect( + copyStaticAsset(root, outDir, file, options), + ).rejects.toThrow() + await writeFile(path.join(root, 'shared.ts'), 'export const value = 1') + await expect( + copyStaticAsset(root, outDir, file, options), + ).resolves.toMatchObject({ copied: true }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it('shares configured libraries, subpaths, and their dependencies as one graph', async () => { + const root = await makeRepoTempDir('.tmp-ts-ssg-libraries-') + const outDir = path.join(root, 'out') + try { + const stateDir = path.join(root, 'node_modules', '@fixture', 'state') + const wrapperDir = path.join(root, 'node_modules', '@fixture', 'wrapper') + await writeFile( + path.join(stateDir, 'package.json'), + JSON.stringify({ + name: '@fixture/state', + type: 'module', + exports: { '.': './index.js', './extra': './extra.js' }, + }), + ) + await writeFile( + path.join(stateDir, 'index.js'), + 'export const value = { count: 7 }; export default value', + ) + await writeFile( + path.join(stateDir, 'extra.js'), + "export { value } from './index.js'", + ) + await writeFile( + path.join(wrapperDir, 'package.json'), + JSON.stringify({ + name: '@fixture/wrapper', + type: 'module', + exports: './index.js', + }), + ) + await writeFile( + path.join(wrapperDir, 'index.js'), + "export { value } from '@fixture/state'", + ) + const nestedState = path.join( + wrapperDir, + 'node_modules', + '@fixture', + 'state', + ) + await writeFile( + path.join(nestedState, 'package.json'), + JSON.stringify({ + name: '@fixture/state', + type: 'module', + exports: './index.js', + }), + ) + await writeFile( + path.join(nestedState, 'index.js'), + 'export const value = { count: -1 }', + ) + const libraries = [ + '@fixture/state', + '@fixture/state/extra', + '@fixture/wrapper', + ] + const sources = [ + "export { default as value } from '@fixture/state'; export { value as wrapped } from '@fixture/wrapper'", + "export { value } from '@fixture/state/extra'", + ] + const built = [] + for (const [index, source] of sources.entries()) { + const relPath = `nested/entry-${index}.ts` + await writeFile(path.join(root, relPath), source) + built.push( + await copyStaticAsset( + root, + outDir, + { + absPath: path.join(root, relPath), + relPath, + ext: '.ts', + }, + { sharedLibraries: libraries, failOnError: true }, + ), + ) + } + expect(built[0].dependencyRelPaths).toContain( + 'node_modules/@fixture/state/index.js', + ) + await writeFile(path.join(outDir, 'package.json'), '{"type":"module"}') + await promisify(execFile)(process.execPath, [ + '--input-type=module', + '-e', + [ + "import assert from 'node:assert/strict'", + `const first = await import(${JSON.stringify(pathToFileURL(built[0].outPath).href)})`, + `const second = await import(${JSON.stringify(pathToFileURL(built[1].outPath).href)})`, + 'assert.equal(first.value, second.value)', + 'assert.equal(first.value, first.wrapped)', + 'assert.equal(first.value.count, 7)', + ].join('\n'), + ]) + const runtimeFiles = await fs.readdir( + path.join(outDir, 'assets', 'runtime'), + ) + expect(runtimeFiles).toContain('chunks') + expect(runtimeFiles.some((file) => file.startsWith('regor.'))).toBe(true) + await expect( + fs.stat(path.join(outDir, '_assets', 'runtime')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + + await expect( + copyStaticAsset( + root, + outDir, + { + absPath: path.join(root, 'nested/entry-1.ts'), + relPath: 'nested/entry-1.ts', + ext: '.ts', + }, + { sharedLibraries: ['@fixture/state'], failOnError: true }, + ), + ).rejects.toThrow('Add "@fixture/state/extra" to scripts.sharedLibraries') } finally { await fs.rm(root, { recursive: true, force: true }) } diff --git a/packages/ts-ssg/src/build/assets.ts b/packages/ts-ssg/src/build/assets.ts index 68b0db63..a1b18e83 100644 --- a/packages/ts-ssg/src/build/assets.ts +++ b/packages/ts-ssg/src/build/assets.ts @@ -2,10 +2,11 @@ import fs from 'node:fs/promises' import path from 'node:path' import { isTypeScriptAssetPath, toOutputAssetRelPath } from '@purestack/ts-util' import { ensureDir } from '@purestack/ts-util-node' -import { build as buildScript, type Metafile } from 'esbuild' +import { build as buildScript } from 'esbuild' import { getLogger } from 'logpot' import { discoverStaticAssets, type StaticAssetFile } from '../discover/content' import { regorTemplateTagsPlugin } from './regorTemplateTransform' +import { SharedLibraryCache } from './shared-libraries' export interface CopyStaticAssetsResult { assets: number @@ -18,6 +19,9 @@ export interface StaticAssetBuildOptions { failOnError?: boolean scriptCacheKey?: string getScriptCacheKey?: (relPath: string) => string | undefined + reserveOutput?: (outPath: string) => void + sharedLibraries?: readonly string[] + sharedLibraryCache?: SharedLibraryCache } export interface CopyStaticAssetsOptions extends StaticAssetBuildOptions { @@ -41,14 +45,26 @@ export async function copyStaticAssets( ? [...options.files] : await discoverStaticAssets(contentDir) const tsDependencyIndex: Record = {} + const scriptOptions = { + ...options, + sharedLibraryCache: + options.sharedLibraryCache ?? + new SharedLibraryCache({ + contentDir, + outDir, + libraries: options.sharedLibraries, + reserveOutput: options.reserveOutput, + }), + } for (const asset of assets) { const outPath = resolveStaticOutPath(outDir, asset, options) await ensureDir(outPath) const dependencyRelPaths = await writeStaticAsset( contentDir, + outDir, asset, outPath, - options, + scriptOptions, ) await removeStaleScriptOutputFiles(asset, outPath, options) if (dependencyRelPaths.length > 0) { @@ -71,6 +87,7 @@ export async function copyStaticAsset( await ensureDir(outPath) const dependencyRelPaths = await writeStaticAsset( contentDir, + outDir, asset, outPath, options, @@ -123,11 +140,21 @@ function resolveScriptCacheKey( async function writeStaticAsset( contentDir: string, + outDir: string, asset: StaticAssetFile, outPath: string, options: StaticAssetBuildOptions, ) { if (isTypeScriptAssetPath(asset.relPath)) { + let sharedDependencyPaths: string[] = [] + const sharedLibraries = + options.sharedLibraryCache ?? + new SharedLibraryCache({ + contentDir, + outDir, + libraries: options.sharedLibraries, + reserveOutput: options.reserveOutput, + }) const buildResult = await buildScript({ entryPoints: [asset.absPath], outfile: outPath, @@ -140,9 +167,17 @@ async function writeStaticAsset( target: 'esnext', logLevel: 'silent', metafile: true, - plugins: [regorTemplateTagsPlugin()], + plugins: [ + regorTemplateTagsPlugin(), + sharedLibraries.plugin(outPath, (inputPaths) => { + sharedDependencyPaths = inputPaths + }), + ], }) - return collectDependencyRelPaths(buildResult.metafile, contentDir) + return collectDependencyRelPaths( + [...Object.keys(buildResult.metafile.inputs), ...sharedDependencyPaths], + contentDir, + ) } await fs.copyFile(asset.absPath, outPath) return [] @@ -185,12 +220,11 @@ function isStaleScriptOutputFile(fileName: string, scriptName: string) { } function collectDependencyRelPaths( - metafile: Metafile | undefined, + inputPaths: readonly string[], root: string, ) { const deps = new Set() - const inputs = metafile?.inputs ?? {} - for (const inputPath of Object.keys(inputs)) { + for (const inputPath of inputPaths) { const absPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(inputPath) diff --git a/packages/ts-ssg/src/build/incremental/change-applier.ts b/packages/ts-ssg/src/build/incremental/change-applier.ts index 1a10dfd4..60326b13 100644 --- a/packages/ts-ssg/src/build/incremental/change-applier.ts +++ b/packages/ts-ssg/src/build/incremental/change-applier.ts @@ -216,7 +216,7 @@ export class IncrementalChangeApplier { await this.input.contentState.refreshGeneratedPages() result.deletedAssets += 1 } - if (ext === '.ts') await this.handleScriptAssetChange(relPath, result) + if (ext === '.ts') await this.applyScriptDependencyChange(relPath, result) if (contentEntry || assetEntry) { await this.input.persistManifest() } @@ -271,7 +271,7 @@ export class IncrementalChangeApplier { return if (ext === '.ts') { - await this.handleScriptAssetChange(relPath, result) + await this.applyScriptDependencyChange(relPath, result) await this.input.persistManifest() return } @@ -298,12 +298,12 @@ export class IncrementalChangeApplier { await this.input.persistManifest() } - private async handleScriptAssetChange( + async applyScriptDependencyChange( relPath: string, result: IncrementalBuildResult, ) { const impactedEntries = - this.input.scriptEntrypoints.resolveImpactedEntryRelPaths(relPath) + this.input.scriptEntrypoints.invalidateDependency(relPath) if (impactedEntries.size === 0) return if (this.input.renderOnRequest()) { diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index c25d4b2a..cf39e27f 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -571,6 +571,160 @@ describe('incremental builder', () => { }) }) + it.each( + [false, true].flatMap((onDemand) => + ['ts', 'js', 'mjs', 'json'].map((ext) => ({ onDemand, ext })), + ), + )( + 'rebuilds every importing entry when shared $ext library source changes (on demand $onDemand)', + async ({ onDemand, ext }) => { + await withTempDir(async (base) => { + const source = (message: string) => + ext === 'json' + ? JSON.stringify({ state: { message } }) + : `export const state = { message: '${message}' }` + const sharedPath = `shared.${ext}` + const site = await createSite(base, 'none', { + 'hosts.mdx': '', + 'account.mdx': '', + 'hosts.ts': "export { state } from 'fixture-library'", + 'account.ts': "export { state } from 'fixture-library'", + [sharedPath]: source('before'), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + paths: { 'fixture-library': [`./${sharedPath}`] }, + }, + }), + 'siteConfig.json': JSON.stringify({ + scripts: { sharedLibraries: ['fixture-library'] }, + mdx: { disableHighlighter: true }, + }), + }) + const outDir = path.join(base, 'out') + const beforeHosts = await readScriptBundle(outDir) + const beforeAccount = await readScriptBundle( + outDir, + 'account/index.html', + ) + if (onDemand) await site.builder.prepareForRequests() + const result = await site.change(sharedPath, source('after')) + expect(result.changedAssets).toBeGreaterThanOrEqual(onDemand ? 0 : 2) + expect(result.changedPages).toBe(onDemand ? 0 : 2) + if (onDemand) { + await site.builder.renderByUrlPath('/hosts/') + await site.builder.renderByUrlPath('/account/') + await site.builder.preparePageAssets() + } + const afterHosts = await readScriptBundle(outDir) + const afterAccount = await readScriptBundle( + outDir, + 'account/index.html', + ) + expect(afterHosts.content).not.toBe(beforeHosts.content) + expect(afterAccount.content).not.toBe(beforeAccount.content) + const runtimeImport = + /from\s*["']([^"']+fixture-library\.[A-Z0-9]+\.js)["']/.exec( + afterHosts.content, + ) + expect(runtimeImport).toBeTruthy() + const runtimePath = path.resolve( + path.dirname(afterHosts.path), + runtimeImport?.[1] ?? '', + ) + expect(await fs.readFile(runtimePath, 'utf8')).toContain('after') + expect(afterAccount.content).toContain(path.basename(runtimePath)) + }) + }, + ) + + it.each(['tsconfig.json', 'tsconfig.paths.json'])( + 'requests a full rebuild when shared-library aliases change in %s', + async (configFile) => { + await withTempDir(async (base) => { + const aliases = (source: string) => + JSON.stringify({ + compilerOptions: { paths: { 'fixture-library': [`./${source}`] } }, + }) + const site = await createSite(base, 'none', { + 'hosts.mdx': '', + 'hosts.ts': "export { state } from 'fixture-library'", + 'before.ts': "export const state = 'before'", + 'after.ts': "export const state = 'after'", + 'tsconfig.json': + configFile === 'tsconfig.json' + ? aliases('before.ts') + : JSON.stringify({ extends: `./${configFile}` }), + ...(configFile === 'tsconfig.json' + ? {} + : { [configFile]: aliases('before.ts') }), + 'siteConfig.json': JSON.stringify({ + scripts: { sharedLibraries: ['fixture-library'] }, + mdx: { disableHighlighter: true }, + }), + }) + const outDir = path.join(base, 'out') + const before = await readScriptBundle(outDir) + const change = await site.change(configFile, aliases('after.ts')) + expect(change.fullRebuild).toBe(true) + await site.builder.buildAll('resolution config changed') + const after = await readScriptBundle(outDir) + expect(after.content).not.toBe(before.content) + const imported = + /from\s*["']([^"']+fixture-library\.[A-Z0-9]+\.js)["']/.exec( + after.content, + ) + expect(imported).toBeTruthy() + const runtime = path.resolve( + path.dirname(after.path), + imported?.[1] ?? '', + ) + expect(await fs.readFile(runtime, 'utf8')).toContain('after') + }) + }, + ) + + it('requests a full rebuild when a shared package changes its exports', async () => { + await withTempDir(async (base) => { + const manifest = (source: string) => + JSON.stringify({ + name: 'fixture-library', + type: 'module', + exports: `./${source}.js`, + }) + const packagePath = 'node_modules/fixture-library/package.json' + const site = await createSite(base, 'none', { + 'hosts.mdx': '', + 'hosts.ts': "export { state } from 'fixture-library'", + [packagePath]: manifest('before'), + 'node_modules/fixture-library/before.js': + "export const state = 'before'", + 'node_modules/fixture-library/after.js': "export const state = 'after'", + 'siteConfig.json': JSON.stringify({ + scripts: { sharedLibraries: ['fixture-library'] }, + mdx: { disableHighlighter: true }, + }), + }) + const outDir = path.join(base, 'out') + const before = await readScriptBundle(outDir) + expect( + (await site.change(packagePath, manifest('after'))).fullRebuild, + ).toBe(true) + await site.builder.buildAll('package exports changed') + const after = await readScriptBundle(outDir) + expect(after.content).not.toBe(before.content) + const imported = + /from\s*["']([^"']+fixture-library\.[A-Z0-9]+\.js)["']/.exec( + after.content, + ) + expect(imported).toBeTruthy() + const runtime = path.resolve( + path.dirname(after.path), + imported?.[1] ?? '', + ) + expect(await fs.readFile(runtime, 'utf8')).toContain('after') + }) + }) + it('removes ts entry bundle after deleting a scripted page with navigation enabled', async () => { await withTempDir(async (base) => { const contentDir = path.join(base, 'content') diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index b4ebb057..c443c94e 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -50,6 +50,7 @@ import { import { type BuildContext, resolveHeaderFooterHtml } from '../page' import { getPageAssets, PageAssetManager, setPageAssets } from '../page-assets' import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key' +import { isScriptConfigFile } from '../shared-libraries' import type { BuildHooks, BuildInput, BuildResult } from '../site' import { writeSitemap } from '../sitemap' import { IncrementalChangeApplier } from './change-applier' @@ -217,6 +218,7 @@ class IncrementalRuntime { minifyScripts: options.minifyScripts, failOnAssetError: options.failOnAssetError, cacheBusting: options.config.scripts.cacheBusting, + sharedLibraries: options.config.scripts.sharedLibraries, getAssets: () => this.manifest.assets, scriptCacheKeys: this.scriptCacheKeys, persistManifest: () => this.persistManifest(), @@ -618,7 +620,7 @@ class IncrementalRuntime { }) // Recursive watchers also emit directory events. They are not asset files. if (sourceStats?.isDirectory()) return result - if (isSiteConfigFile(relPath)) { + if (isSiteConfigFile(relPath) || isScriptConfigFile(relPath)) { result.fullRebuild = true return result } @@ -642,6 +644,9 @@ class IncrementalRuntime { const assetDependents = this.assets.dependents(relPath) this.contentState.takeMarkedPageCount() const ext = path.extname(relPath).toLowerCase() + if (ext !== '.ts' && this.scriptEntrypoints.hasDependency(relPath)) { + await this.changeApplier.applyScriptDependencyChange(relPath, result) + } const imported = this.importedFiles.has(toPosixPath(relPath)) const navigationFile = path.basename(relPath).toUpperCase() === diff --git a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts index 92028717..f0420bfb 100644 --- a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts +++ b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts @@ -6,6 +6,7 @@ import { copyStaticAsset, resolveStaticOutPath } from '../assets' import type { AssetManifestEntry } from '../manifest' import { readSignature } from '../manifest' import type { ScriptCacheKeyStore } from '../script-cache-key' +import { SharedLibraryCache } from '../shared-libraries' import { outputPathKey, removeFile, toAssetFile } from './support' import type { IncrementalBuildResult } from './types' @@ -15,6 +16,7 @@ interface ScriptEntrypointManagerInput { minifyScripts: boolean failOnAssetError: boolean cacheBusting: boolean + sharedLibraries: readonly string[] getAssets: () => Record scriptCacheKeys: ScriptCacheKeyStore persistManifest: () => Promise @@ -26,8 +28,15 @@ export class ScriptEntrypointManager { private readonly dependentsByRelPath = new Map>() private readonly dirtyEntrypoints = new Map() private dirtyVersion = 0 + private readonly sharedLibraries: SharedLibraryCache - constructor(private readonly input: ScriptEntrypointManagerInput) {} + constructor(private readonly input: ScriptEntrypointManagerInput) { + this.sharedLibraries = new SharedLibraryCache({ + ...input.config, + libraries: input.sharedLibraries, + reserveOutput: input.reserveOutput, + }) + } rebuildDependencyIndex(index: Record) { this.entryDependencies.clear() @@ -39,6 +48,7 @@ export class ScriptEntrypointManager { clearPageEntrypoints() { this.pageEntrypoints.clear() + this.sharedLibraries.invalidate() } setPageEntrypoints(pageRelPath: string, scriptEntrypoints: string[]) { @@ -93,7 +103,14 @@ export class ScriptEntrypointManager { return false } - resolveImpactedEntryRelPaths(changedRelPath: string) { + hasDependency(relPath: string) { + return this.dependentsByRelPath.has(this.normalizeRelPath(relPath)) + } + + invalidateDependency(changedRelPath: string) { + this.sharedLibraries.invalidate( + path.join(this.input.config.contentDir, changedRelPath), + ) const normalized = this.normalizeRelPath(changedRelPath) const impacted = new Set() // Failed bundles have no dependency graph yet. A TypeScript edit may fix @@ -234,6 +251,9 @@ export class ScriptEntrypointManager { minifyScripts: this.input.minifyScripts, failOnError: this.input.failOnAssetError, scriptCacheKey: cacheKey, + reserveOutput: this.input.reserveOutput, + sharedLibraries: this.input.sharedLibraries, + sharedLibraryCache: this.sharedLibraries, }, ) if (!assetCopy.copied) { diff --git a/packages/ts-ssg/src/build/shared-libraries.ts b/packages/ts-ssg/src/build/shared-libraries.ts new file mode 100644 index 00000000..14e5bba5 --- /dev/null +++ b/packages/ts-ssg/src/build/shared-libraries.ts @@ -0,0 +1,227 @@ +import { createHash, randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { build, type Plugin } from 'esbuild' +import { regorTemplateTagsPlugin } from './regorTemplateTransform' + +interface SharedLibrariesOptions { + contentDir: string + outDir: string + libraries?: readonly string[] + reserveOutput?: (outPath: string) => void +} + +interface SharedLibrariesOutput { + entries: Map + inputPaths: string[] +} + +/** Resolution changes need a fresh graph, including package exports and aliases. */ +export function isScriptConfigFile(filePath: string) { + const name = path.basename(filePath).toLowerCase() + return ( + name === 'package.json' || + /^(?:tsconfig|jsconfig)(?:\.[\w-]+)*\.json$/.test(name) + ) +} + +async function writeRuntimeFile(filePath: string, contents: Uint8Array) { + const existing = await fs + .readFile(filePath) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error + return undefined + }) + if (existing?.equals(contents)) return + + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const temporary = `${filePath}.${randomUUID()}.tmp` + try { + await fs.writeFile(temporary, contents, { flag: 'wx' }) + await fs.rename(temporary, filePath) + } finally { + await fs.rm(temporary, { force: true }) + } +} + +function entryName(specifier: string) { + if (/^[\w-]+$/.test(specifier)) return specifier + const slug = specifier.replace(/[^\w-]/g, '-') + const hash = createHash('sha256').update(specifier).digest('hex').slice(0, 8) + return `${slug}-${hash}` +} + +async function writeLibraries( + options: SharedLibrariesOptions, + libraries: readonly string[], +) { + const contentDir = path.resolve(options.contentDir) + const outDir = path.resolve(options.outDir, 'assets', 'runtime') + const skipResolution = Symbol('shared library resolution') + const entries = new Map(libraries.map((name) => [name, entryName(name)])) + // Build all configured entrypoints in one graph. This also preserves identity + // across package subpaths and dependencies shared by different libraries. + const result = await build({ + absWorkingDir: contentDir, + entryPoints: Object.fromEntries( + [...entries].map(([specifier, name]) => [name, specifier]), + ), + outdir: outDir, + entryNames: '[name].[hash]', + chunkNames: 'chunks/[name].[hash]', + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + conditions: ['source'], + target: 'esnext', + minify: true, + write: false, + metafile: true, + logLevel: 'silent', + plugins: [ + regorTemplateTagsPlugin(), + { + name: 'shared-library-resolution', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData === skipResolution || !entries.has(args.path)) + return undefined + // Every importer uses the site's copy, including packages that + // resolve a different nested copy of a configured shared library. + const resolved = await pluginBuild.resolve(args.path, { + kind: args.kind, + resolveDir: contentDir, + pluginData: skipResolution, + }) + if (resolved.errors.length === 0) return resolved + // Framework dependencies can be private to its installation (for + // example with pnpm), rather than resolvable from the content root. + return pluginBuild.resolve(args.path, { + kind: args.kind, + resolveDir: path.dirname(fileURLToPath(import.meta.url)), + pluginData: skipResolution, + }) + }) + }, + }, + ], + }) + const inputPaths = Object.keys(result.metafile.inputs).map((input) => + path.resolve(contentDir, input), + ) + for (const file of result.outputFiles) options.reserveOutput?.(file.path) + await Promise.all( + result.outputFiles.map((file) => + writeRuntimeFile(file.path, file.contents), + ), + ) + const outputEntries = new Map( + [...entries].map(([specifier, name]) => { + const file = result.outputFiles.find( + (file) => + path.dirname(file.path) === outDir && + path.basename(file.path).startsWith(`${name}.`), + ) + if (!file) + throw new Error(`Missing shared library output for ${specifier}.`) + return [specifier, file.path] + }), + ) + return { entries: outputEntries, inputPaths } +} + +const fileKey = (filePath: string) => { + const absolute = path.resolve(filePath) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +/** One lazy shared-library build per site session, reused by every script. */ +export class SharedLibraryCache { + private readonly libraries: string[] + private output: Promise | undefined + private dependencies = new Set() + + constructor(private readonly options: SharedLibrariesOptions) { + this.libraries = [ + ...new Set(['regor', ...(options.libraries ?? [])]), + ].sort() + } + + /** No filesystem work on entry edits; only changes to library inputs evict the build. */ + invalidate(filePath?: string) { + if ( + filePath && + !isScriptConfigFile(filePath) && + this.dependencies.size > 0 && + !this.dependencies.has(fileKey(filePath)) + ) + return + this.output = undefined + this.dependencies.clear() + } + + private getOutput(): Promise { + if (!this.output) { + const task = writeLibraries(this.options, this.libraries) + .then((result) => { + if (this.output === task) + this.dependencies = new Set(result.inputPaths.map(fileKey)) + return result + }) + .catch((error) => { + if (this.output === task) this.output = undefined + throw error + }) + this.output = task + } + return this.output + } + + plugin( + scriptOutPath: string, + onDependencies: (inputPaths: string[]) => void, + ): Plugin { + return { + name: 'shared-libraries', + setup: (pluginBuild) => { + let output: Promise> | undefined + pluginBuild.onResolve({ filter: /^[^./]/ }, async (args) => { + if (!this.libraries.includes(args.path)) { + if ( + this.libraries.some((name) => args.path.startsWith(`${name}/`)) + ) { + return { + errors: [ + { + text: `Add "${args.path}" to scripts.sharedLibraries so its subpath shares the same dependency graph.`, + }, + ], + } + } + return undefined + } + output ??= this.getOutput().then(({ entries, inputPaths }) => { + onDependencies(inputPaths) + return entries + }) + const outPath = (await output).get(args.path) + if (!outPath) + throw new Error(`Missing shared library output for ${args.path}.`) + const relativePath = path + .relative(path.dirname(scriptOutPath), outPath) + .split(path.sep) + .map(encodeURIComponent) + .join('/') + return { + path: relativePath.startsWith('.') + ? relativePath + : `./${relativePath}`, + external: true, + } + }) + }, + } + } +} diff --git a/packages/ts-ssg/src/config/config.test.ts b/packages/ts-ssg/src/config/config.test.ts index abd16835..797e9b18 100644 --- a/packages/ts-ssg/src/config/config.test.ts +++ b/packages/ts-ssg/src/config/config.test.ts @@ -236,6 +236,7 @@ describe('resolveSiteConfig sitemap', () => { it('enables script cache busting by default and accepts overrides', () => { expect(resolveSiteConfig({ rootDir: process.cwd() }).scripts).toEqual({ cacheBusting: true, + sharedLibraries: ['regor'], }) const config = resolveSiteConfig({ @@ -247,6 +248,56 @@ describe('resolveSiteConfig sitemap', () => { expect(config.scripts.cacheBusting).toBe(false) }) + it('resolves shared libraries from file and input while always including Regor', () => { + const contentDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ts-ssg-libraries-'), + ) + try { + fs.writeFileSync( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ + scripts: { sharedLibraries: ['file-library', 'file-library'] }, + }), + ) + expect(resolveSiteConfig({ contentDir }).scripts.sharedLibraries).toEqual( + ['regor', 'file-library'], + ) + expect( + resolveSiteConfig({ + contentDir, + scripts: { + sharedLibraries: [ + '@scope/library', + '@scope/library/extra', + 'regor', + ], + }, + }).scripts.sharedLibraries, + ).toEqual(['regor', '@scope/library', '@scope/library/extra']) + expect( + resolveSiteConfig({ contentDir, scripts: { sharedLibraries: [] } }) + .scripts.sharedLibraries, + ).toEqual(['regor']) + for (const sharedLibraries of [ + 'invalid', + [''], + ['../local'], + ['https://example.com/lib.js'], + [7], + ]) { + fs.writeFileSync( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ scripts: { sharedLibraries } }), + ) + expect(() => resolveSiteConfig({ contentDir })).toThrow( + 'scripts.sharedLibraries', + ) + } + } finally { + fs.rmSync(contentDir, { recursive: true, force: true }) + } + }) + it('resolves logo presentation and noninteractive overrides', () => { const logo = { brand: 'Calc Core', diff --git a/packages/ts-ssg/src/config/core-config.ts b/packages/ts-ssg/src/config/core-config.ts index 8ca60cee..ee3dade8 100644 --- a/packages/ts-ssg/src/config/core-config.ts +++ b/packages/ts-ssg/src/config/core-config.ts @@ -119,8 +119,22 @@ function resolveScriptsConfig( input?: DeepPartial, file?: DeepPartial, ): SiteScriptsConfig { + const sharedLibraries = input?.sharedLibraries ?? file?.sharedLibraries ?? [] + if ( + !Array.isArray(sharedLibraries) || + sharedLibraries.some( + (value) => + typeof value !== 'string' || + !/^(?:@[\w~-]+\/)?[\w~-][\w.~-]*(?:\/[\w~-][\w.~-]*)*$/.test(value), + ) + ) { + throw new Error( + 'scripts.sharedLibraries must be an array of package import specifiers.', + ) + } return { cacheBusting: pickBoolean(input?.cacheBusting, file?.cacheBusting, true), + sharedLibraries: [...new Set(['regor', ...sharedLibraries])], } } diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 2bdf9049..fed8dee9 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -24,7 +24,7 @@ export function getHead(config?: BasicHeadConfig) { return createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.18', + content: 'PureStack v1.1.19', }), ) } diff --git a/packages/ts-ssg/src/dev/server.test.ts b/packages/ts-ssg/src/dev/server.test.ts index 5ea44968..ab2ed1b9 100644 --- a/packages/ts-ssg/src/dev/server.test.ts +++ b/packages/ts-ssg/src/dev/server.test.ts @@ -30,6 +30,90 @@ describe('dev server', () => { root = undefined }) + it.each([true, false])( + 'serves one shared Regor runtime under a base path (fullRender %s)', + async (fullRender) => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-regor-') + const contentDir = path.join(root, 'content') + const outDir = path.join(root, 'out') + await writeFile( + path.join( + contentDir, + 'node_modules', + 'fixture-library', + 'package.json', + ), + JSON.stringify({ + name: 'fixture-library', + type: 'module', + exports: './index.js', + }), + ) + await writeFile( + path.join(contentDir, 'node_modules', 'fixture-library', 'index.js'), + 'export const state = { count: 1 }', + ) + await writeFile( + path.join(contentDir, 'index.mdx'), + '\n', + ) + for (const relPath of ['first.ts', 'nested/second.ts']) { + await writeFile( + path.join(contentDir, relPath), + "import { sref } from 'regor'\nexport { state } from 'fixture-library'\nexport const value = sref(1)", + ) + } + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + watch: false, + fullRender, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir, + basePath: '/docs', + scripts: { sharedLibraries: ['fixture-library'] }, + mdx: { disableHighlighter: true }, + }, + }, + }) + const pageUrl = `http://${HOST}:${port}/docs/` + const response = await fetch(pageUrl) + expect(response.status).toBe(200) + const html = await response.text() + const scripts = [...html.matchAll(/]*\bsrc="([^"]+\.js)"/g)] + expect(scripts).toHaveLength(2) + const runtimeUrls: string[] = [] + for (const [, src] of scripts) { + const scriptUrl = new URL(src, pageUrl) + const script = await fetch(scriptUrl) + expect(script.status).toBe(200) + const code = await script.text() + const imported = /from\s*["']([^"']+regor\.[A-Z0-9]+\.js)["']/.exec( + code, + ) + expect(imported).toBeTruthy() + runtimeUrls.push(new URL(imported?.[1] ?? '', scriptUrl).href) + } + expect(runtimeUrls[0]).toBe(runtimeUrls[1]) + expect(new URL(runtimeUrls[0]).pathname).toMatch( + /^\/docs\/assets\/runtime\/regor\./, + ) + const runtime = await fetch(runtimeUrls[0]) + expect(runtime.status).toBe(200) + expect(runtime.headers.get('content-type')).toContain('javascript') + expect(await runtime.text()).toContain('regor.runtime') + expect( + (await fs.readdir(path.join(outDir, 'assets', 'runtime'))).some( + (file) => file.startsWith('fixture-library.'), + ), + ).toBe(true) + }, + ) + it.each([true, false])( 'does not generate sitemap or robots with fullRender %s', async (fullRender) => { diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index ada546da..6ae3e8ab 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 79aca626..9d2949e2 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-theme/package.json b/packages/ts-theme/package.json index 475aac09..039db712 100644 --- a/packages/ts-theme/package.json +++ b/packages/ts-theme/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-theme", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-theme.mjs", "types": "./dist/ts-theme.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index de36eb9e..61ec0096 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index 7ebdfe91..742b27b8 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.18", + "version": "1.1.19", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts",