diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9e4b5fb..ae648d1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,14 @@ - Questions are welcome, however unless there is a official support contract established between the maintainers and the requester, support is not guaranteed. - Contributors reserve the right to walk away from this project at any moment with or without notice. +## Generated default layout + +Edit `lib/defaults/default.root.layout.ts`, then run `npm run build:defaults` to regenerate `lib/defaults/default.root.layout.js`. +The JavaScript is checked in so normal development and tests work immediately after checkout, without a declaration build or a runtime TypeScript loader. +Do not edit the generated JavaScript directly. +Both layouts are published, and eject copies the requested language (rewriting the TypeScript type import to the public package entry). +Declaration cleanup and `npm run clean` deliberately preserve the generated JavaScript. + ## Releasing Changelog, and releasing is automated with npm scripts and actions. To create a release: @@ -29,6 +37,12 @@ If for some reason that isn't working or a local release is preferred, follow th - Ensure a clean working git workspace. - Run `npm version {patch,minor,major}`. - - This wills update the version number and generate the changelog. + - This updates the version number, builds the default JavaScript and version-dependent manifest schema, and uses `releasearoni version --add` to stage both generated files alongside the changelog before npm creates the version commit and tag. - Run `npm publish`. - - This will push your local git branch and tags to the default remote, perform a [gh-release](https://ghub.io/gh-release), and create an npm publication + - `releasearoni` runs the full build before pushing the branch and tags and creating the GitHub release. + - The `prepack` hook cleans old declarations before regenerating the default JavaScript and declarations for both `npm pack` and `npm publish`, so tarballs include both layout languages and their types even after a full release build. + - Post-publish cleanup removes temporary declarations and site output, but preserves versioned JavaScript so it does not dirty the version commit. + +Generation belongs in `version`, not `preversion` (which runs before the version update) or `postversion` (which runs after the commit and tag). +The release workflow's pre-version reset/clean is safe because the initial generated JavaScript is tracked and the version hook rebuilds it before staging. +Run `npm run test:version-build` to verify generation, staging, tagging, and cleanup in a disposable repository without versioning this checkout. diff --git a/agents.md b/agents.md index 7249a1e4..604a0ef3 100644 --- a/agents.md +++ b/agents.md @@ -10,5 +10,6 @@ - Type builds are only needed during publish time or when debugging types. - After running a type build, clean up the generated build files and do not leave them sitting around. - Use the cleanup scripts in `package.json` for generated type build files. +- The generated `lib/defaults/default.root.layout.js` is versioned runtime code, not temporary declaration output; regenerate it with `npm run build:defaults` after editing its TypeScript source and never remove it during cleanup. - For formatting-only ESLint failures, use `npx eslint --fix` for a quick targeted fix before rerunning lint. - When handling PR review comments, validate that each comment is correct before making changes; maintainer comments are almost always valid, but review bot comments may be wrong, and after addressing a comment, always reply with what was done. diff --git a/bin.js b/bin.js index 13b79105..45e4be92 100755 --- a/bin.js +++ b/bin.js @@ -7,7 +7,7 @@ * @import { BsInstance } from '@domstack/sync' */ -import { readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { basename, resolve, join, relative } from 'node:path' import { parseArgs } from 'node:util' import { printHelpText } from 'argsclopts' @@ -74,6 +74,15 @@ const options = { short: 'e', help: 'eject the DOMStack default layout, style and client into the src flag directory', }, + language: { + type: 'string', + default: 'js', + help: 'language for --eject: ts or js (default: js)', + }, + yes: { + type: 'boolean', + help: 'skip confirmation for --eject', + }, watch: { type: 'boolean', short: 'w', @@ -143,10 +152,10 @@ async function run () { // Eject task if (argv['eject']) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const language = argv['language'] + if (language !== 'ts' && language !== 'js') { + throw new Error('--language must be ts or js') + } const localPkg = await packageDirectory({ cwd: src }) @@ -162,9 +171,10 @@ async function run () { const relativeSrc = relative(process.cwd(), src) const relativePkg = relative(process.cwd(), localPkgJson) - const targetLayoutPath = `layouts/root.layout.${targetIsModule ? 'js' : 'mjs'}` + const extension = language === 'ts' ? (targetIsModule ? 'ts' : 'mts') : targetIsModule ? 'js' : 'mjs' + const targetLayoutPath = `layouts/root.layout.${extension}` const targetGlobalStylePath = 'globals/global.css' - const targetGlobalClientPath = `globals/global.client.${targetIsModule ? 'js' : 'mjs'}` + const targetGlobalClientPath = `globals/global.client.${language === 'ts' ? 'ts' : extension}` const tbPkgContents = await getPkg() const mineVersion = tbPkgContents?.['dependencies']?.['mine.css'] @@ -185,18 +195,31 @@ domstack eject actions: - Add fragtml@${fragtmlVersion} to ${relativePkg} - Add highlight.js@${highlightVersion} to ${relativePkg} `) - const answer = await askYesNo(rl, 'Continue?') - if (answer === false) { - console.log('No action taken. Exiting.') - process.exit(0) + if (!argv['yes']) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + let answer + try { + answer = await askYesNo(rl, 'Continue?') + } finally { + rl.close() + } + if (!answer) { + console.log('No action taken. Exiting.') + process.exit(0) + } } - const defaultLayoutPath = join(__dirname, 'lib/defaults/default.root.layout.js') + const defaultLayoutPath = join(__dirname, `lib/defaults/default.root.layout.${language}`) const defaultGlobalStylePath = join(__dirname, 'lib/defaults/default.style.css') const defaultGlobalClientPath = join(__dirname, 'lib/defaults/default.client.js') + const layoutSource = await readFile(defaultLayoutPath, 'utf8') + const layout = language === 'ts' + ? layoutSource.replace("from '#types'", "from '@domstack/static/types.js'") + : layoutSource + await mkdir(join(src, 'layouts'), { recursive: true }) await Promise.all([ - copyFile(defaultLayoutPath, join(src, targetLayoutPath)), + writeFile(join(src, targetLayoutPath), layout), copyFile(defaultGlobalStylePath, join(src, targetGlobalStylePath)), copyFile(defaultGlobalClientPath, join(src, targetGlobalClientPath)), ]) diff --git a/docs/cli/README.md b/docs/cli/README.md index 6d785e85..5c7311fe 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -28,6 +28,8 @@ Usage: domstack [options] --noEsbuildMeta skip writing the esbuild metafile to disk --domstackManifest write the domstack manifest to disk --eject, -e eject the DOMStack default layout, style and client into the src flag directory + --language language for --eject: ts or js (default: js) + --yes skip confirmation for --eject --watch, -w build, watch and serve the site build --watch-only watch and build the src folder without serving --verbose show debug logs, including the build tree and individual copy operations @@ -66,13 +68,29 @@ When you run `domstack --eject`, it will: 2. Create a default global CSS file at `globals/global.css` 3. - Create a default client-side JavaScript file at `globals/global.client.js` + Create a default client-side JavaScript file at `globals/global.client.js` (or `.mjs` depending on your package.json type) 4. Add the necessary dependencies to your package.json: - mine.css - fragtml - highlight.js +Use `domstack --eject --language ts` to write `layouts/root.layout.ts` and `globals/global.client.ts` instead. +For packages without `"type": "module"`, the TypeScript layout uses `.mts` so Node loads it as ESM without changing your package type. +The CSS and added dependencies are the same for both languages. +JavaScript remains the default (`--language js`), with `.js` files in module packages and `.mjs` files otherwise. +Only `ts` and `js` are accepted language values. + +DOMStack maintains one canonical TypeScript root layout and generates its JavaScript counterpart at build and release time. +Both files are published; the runtime loads JavaScript directly without a custom loader, and eject copies the selected language rather than compiling it. +The TypeScript output uses the public type-only `@domstack/static/types.js` entry instead of DOMStack's private `#types` alias. +Keep `@domstack/static` installed for those types; no runtime type import or separate TypeScript compilation step is needed. +The client is currently comment-only, but receives a `.ts` extension when TypeScript is selected. + +For automation, run `domstack --eject --language ts --yes --src src` to skip the confirmation prompt. +Without `--yes`, eject asks for confirmation before writing files or updating dependencies. +Eject overwrites its target files, so review or back up existing customizations before proceeding. + It is recommended to eject early in your project so that you can customize the root layout as you see fit, and decouple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released. [domstack-sync]: https://www.npmjs.com/package/@domstack/sync diff --git a/docs/layouts/README.md b/docs/layouts/README.md index 7f887c52..10875e51 100644 --- a/docs/layouts/README.md +++ b/docs/layouts/README.md @@ -202,7 +202,11 @@ const defaultRootLayout: LayoutFunction} WorkerFiles @@ -28,7 +29,7 @@ import { resolveLayoutChain } from './resolve-layout-chain.js' * @returns {Promise<{ render: InternalLayoutFunction, vars: Partial, parentLayout: string | undefined }>} The resolved layout module exports. */ export async function resolveLayout (layoutPath) { - const { default: layout, vars, parentLayout } = await import(layoutPath) + const { default: layout, vars, parentLayout } = await import(pathToFileURL(layoutPath).href) if (typeof layout !== 'function') throw new TypeError(`Layout "${layoutPath}" must export a default render function`) if (parentLayout !== undefined && (typeof parentLayout !== 'string' || !parentLayout.trim())) { throw new TypeError(`Layout "${layoutPath}" parentLayout must be a non-empty string`) diff --git a/lib/defaults/default.root.layout.js b/lib/defaults/default.root.layout.js index 10646f12..a87badde 100644 --- a/lib/defaults/default.root.layout.js +++ b/lib/defaults/default.root.layout.js @@ -1,33 +1,19 @@ -/** - * @import { LayoutFunction } from '#types' - * @import { HtmlResult } from 'fragtml/types.js' - */ -import { html, raw, render } from 'fragtml' - -/** - * @typedef {{ - * title: string, - * siteName: string, - * defaultStyle: boolean, - * basePath: string - * }} DefaultRootLayoutVars - */ - -/** - * Build all of the bundles using esbuild. - * - * @type {LayoutFunction} - */ -export default function defaultRootLayout ({ +// Generated from default.root.layout.ts by npm run build:defaults. Do not edit. +var __freeze = Object.freeze; +var __defProp = Object.defineProperty; +var __template = (cooked, raw2) => __freeze(__defProp(cooked, "raw", { value: __freeze(raw2 || cooked.slice()) })); +var _a; +import { html, raw, render } from "fragtml"; +function defaultRootLayout({ vars: { title, - siteName = 'domstack', - basePath, + siteName = "domstack", + basePath /* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */ }, scripts, styles, - children, + children /* pages */ /* page */ }) { @@ -36,19 +22,18 @@ export default function defaultRootLayout ({ - ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + ${title ? `${title}` : ""}${title && siteName ? " | " : ""}${siteName} - ${scripts - ? scripts.map(script => html``) - : null} - ${styles - ? styles.map(style => html``) - : null} + ${scripts ? scripts.map((script) => html(_a || (_a = __template(['`) + : null} + ${styles + ? styles.map(style => html``) + : null} + + +
${typeof children === 'string' ? raw(children) : children}
+ + + `) +} diff --git a/package.json b/package.json index 55adbeae..747205bd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "lib/**/*.d.ts.map", "lib/**/*.js", "lib/**/*.json", + "lib/defaults/default.root.layout.ts", "!lib/**/*.test.js", "!lib/**/fixtures/**", "page.vars.js", @@ -115,12 +116,14 @@ "scripts": { "prepublishOnly": "releasearoni", "postpublish": "npm run clean", - "version": "releasearoni version", + "version": "run-p build:defaults build:schema && releasearoni version --add lib/defaults/default.root.layout.js --add lib/domstack-manifest/schema.json", + "prepack": "run-p clean:declarations-top clean:declarations-lib && npm run build:defaults && npm run build:declaration", "test": "npm run clean && run-s test:*", "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:version-build": "node scripts/test-version-build.js", "test:playwright": "playwright test", "test:tsc": "tsc", "build-examples": "run-p example:*", @@ -129,7 +132,8 @@ "clean:declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.ts*')", "clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')", "clean-node_modules": "rm -rf node_modules && rm -rf examples/*/node_modules", - "build": "npm run clean && run-p build:*", + "build": "npm run clean && npm run build:defaults && run-p build:domstack build:declaration build:schema", + "build:defaults": "node scripts/build-defaults.js", "build:domstack": "./bin.js --src . --ignore examples,test-cases,coverage,*.tsconfig.json,fonts,/lib,/types,/scripts,/plans,/browser-tests,/test-results,/lcov.info,/tsconfig.json,/AGENTS.md,/AGENT.md,/agents.md", "build:declaration": "tsc -p declaration.tsconfig.json", "build:schema": "node scripts/domstack-manifest-schema.js", diff --git a/scripts/build-defaults.js b/scripts/build-defaults.js new file mode 100644 index 00000000..a2c53ee0 --- /dev/null +++ b/scripts/build-defaults.js @@ -0,0 +1,13 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { transform } from 'esbuild' + +const sourceURL = new URL('../lib/defaults/default.root.layout.ts', import.meta.url) +const outputURL = new URL('../lib/defaults/default.root.layout.js', import.meta.url) +const source = await readFile(sourceURL, 'utf8') +const { code } = await transform(source, { + loader: 'ts', + format: 'esm', + target: 'es2022', + legalComments: 'inline', +}) +await writeFile(outputURL, `// Generated from default.root.layout.ts by npm run build:defaults. Do not edit.\n${code}`) diff --git a/scripts/test-packed-types.js b/scripts/test-packed-types.js index b157dbc6..abe65bd1 100644 --- a/scripts/test-packed-types.js +++ b/scripts/test-packed-types.js @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict' import { execFile, spawn } from 'node:child_process' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -15,13 +16,18 @@ try { 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], + ['pack', '--json', '--pack-destination', temporaryPath], { cwd: projectPath, encoding: 'utf8' } ) - const [{ filename }] = JSON.parse(stdout) + const [{ filename, files }] = JSON.parse(stdout) + const packedPaths = files.map(/** @param {{ path: string }} file */ file => file.path) + assert.ok(packedPaths.includes('lib/defaults/default.root.layout.ts')) + assert.ok(packedPaths.includes('lib/defaults/default.root.layout.d.ts')) + assert.ok(packedPaths.includes('lib/defaults/default.root.layout.js')) + assert.ok(!packedPaths.includes('lib/defaults/load-layout.js')) const tarballPath = path.join(temporaryPath, filename) await mkdir(consumerPath) @@ -101,6 +107,36 @@ void ({} as Results) ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock'], consumerPath ) + const packedBin = path.join(consumerPath, 'node_modules', '@domstack/static', 'bin.js') + for (const language of ['ts', 'js']) { + const src = `src-${language}` + await mkdir(path.join(consumerPath, src)) + await writeFile(path.join(consumerPath, src, 'page.html'), '

