diff --git a/index.js b/index.js index 9bae2d6f..9ae2a2c1 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,5 @@ +/// + /** * @import { DomStackOpts, Results, SiteData } from './lib/builder.js' * @import { Stats } from 'node:fs' diff --git a/lib/build-pages/page-builders/html/index.js b/lib/build-pages/page-builders/html/index.js index 22357155..2cc9355e 100644 --- a/lib/build-pages/page-builders/html/index.js +++ b/lib/build-pages/page-builders/html/index.js @@ -8,8 +8,7 @@ import Handlebars from 'handlebars' /** * Build all of the bundles using esbuild. - * @template {Record} T - The type of variables for the page - * @type {PageBuilderType} + * @type {PageBuilderType, string>} */ export async function htmlBuilder ({ pageInfo }) { assert(pageInfo.type === 'html', 'html builder requires a "html" page type') diff --git a/lib/build-pages/page-builders/md/index.js b/lib/build-pages/page-builders/md/index.js index 10ac8981..b5318222 100644 --- a/lib/build-pages/page-builders/md/index.js +++ b/lib/build-pages/page-builders/md/index.js @@ -14,8 +14,7 @@ let md = null /** * Build all of the bundles using esbuild. - * @template {Record} T - The type of variables for the page - * @type {PageBuilderType} + * @type {PageBuilderType, string>} */ export async function mdBuilder ({ pageInfo, options }) { assert(pageInfo.type === 'md', 'md builder requires an "md" page type') diff --git a/lib/builder.js b/lib/builder.js index a60be66d..238ba455 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -68,7 +68,7 @@ import { * @property {string[]|undefined} [target=[]] - Esbuild target values used for JavaScript and CSS bundling. * @property {boolean|undefined} [buildDrafts=false] - Build files marked with the `published: false` variable. * @property {string[]|undefined} [copy=[]] - Paths to copy into the dest directory. Relative paths are resolved to absolute paths from the current working directory by the DomStack constructor, matching the CLI `--copy` behavior. - * @property {PinoLogger|undefined} [logger] - Logger used for watch output and embedded sync output. + * @property {PinoLogger|undefined} [logger] - Pino logger instance used for watch output and embedded sync output. */ /** diff --git a/package.json b/package.json index a3707173..e7e308bf 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "page.vars.js", "scripts/domstack-manifest-schema.js", "style.css", + "types/thread-stream.d.ts", "types.d.ts", "types.d.ts.map" ], @@ -53,7 +54,7 @@ "async-folder-walker": "^3.0.5", "chokidar": "^5.0.0", "clean-deep": "^3.4.0", - "cpx2": "^9.0.0", + "cpx2": "^9.0.1", "esbuild": "^0.28.1", "fragtml": "^0.0.9", "handlebars": "^4.7.8", @@ -120,6 +121,7 @@ "test:installed-check": "installed-check --ignore-dev --no-workspaces", "test:neostandard": "eslint . --ignore-pattern 'test-cases/build-errors/src/**/*.js' --ignore-pattern 'test-cases/page-build-errors/src/**/*.js'", "test:node-test": "node --test --experimental-test-coverage --test-reporter=dot --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info", + "test:packed-types": "node scripts/test-packed-types.js", "test:playwright": "playwright test", "test:tsc": "tsc", "build-examples": "run-p example:*", diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js new file mode 100644 index 00000000..b157dbc6 --- /dev/null +++ b/scripts/test-packed-types.js @@ -0,0 +1,149 @@ +import { execFile, spawn } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const projectPath = path.resolve(import.meta.dirname, '..') +const { dependencies, devDependencies } = JSON.parse(await readFile(path.join(projectPath, 'package.json'), 'utf8')) +const temporaryPath = await mkdtemp(path.join(tmpdir(), 'domstack-packed-types-')) +const consumerPath = path.join(temporaryPath, 'consumer') + +try { + await Promise.all([ + run('npm', ['run', 'clean:declarations-top'], projectPath), + run('npm', ['run', 'clean:declarations-lib'], projectPath), + ]) + await run('npm', ['run', 'build:declaration'], projectPath) + const { stdout } = await execFileAsync( + 'npm', + ['pack', '--json', '--ignore-scripts', '--pack-destination', temporaryPath], + { cwd: projectPath, encoding: 'utf8' } + ) + const [{ filename }] = JSON.parse(stdout) + const tarballPath = path.join(temporaryPath, filename) + + await mkdir(consumerPath) + await Promise.all([ + writeFile(path.join(consumerPath, 'package.json'), `${JSON.stringify({ + name: 'domstack-packed-type-consumer', + private: true, + type: 'module', + dependencies: { + '@domstack/static': `file:${tarballPath}`, + '@types/node': '^26.0.1', + pino: dependencies.pino, + typescript: devDependencies.typescript, + }, + }, null, 2)}\n`), + writeFile(path.join(consumerPath, 'index.ts'), `import { DomStack, PageData } from '@domstack/static' +import pino from 'pino' + +const stack = new DomStack('src', 'public', { logger: pino({ level: 'silent' }) }) +void PageData +void stack +`), + writeFile(path.join(consumerPath, 'types.ts'), `import pino from 'pino' +import type { WorkerOptions } from 'node:worker_threads' +import type { + DomStackOpts, + PageFunction, + Results, +} from '@domstack/static/types.js' + +const logger = pino({ level: 'silent' }) +const options: DomStackOpts = { buildDrafts: true, logger } +const render: PageFunction, string> = ({ vars }) => String(vars) + +// The logger option retains Pino's full contract. +const configuredLogger: pino.Logger | undefined = options.logger +const childOptions: DomStackOpts = { logger: logger.child({ component: 'consumer' }) } +// @ts-expect-error A generic logging object is not a Pino logger. +const invalidOptions: DomStackOpts = { logger: { info () {} } } + +// The compatibility alias preserves the transport's transfer-list type, not any. +type TransferItem = NonNullable['emit']>[2]>[number] +declare const item: TransferItem +const expected: NonNullable[number] = item +const actual: TransferItem = expected +// @ts-expect-error A primitive is not transferable. +const invalidTransfer: TransferItem = 123 + +void render +void configuredLogger +void childOptions +void invalidOptions +void actual +void invalidTransfer +void ({} as Results) +`), + writeFile(path.join(consumerPath, 'tsconfig.json'), `${JSON.stringify({ + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ES2022', + types: ['node'], + }, + include: ['index.ts'], + }, null, 2)}\n`), + writeFile(path.join(consumerPath, 'tsconfig-types.json'), `${JSON.stringify({ + extends: './tsconfig.json', + include: ['types.ts'], + }, null, 2)}\n`), + ]) + + await run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock'], + consumerPath + ) + // Node 26 exercises the missing alias; 24 and 22 guard against duplicate + // declarations on supported older releases. Check each entry in isolation. + for (const nodeVersion of [26, 24, 22]) { + if (nodeVersion !== 26) { + await run( + 'npm', + ['install', `@types/node@^${nodeVersion}.0.0`, '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock'], + consumerPath + ) + } + for (const config of ['tsconfig.json', 'tsconfig-types.json']) { + console.log(`Checking TypeScript ${devDependencies.typescript}, @types/node ${nodeVersion}, ${config}`) + await run( + process.execPath, + [path.join(consumerPath, 'node_modules', 'typescript', 'bin', 'tsc'), '--project', config], + consumerPath + ) + } + } +} finally { + await rm(temporaryPath, { recursive: true, force: true }) + await Promise.all([ + run('npm', ['run', 'clean:declarations-top'], projectPath), + run('npm', ['run', 'clean:declarations-lib'], projectPath), + ]) +} + +/** + * @param {string} command + * @param {string[]} args + * @param {string} cwd + * @returns {Promise} + */ +function run (command, args, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${command} exited with ${signal ?? code}`)) + } + }) + }) +} diff --git a/types.ts b/types.ts index cbd3c5da..da8a48e1 100644 --- a/types.ts +++ b/types.ts @@ -1,3 +1,5 @@ +/// + // Type-only public entry for `import type { ... } from '@domstack/static/types.js'`. // There is intentionally no runtime `types.js` today; this source emits `types.d.ts`, // and `types.js` is reserved for a future runtime/type companion entry if needed. diff --git a/types/thread-stream.d.ts b/types/thread-stream.d.ts new file mode 100644 index 00000000..9fd1b2fd --- /dev/null +++ b/types/thread-stream.d.ts @@ -0,0 +1,20 @@ +import type { WorkerOptions } from 'node:worker_threads' + +/** + * Temporary compatibility for thread-stream <=4.2.0, which still references + * worker_threads.TransferListItem after @types/node 26 removed that alias. + * Upstream fix: https://github.com/pinojs/thread-stream/pull/233 + * + * Remove this file, its public entry-point references, and its package files + * entry once the supported Pino dependency tree requires the upstream fix. + */ +declare namespace ThreadStreamCompat { + type TransferListItem = NonNullable[number] +} + +declare module 'worker_threads' { + // An import alias keeps the duplicate-name diagnostic here, rather than in + // Node's declarations. The packed tests check old and new Node declarations. + // @ts-ignore Node <=25 already exports this equivalent alias. + export import TransferListItem = ThreadStreamCompat.TransferListItem +}