Packed defaults

') + await run(process.execPath, [packedBin, '--eject', '--language', language, '--yes', '--src', src], consumerPath) + await run(process.execPath, [packedBin, '--src', src, '--dest', `public-${language}`], consumerPath) + assert.match(await readFile(path.join(consumerPath, `public-${language}`, 'index.html'), 'utf8'), /

Packed defaults<\/h1>/) + } + const packedLayout = path.join(consumerPath, 'node_modules/@domstack/static/lib/defaults/default.root.layout.js') + assert.doesNotMatch(await readFile(packedLayout, 'utf8'), /#types|import type|@domstack\/static\/types/) + // Upstream runtime imports must work without loading the adjacent TypeScript source. + await rm(path.join(consumerPath, 'node_modules/@domstack/static/lib/defaults/default.root.layout.ts')) + await writeFile(path.join(consumerPath, 'upstream.mjs'), `import layout from '@domstack/static/lib/defaults/default.root.layout.js' +import assert from 'node:assert/strict' +assert.ok(layout({ vars: {}, children: '

Upstream

' }).includes('

Upstream

')) +`) + await run(process.execPath, ['upstream.mjs'], consumerPath) + await mkdir(path.join(consumerPath, 'src-default')) + await writeFile(path.join(consumerPath, 'src-default', 'page.html'), '

Canonical default

') + await run(process.execPath, [packedBin, '--src', 'src-default', '--dest', 'public-default'], consumerPath) + assert.match(await readFile(path.join(consumerPath, 'public-default', 'index.html'), 'utf8'), /

Canonical default<\/h1>/) + await writeFile(path.join(consumerPath, 'tsconfig-eject.json'), `${JSON.stringify({ + extends: './tsconfig.json', + // fragtml currently ships a declaration with an unresolved FragmentBoundary. + // Still check our ejected source and its public imports, not dependency internals. + compilerOptions: { skipLibCheck: true }, + include: ['src-ts/**/*.ts'], + }, null, 2)}\n`) + // 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]) { @@ -111,7 +147,7 @@ void ({} as Results) consumerPath ) } - for (const config of ['tsconfig.json', 'tsconfig-types.json']) { + for (const config of ['tsconfig.json', 'tsconfig-types.json', 'tsconfig-eject.json']) { console.log(`Checking TypeScript ${devDependencies.typescript}, @types/node ${nodeVersion}, ${config}`) await run( process.execPath, diff --git a/scripts/test-version-build.js b/scripts/test-version-build.js new file mode 100644 index 00000000..113edbe3 --- /dev/null +++ b/scripts/test-version-build.js @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +const project = path.resolve(import.meta.dirname, '..') +const temporary = await mkdtemp(path.join(tmpdir(), 'domstack-version-build-')) +const generated = 'lib/defaults/default.root.layout.js' +const source = 'lib/defaults/default.root.layout.ts' + +/** + * @param {string} command + * @param {string[]} args + * @param {string} [cwd] + */ +async function run (command, args, cwd = temporary) { + const { stdout } = await exec(command, args, { + cwd, + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, GIT_EDITOR: 'true', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }) + return stdout.trim() +} + +try { + // Snapshot current edits and new files without touching the real index or refs. + const files = await run('git', ['--no-pager', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], project) + for (const file of new Set(files.split('\0').filter(Boolean))) { + const destination = path.join(temporary, file) + await mkdir(path.dirname(destination), { recursive: true }) + try { + await copyFile(path.join(project, file), destination) + } catch (error) { + if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error + } + } + await symlink(path.join(project, 'node_modules'), path.join(temporary, 'node_modules'), 'dir') + await run('git', ['init', '--initial-branch=main']) + await run('git', ['config', 'user.name', 'DOMStack lifecycle test']) + await run('git', ['config', 'user.email', 'lifecycle@example.invalid']) + await run('git', ['remote', 'add', 'origin', 'https://github.com/bcomnes/domstack.git']) + await rm(path.join(temporary, generated), { force: true }) + await run('git', ['add', '.']) + await run('git', ['commit', '-m', 'Disposable initial snapshot']) + + for (const version of ['99.0.0-test.0', '99.0.0-test.1']) { + if (version.endsWith('.1')) { + const original = await readFile(path.join(temporary, source), 'utf8') + await writeFile(path.join(temporary, source), original.replace("siteName = 'domstack'", "siteName = 'version-hook-test'")) + await run('git', ['add', source]) + await run('git', ['commit', '-m', 'Change canonical source without rebuilding']) + } + await run('npm', ['version', version, '--no-workspaces', '--sign-git-commit=false', '--sign-git-tag=false']) + const output = await readFile(path.join(temporary, generated), 'utf8') + assert.equal(await run('git', ['--no-pager', 'show', `v${version}:${generated}`]), output.trim()) + const schema = await run('git', ['--no-pager', 'show', `v${version}:lib/domstack-manifest/schema.json`]) + assert.match(schema, /@domstack\/static@99\//, 'version-dependent schema is captured before the version commit') + assert.equal(await run('git', ['--no-pager', 'rev-parse', `v${version}^{commit}`]), await run('git', ['--no-pager', 'rev-parse', 'HEAD'])) + assert.equal(await run('git', ['--no-optional-locks', 'status', '--porcelain']), '') + if (version.endsWith('.1')) assert.match(output, /version-hook-test/) + await run('npm', ['run', 'build:defaults']) + await run('npm', ['run', 'build:defaults']) + assert.equal(await readFile(path.join(temporary, generated), 'utf8'), output, 'generation is byte-for-byte idempotent') + await run('npm', ['run', 'clean']) + await run('npm', ['run', 'postpublish']) + assert.equal(await readFile(path.join(temporary, generated), 'utf8'), output, 'cleanup preserves generated runtime code') + assert.equal(await run('git', ['--no-optional-locks', 'status', '--porcelain']), '', 'version commit stays clean') + console.log(`Verified ${version}: generated JS staged, committed, tagged, idempotent, and preserved by cleanup`) + } + // releasearoni builds after versioning; npm then runs prepack before publishing. + const versionedOutput = await readFile(path.join(temporary, generated), 'utf8') + await run('npm', ['run', 'build']) + const packDestination = path.join(temporary, '.tmp-pack') + await mkdir(packDestination) + const [{ files: packedFiles }] = JSON.parse(await run('npm', ['pack', '--json', '--pack-destination', packDestination])) + for (const required of [source, generated, 'lib/defaults/default.root.layout.d.ts']) { + assert.ok(packedFiles.some(/** @param {{ path: string }} file */ file => file.path === required)) + } + await run('npm', ['run', 'postpublish']) + assert.equal(await readFile(path.join(temporary, generated), 'utf8'), versionedOutput) + assert.equal(await run('git', ['--no-optional-locks', 'status', '--porcelain']), '', 'release build, pack, and postpublish leave the version commit clean') + console.log('Verified release build → pack → postpublish preserves the version commit and packages both layouts and declarations') +} finally { + await rm(temporary, { recursive: true, force: true }) +} diff --git a/test-cases/cli-errors/eject.test.js b/test-cases/cli-errors/eject.test.js new file mode 100644 index 00000000..b6a751ee --- /dev/null +++ b/test-cases/cli-errors/eject.test.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { execFile, spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { test } from 'node:test' + +const exec = promisify(execFile) +const project = resolve(import.meta.dirname, '../..') +const bin = join(project, 'bin.js') + +for (const type of ['module', 'commonjs']) { + for (const language of [undefined, 'js', 'ts']) { + test(`eject ${language ?? 'default'} into ${type} package and build`, async t => { + const cwd = await mkdtemp(join(tmpdir(), 'domstack-eject-')) + t.after(() => rm(cwd, { recursive: true, force: true })) + await writeFile(join(cwd, 'package.json'), JSON.stringify({ type, dependencies: { retained: '1.0.0' } })) + await mkdir(join(cwd, 'src')) + await writeFile(join(cwd, 'src/page.html'), '

Ejected site

') + await symlink(join(project, 'node_modules'), join(cwd, 'node_modules'), 'dir') + const args = [bin, '--eject', '--yes', ...(language ? ['--language', language] : [])] + const { stdout } = await exec(process.execPath, args, { cwd, timeout: 30000 }) + assert.match(stdout, /Done ejecting files!/) + assert.doesNotMatch(stdout, /Continue\?/) + const extension = language === 'ts' ? (type === 'module' ? 'ts' : 'mts') : type === 'module' ? 'js' : 'mjs' + const layout = await readFile(join(cwd, `src/layouts/root.layout.${extension}`), 'utf8') + const canonical = await readFile(join(project, 'lib/defaults/default.root.layout.ts'), 'utf8') + assert.equal(layout, language === 'ts' + ? canonical.replace("from '#types'", "from '@domstack/static/types.js'") + : await readFile(join(project, 'lib/defaults/default.root.layout.js'), 'utf8')) + assert.doesNotMatch(layout, /#types/) + assert.equal(await readFile(join(cwd, `src/globals/global.client.${language === 'ts' ? 'ts' : extension}`), 'utf8'), + await readFile(join(project, 'lib/defaults/default.client.js'), 'utf8')) + assert.equal(await readFile(join(cwd, 'src/globals/global.css'), 'utf8'), + await readFile(join(project, 'lib/defaults/default.style.css'), 'utf8')) + const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) + assert.equal(pkg.dependencies.retained, '1.0.0') + for (const dependency of ['mine.css', 'fragtml', 'highlight.js']) { + assert.ok(pkg.dependencies[dependency]) + } + await exec(process.execPath, [bin], { cwd, timeout: 30000 }) + assert.match(await readFile(join(cwd, 'public/index.html'), 'utf8'), /

Ejected site<\/h1>/) + }) + } +} + +test('eject still asks for confirmation and respects a declined prompt', async t => { + const cwd = await mkdtemp(join(tmpdir(), 'domstack-eject-prompt-')) + t.after(() => rm(cwd, { recursive: true, force: true })) + const pkg = '{"type":"module"}' + await writeFile(join(cwd, 'package.json'), pkg) + await mkdir(join(cwd, 'src')) + const result = spawnSync(process.execPath, [bin, '--eject'], { + cwd, input: 'n\n', encoding: 'utf8', timeout: 30000, + }) + assert.ifError(result.error) + assert.equal(result.status, 0) + assert.match(result.stdout, /Continue\?/) + assert.match(result.stdout, /No action taken/) + assert.equal(await readFile(join(cwd, 'package.json'), 'utf8'), pkg) + await assert.rejects(readFile(join(cwd, 'src/layouts/root.layout.js')), { code: 'ENOENT' }) +}) + +test('invalid eject language fails without changing the project', async t => { + const cwd = await mkdtemp(join(tmpdir(), 'domstack-eject-invalid-')) + t.after(() => rm(cwd, { recursive: true, force: true })) + const pkg = '{"type":"module"}' + await writeFile(join(cwd, 'package.json'), pkg) + await assert.rejects(exec(process.execPath, [bin, '--eject', '--yes', '--language', 'tsx'], { cwd, timeout: 30000 }), /--language must be ts or js/) + assert.equal(await readFile(join(cwd, 'package.json'), 'utf8'), pkg) + await assert.rejects(readFile(join(cwd, 'src/layouts/root.layout.js')), { code: 'ENOENT' }) +})