From 25ce481bc9b0486b4b6e317b2ad4bb26651e497e Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 25 Aug 2026 13:36:37 -0400 Subject: [PATCH] Add npx claude-translator init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing was four manual steps: clone, copy scripts, npm i parse5, copy config. Now it is one command, and the four steps still work for anyone who prefers them. bin/claude-translator.mjs copies the pipeline into scripts/i18n/ (or --dir), writes i18n.config.json, declares parse5, and appends the derived i18n/ paths to .gitignore — deliberately NOT i18n/tm/{lang}.json, because the memory is the asset and ignoring it is how people pay twice. Two rules it does not break: it never overwrites without --force, and it prints every path it touched. A scaffolder that quietly clobbers a config someone has edited is worse than no scaffolder, so both are covered by tests rather than by intent. The scripts are copied rather than run from node_modules on purpose. They resolve parse5 and every relative path from the project they live in, they are short enough to read, and this is AGPL software whose point is that you can change them. Packaging - "files" allowlist so the tarball carries bin, scripts, references, SKILL.md and the example config, and nothing else — 27 files - parse5 moved to devDependencies. Nothing in the published package needs it at runtime, so npx no longer downloads it; a clone plus npm install is unaffected Verified through the real package path rather than by running the source: npm pack, install the tarball into a clean project, run init, then drive extract -> translate -> build-locales -> verify to ALL GATES PASSED using a local stand-in provider. CI now does the same on every PR, so a broken published package fails the build instead of a user's first command. 12 CLI contract tests cover the refusal to install outside a Node project (and that it leaves nothing behind), idempotency, --force, --dir, appending to an existing .gitignore rather than replacing it, and that package.json "files" actually ships what init copies. Not yet published to npm — no credentials on this machine. Until it is, `npx claude-translator init` will not resolve; the README documents running the scaffolder from a clone as the alternative. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 16 ++- CHANGELOG.md | 24 ++++ README.md | 40 +++++-- SKILL.md | 20 ++-- bin/claude-translator.mjs | 230 ++++++++++++++++++++++++++++++++++++++ bin/cli.test.mjs | 165 +++++++++++++++++++++++++++ package.json | 20 +++- scripts/credit.mjs | 2 +- 8 files changed, 492 insertions(+), 25 deletions(-) create mode 100755 bin/claude-translator.mjs create mode 100644 bin/cli.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 761550d..27a3185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,13 @@ jobs: - name: Every script parses run: | - for f in scripts/*.mjs scripts/providers/*.mjs; do + for f in scripts/*.mjs scripts/providers/*.mjs bin/*.mjs; do node --check "$f" || { echo "::error file=$f::syntax error"; exit 1; } done bash -n scripts/finalize.sh echo "all scripts parse" - - name: Provider contract tests + - name: Contract tests (providers + CLI) run: npm test - name: Example config is valid JSON @@ -46,6 +46,18 @@ jobs: fi echo "no key material found" + - name: Packaged CLI installs and scaffolds + run: | + npm pack --pack-destination /tmp + mkdir -p /tmp/cli-smoke && cd /tmp/cli-smoke + npm init -y > /dev/null + npm install --no-audit --no-fund --save-dev /tmp/claude-translator-*.tgz + ./node_modules/.bin/claude-translator init + test -f scripts/i18n/extract.mjs || { echo "::error::init did not scaffold"; exit 1; } + test -f i18n.config.json || { echo "::error::init wrote no config"; exit 1; } + node -e "JSON.parse(require('fs').readFileSync('i18n.config.json','utf8'))" + echo "packaged CLI works" + - name: Config loader smoke test run: | mkdir -p /tmp/fixture/dist/about /tmp/fixture/dist/pricing diff --git a/CHANGELOG.md b/CHANGELOG.md index f7710e5..ecaef9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] — 2026-08-25 + +### Added + +- **`npx claude-translator init`** — a scaffolder that replaces the four manual install + steps. Copies the pipeline into `scripts/i18n/` (or `--dir`), writes `i18n.config.json`, + declares `parse5`, and appends the derived `i18n/` paths to `.gitignore`. It never + overwrites without `--force` and prints every path it touched +- `--help`, `--version`, `--dir`, `--force` +- **12 CLI contract tests** (`npm test`) covering the refusal to install outside a Node + project, idempotency, `--force`, `--dir`, appending rather than replacing an existing + `.gitignore`, and that `package.json` "files" actually ships what `init` copies +- CI now packs the tarball, installs it into a scratch project and runs `init`, so a + broken published package fails the build rather than a user's first command + +### Changed + +- `parse5` moved from `dependencies` to `devDependencies`. Nothing in the published + package needs it at runtime — the CLI only copies files — so `npx` no longer downloads + it. A clone plus `npm install` is unaffected +- `package.json` gained a `files` allowlist, so the tarball carries the scripts, + references, `SKILL.md` and the example config, and nothing else + ## [1.2.0] — 2026-08-25 Renamed to **Claude Translator**, and the translation step is no longer tied to one vendor. @@ -106,6 +129,7 @@ into dozens of languages. and cost, and adapting to other static site generators - `SKILL.md`, so the repository can be installed directly as a Claude Code skill +[1.3.0]: https://github.com/ConveyThis/claude-translator/releases/tag/v1.3.0 [1.2.0]: https://github.com/ConveyThis/claude-translator/releases/tag/v1.2.0 [1.1.0]: https://github.com/ConveyThis/claude-translator/releases/tag/v1.1.0 [1.0.0]: https://github.com/ConveyThis/claude-translator/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 0cd8832..b9e5092 100644 --- a/README.md +++ b/README.md @@ -99,16 +99,15 @@ The builder restores the original tags by index. ```bash # 1. Install into your project -git clone https://github.com/ConveyThis/claude-translator.git -cp -r claude-translator/scripts /scripts/i18n -cd && npm install --save-dev parse5 +cd +npx claude-translator init +npm install # parse5, the only dependency -# 2. Configure -cp ../claude-translator/i18n.config.example.json i18n.config.json +# 2. Configure — baseUrl, locales, provider $EDITOR i18n.config.json -# 3. Provide your key (or skip entirely for a local model) -echo "ANTHROPIC_API_KEY=your-key-here" >> .env # make sure .env is gitignored +# 3. Provide your key (skip entirely for a local model) +echo "ANTHROPIC_API_KEY=your-key-here" >> .env # .gitignore this # 4. Run npm run build # your normal build @@ -121,6 +120,29 @@ node scripts/i18n/audit-seo.mjs # full SEO audit Deploy the resulting build directory exactly as you deploy it today. +`init` copies the pipeline into `scripts/i18n/`, writes a config, declares `parse5`, and adds +the derived `i18n/` paths to `.gitignore`. It **never overwrites anything without `--force`** +and prints every file it touched. `--dir ` puts the scripts somewhere else. + +
+Installing without npx + +```bash +git clone https://github.com/ConveyThis/claude-translator.git +cp -r claude-translator/scripts /scripts/i18n +cp claude-translator/i18n.config.example.json /i18n.config.json +cd && npm install --save-dev parse5 +``` + +Or run the scaffolder straight from the clone: +`node claude-translator/bin/claude-translator.mjs init` + +
+ +The scripts are copied into your project rather than run from `node_modules` on purpose: they +resolve `parse5` and every relative path from the project they live in, they are short enough to +read, and this is AGPL software whose point is that you can change them. + --- ## Configuration @@ -340,8 +362,8 @@ git clone https://github.com/ConveyThis/claude-translator.git \ ~/.claude/skills/claude-translator ``` -Then ask Claude to "localize this site" and it will follow `SKILL.md`, including the -failure modes documented in `references/`. +Then ask Claude to "localize this site". It follows `SKILL.md`, including the failure modes in +`references/` and the routing rules for when this is the wrong tool entirely. --- diff --git a/SKILL.md b/SKILL.md index 7c8339f..2c40955 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,7 +14,7 @@ argument-hint: "[project-dir]" license: AGPL-3.0 metadata: author: ConveyThis - version: "1.2.0" + version: "1.3.0" category: i18n --- @@ -97,19 +97,21 @@ do not take the same parameters and guessing costs money. ## Setup ```bash -cp ~/.claude/skills/claude-translator/scripts/*.mjs /scripts/ -cp ~/.claude/skills/claude-translator/scripts/*.sh /scripts/ -cp ~/.claude/skills/claude-translator/i18n.config.example.json /i18n.config.json -cd && npm i -D parse5 # the only dependency +cd +node ~/.claude/skills/claude-translator/bin/claude-translator.mjs init +npm install # parse5, the only dependency ``` +That copies the pipeline into `scripts/i18n/`, writes `i18n.config.json`, declares +`parse5` and adds the derived paths to `.gitignore`. It never overwrites without +`--force`, so it is safe to re-run; `--dir ` puts the scripts elsewhere. + Scripts run **from inside the project** so `parse5` and relative paths resolve. Edit `i18n.config.json` — five keys cover everything; see `i18n.config.example.json`. -Add to `.gitignore`: `i18n/source.json`, `i18n/manifest.json`, `i18n/segments/`, -`i18n/tm/*.failures.json`, `i18n/tm/*.review.json`, and `i18n` in `.prettierignore`. -**Commit `i18n/tm/{lang}.json`** — the memory is the asset; losing it means paying for a -full re-translation. +**Commit `i18n/tm/{lang}.json`.** The scaffolder deliberately does not ignore it: the +memory is the asset, and losing it means paying for a full re-translation. Everything +else under `i18n/` is derived and is ignored for you. Add `i18n` to `.prettierignore`. ## Pipeline diff --git a/bin/claude-translator.mjs b/bin/claude-translator.mjs new file mode 100755 index 0000000..8b12df3 --- /dev/null +++ b/bin/claude-translator.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * claude-translator — project scaffolder. + * + * npx claude-translator init + * + * Does in one command what the README's four manual steps do: copies the pipeline into + * your project, writes a config, adds the one dependency, and tells you what to run next. + * + * ── Why it copies the scripts instead of running them from node_modules ────── + * The scripts are meant to be yours. They resolve `parse5` and every relative path from + * the project they sit in, they are short enough to read, and this is AGPL software whose + * whole point is that you can change it. Vendoring them keeps all of that true. The + * alternative — a black-box binary reaching into your build output — is the thing this + * project exists as an alternative to. + * + * ── The one rule ──────────────────────────────────────────────────────────── + * Never overwrite anything the user has without --force, and print every path touched. + * A scaffolder that silently clobbers a config people have edited is worse than no + * scaffolder at all. + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs'; +import { join, dirname, resolve, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')); + +// ── Output ─────────────────────────────────────────────────────────────────── + +const c = process.stdout.isTTY + ? { dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[32m', yellow: '\x1b[33m', reset: '\x1b[0m' } + : { dim: '', bold: '', green: '', yellow: '', reset: '' }; + +const wrote = []; +const skipped = []; + +const say = (s = '') => console.log(s); +const ok = (path) => wrote.push(path); +const skip = (path, why) => skipped.push(`${path} ${c.dim}(${why})${c.reset}`); + +// ── CLI ────────────────────────────────────────────────────────────────────── + +const HELP = ` +${c.bold}claude-translator${c.reset} ${pkg.version} +Static-site localization: translate a built site into dozens of languages as real +static pages, without re-rendering and without breaking Core Web Vitals. + +${c.bold}USAGE${c.reset} + npx claude-translator init [options] + +${c.bold}OPTIONS${c.reset} + --dir Where to put the pipeline scripts (default: scripts/i18n) + --force Overwrite files that already exist (default: never) + --help, -h Show this + --version, -v Print the version + +${c.bold}AFTER INIT${c.reset} + npm install install parse5, the only dependency + \$EDITOR i18n.config.json set baseUrl, locales, provider + node /extract.mjs find translatable units + node /translate.mjs --lang es,fr translate + node /build-locales.mjs --lang all write the localized pages + node /verify.mjs --lang all six gates + node /audit-seo.mjs full SEO audit + +${c.bold}DOCS${c.reset} https://github.com/ConveyThis/claude-translator +`; + +const argv = process.argv.slice(2); +const has = (...names) => names.some((n) => argv.includes(n)); +const valueOf = (name, fallback) => { + const i = argv.indexOf(name); + return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback; +}; + +if (has('--help', '-h') || argv.length === 0) { + say(HELP); + process.exit(0); +} +if (has('--version', '-v')) { + say(pkg.version); + process.exit(0); +} + +const command = argv[0]; +if (command !== 'init') { + console.error(`Unknown command "${command}". Run \`npx claude-translator --help\`.`); + process.exit(1); +} + +const FORCE = has('--force'); +const DIR = valueOf('--dir', 'scripts/i18n'); +const CWD = process.cwd(); + +// ── Guard: is this a project? ──────────────────────────────────────────────── +// Scattering files into whatever directory someone happened to be in is the kind of +// thing people remember about a tool. + +const pkgJsonPath = join(CWD, 'package.json'); +if (!existsSync(pkgJsonPath)) { + console.error(`No package.json in ${CWD} + +claude-translator init installs into a Node project, because the scripts need parse5 +and resolve paths from the project root. Run it from your project, or create one first: + + npm init -y && npx claude-translator init +`); + process.exit(1); +} + +say(`\n${c.bold}claude-translator ${pkg.version}${c.reset} → ${CWD}\n`); + +// ── 1. The pipeline scripts ────────────────────────────────────────────────── + +/** Copy one file, honouring --force, recording what happened. */ +function place(from, to, label = relative(CWD, to)) { + if (existsSync(to) && !FORCE) { + skip(label, 'exists — use --force to replace'); + return; + } + mkdirSync(dirname(to), { recursive: true }); + copyFileSync(from, to); + ok(label); +} + +const targetDir = resolve(CWD, DIR); +const srcDir = join(PKG_ROOT, 'scripts'); + +for (const entry of readdirSync(srcDir)) { + const from = join(srcDir, entry); + if (statSync(from).isDirectory()) { + for (const sub of readdirSync(from)) { + place(join(from, sub), join(targetDir, entry, sub)); + } + continue; + } + if (!/\.(mjs|sh)$/.test(entry)) continue; + place(from, join(targetDir, entry)); +} + +// ── 2. The config ──────────────────────────────────────────────────────────── + +place(join(PKG_ROOT, 'i18n.config.example.json'), join(CWD, 'i18n.config.json'), 'i18n.config.json'); + +// ── 3. The one dependency ──────────────────────────────────────────────────── +// Written into package.json rather than installed here: running npm from inside npx is +// slow, can pick the wrong package manager, and rewrites a lockfile the user did not ask +// us to touch. Editing the manifest and saying "now run install" is the honest version. + +let needsInstall = false; +{ + const manifest = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + const declared = + manifest.dependencies?.parse5 ?? manifest.devDependencies?.parse5 ?? manifest.peerDependencies?.parse5; + if (declared) { + skip('package.json', `parse5 already declared (${declared})`); + } else { + manifest.devDependencies = { ...(manifest.devDependencies ?? {}), parse5: '^7.3.0' }; + // Keep devDependencies sorted so the diff is one line, not a reshuffle. + manifest.devDependencies = Object.fromEntries(Object.entries(manifest.devDependencies).sort()); + writeFileSync(pkgJsonPath, `${JSON.stringify(manifest, null, 2)}\n`); + ok('package.json (added parse5 to devDependencies)'); + needsInstall = true; + } +} + +// ── 4. .gitignore ──────────────────────────────────────────────────────────── +// The memory files are the asset. Everything else under i18n/ is derived and noisy. + +const GITIGNORE_BLOCK = [ + '', + '# claude-translator — derived files. Do NOT ignore i18n/tm/{lang}.json:', + '# the translation memory is the asset, and losing it means paying to rebuild it.', + 'i18n/source.json', + 'i18n/manifest.json', + 'i18n/segments/', + 'i18n/seo-audit.json', + 'i18n/tm/*.failures.json', + 'i18n/tm/*.review.json', + '', +]; + +{ + const gitignorePath = join(CWD, '.gitignore'); + const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''; + if (existing.includes('claude-translator — derived files')) { + skip('.gitignore', 'already has the block'); + } else { + writeFileSync(gitignorePath, existing.replace(/\n*$/, '\n') + GITIGNORE_BLOCK.join('\n')); + ok(existing ? '.gitignore (appended)' : '.gitignore'); + } +} + +// ── Report ─────────────────────────────────────────────────────────────────── + +if (wrote.length) { + say(`${c.green}Wrote${c.reset}`); + for (const w of wrote) say(` ${w}`); +} +if (skipped.length) { + say(`\n${c.yellow}Left alone${c.reset}`); + for (const s of skipped) say(` ${s}`); +} + +// Column width is computed, not guessed: --dir changes how long these commands are, and +// a hardcoded pad lets the longest one collide with its own description. +const rows = [ + ...(needsInstall ? [['npm install', 'parse5, the only dependency']] : []), + ['$EDITOR i18n.config.json', 'baseUrl, locales, provider'], + [], + ['npm run build', 'your normal build, source language only'], + [`node ${DIR}/extract.mjs`, 'find translatable units'], + [`node ${DIR}/translate.mjs --lang es,fr`, 'translate (needs a provider key)'], + [`node ${DIR}/build-locales.mjs --lang all`, 'write the localized pages'], + [`node ${DIR}/verify.mjs --lang all`, 'six gates'], + [`node ${DIR}/audit-seo.mjs`, 'full SEO audit'], +]; +const width = Math.max(...rows.filter((r) => r.length).map(([cmd]) => cmd.length)) + 2; + +say(`\n${c.bold}Next${c.reset}`); +for (const row of rows) { + if (!row.length) { say(''); continue; } + const [cmd, note] = row; + say(` ${cmd.padEnd(width)}${c.dim}${note}${c.reset}`); +} + +say(`\n${c.dim}Deploy the build directory exactly as you deploy it today.`); +say(`Docs: https://github.com/ConveyThis/claude-translator${c.reset}\n`); diff --git a/bin/cli.test.mjs b/bin/cli.test.mjs new file mode 100644 index 0000000..0e3669c --- /dev/null +++ b/bin/cli.test.mjs @@ -0,0 +1,165 @@ +/** + * CLI contract tests. + * + * node --test bin/cli.test.mjs + * + * `init` writes into whatever directory it is run from, so every test here works in a + * throwaway directory under os.tmpdir() and never touches the repository. + * + * The point of these is the promise the CLI makes in its own help text: it does not + * overwrite what you already have, and it tells you every path it touched. A scaffolder + * that quietly breaks that promise is worse than no scaffolder. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const CLI = resolve(dirname(fileURLToPath(import.meta.url)), 'claude-translator.mjs'); +const PKG = JSON.parse(readFileSync(resolve(dirname(CLI), '..', 'package.json'), 'utf8')); + +/** Run the CLI in a fresh directory. Returns { stdout, status }. */ +function run(args, { project = true, dir } = {}) { + const cwd = dir ?? mkdtempSync(join(tmpdir(), 'ct-cli-')); + if (project && !existsSync(join(cwd, 'package.json'))) { + writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0' }, null, 2)); + } + try { + const stdout = execFileSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' }); + return { stdout, status: 0, cwd }; + } catch (err) { + return { stdout: `${err.stdout ?? ''}${err.stderr ?? ''}`, status: err.status, cwd }; + } +} + +test('--version prints the package version', () => { + const { stdout, status } = run(['--version']); + assert.equal(status, 0); + assert.equal(stdout.trim(), PKG.version); +}); + +test('--help documents every command it accepts, and no command it does not', () => { + const { stdout, status } = run(['--help']); + assert.equal(status, 0); + for (const flag of ['init', '--dir', '--force', '--help', '--version']) { + assert.ok(stdout.includes(flag), `help omits ${flag}`); + } + // Every pipeline step the help promises must be a script the package actually ships. + for (const s of ['extract.mjs', 'translate.mjs', 'build-locales.mjs', 'verify.mjs', 'audit-seo.mjs']) { + assert.ok(stdout.includes(s), `help omits ${s}`); + assert.ok(existsSync(resolve(dirname(CLI), '..', 'scripts', s)), `help names ${s}, which does not exist`); + } +}); + +test('bare invocation shows help rather than doing something', () => { + const { stdout, status } = run([]); + assert.equal(status, 0); + assert.ok(stdout.includes('USAGE')); +}); + +test('an unknown command fails loudly', () => { + const { status, stdout } = run(['frobnicate']); + assert.equal(status, 1); + assert.ok(/unknown command/i.test(stdout)); +}); + +test('refuses to install outside a Node project, and leaves nothing behind', () => { + const cwd = mkdtempSync(join(tmpdir(), 'ct-cli-')); + const { status, stdout } = run(['init'], { project: false, dir: cwd }); + assert.equal(status, 1); + assert.ok(stdout.includes('No package.json')); + assert.equal(existsSync(join(cwd, 'scripts')), false, 'scattered files into a non-project'); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('init scaffolds a runnable pipeline', () => { + const { stdout, status, cwd } = run(['init']); + assert.equal(status, 0); + + for (const f of [ + 'scripts/i18n/extract.mjs', + 'scripts/i18n/translate.mjs', + 'scripts/i18n/build-locales.mjs', + 'scripts/i18n/verify.mjs', + 'scripts/i18n/audit-seo.mjs', + 'scripts/i18n/config.mjs', + 'scripts/i18n/credit.mjs', + 'scripts/i18n/providers/index.mjs', + 'scripts/i18n/providers/anthropic.mjs', + 'i18n.config.json', + ]) { + assert.ok(existsSync(join(cwd, f)), `init did not write ${f}`); + assert.ok(stdout.includes(f.split('/').pop()), `init wrote ${f} without reporting it`); + } + + const manifest = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + assert.ok(manifest.devDependencies?.parse5, 'parse5 was not declared'); + assert.ok(readFileSync(join(cwd, '.gitignore'), 'utf8').includes('i18n/segments/')); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('the config it writes is valid JSON with the keys the scripts require', () => { + const { cwd } = run(['init']); + const cfg = JSON.parse(readFileSync(join(cwd, 'i18n.config.json'), 'utf8')); + for (const key of ['buildDir', 'baseUrl', 'locales']) { + assert.ok(key in cfg, `config is missing ${key}`); + } + rmSync(cwd, { recursive: true, force: true }); +}); + +test('a second init overwrites nothing and says so', () => { + const { cwd } = run(['init']); + const configPath = join(cwd, 'i18n.config.json'); + writeFileSync(configPath, '{"buildDir":"MINE"}'); + + const { stdout, status } = run(['init'], { dir: cwd }); + assert.equal(status, 0); + assert.equal(JSON.parse(readFileSync(configPath, 'utf8')).buildDir, 'MINE', 'clobbered an edited config'); + assert.ok(stdout.includes('Left alone')); + assert.ok(stdout.includes('already has the block'), 'appended the gitignore block twice'); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('--force is the only way to replace an edited file', () => { + const { cwd } = run(['init']); + const configPath = join(cwd, 'i18n.config.json'); + writeFileSync(configPath, '{"buildDir":"MINE"}'); + + run(['init', '--force'], { dir: cwd }); + assert.notEqual(JSON.parse(readFileSync(configPath, 'utf8')).buildDir, 'MINE', '--force did not replace'); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('--dir puts the scripts where it is told', () => { + const { cwd, stdout } = run(['init', '--dir', 'tools/localize']); + assert.ok(existsSync(join(cwd, 'tools/localize/extract.mjs'))); + assert.equal(existsSync(join(cwd, 'scripts/i18n')), false); + // The next-steps block must reference the chosen directory, not the default. + assert.ok(stdout.includes('tools/localize/extract.mjs')); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('an existing .gitignore is appended to, not replaced', () => { + const cwd = mkdtempSync(join(tmpdir(), 'ct-cli-')); + writeFileSync(join(cwd, 'package.json'), '{"name":"f","version":"1.0.0"}'); + writeFileSync(join(cwd, '.gitignore'), 'node_modules\n.env\n'); + run(['init'], { dir: cwd }); + const gi = readFileSync(join(cwd, '.gitignore'), 'utf8'); + assert.ok(gi.includes('node_modules'), 'dropped existing entries'); + assert.ok(gi.includes('.env'), 'dropped existing entries'); + assert.ok(gi.includes('i18n/segments/'), 'did not append ours'); + rmSync(cwd, { recursive: true, force: true }); +}); + +test('package.json "files" ships everything init needs to copy', () => { + const root = resolve(dirname(CLI), '..'); + for (const needed of ['bin', 'scripts', 'i18n.config.example.json']) { + assert.ok(PKG.files.includes(needed), `"files" omits ${needed}; npx would install a broken package`); + } + assert.equal(PKG.bin['claude-translator'], 'bin/claude-translator.mjs'); + assert.ok(existsSync(join(root, PKG.bin['claude-translator'])), 'bin path does not exist'); +}); diff --git a/package.json b/package.json index e709389..7e59f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-translator", - "version": "1.2.0", + "version": "1.3.0", "description": "Translate a static website into dozens of languages as real static pages, by substituting into built HTML instead of re-rendering.", "type": "module", "license": "AGPL-3.0-or-later", @@ -32,7 +32,19 @@ "engines": { "node": ">=20" }, - "dependencies": { + "bin": { + "claude-translator": "bin/claude-translator.mjs" + }, + "files": [ + "bin", + "scripts", + "references", + "SKILL.md", + "LICENSING.md", + "CHANGELOG.md", + "i18n.config.example.json" + ], + "devDependencies": { "parse5": "^7.3.0" }, "scripts": { @@ -42,7 +54,7 @@ "i18n:build": "node scripts/build-locales.mjs", "i18n:verify": "node scripts/verify.mjs", "i18n:audit": "node scripts/audit-seo.mjs", - "check": "for f in scripts/*.mjs scripts/providers/*.mjs; do node --check \"$f\" || exit 1; done && echo 'all scripts parse'", - "test": "node --test scripts/providers/providers.test.mjs" + "check": "for f in scripts/*.mjs scripts/providers/*.mjs bin/*.mjs; do node --check \"$f\" || exit 1; done && echo 'all scripts parse'", + "test": "node --test scripts/providers/providers.test.mjs bin/cli.test.mjs" } } diff --git a/scripts/credit.mjs b/scripts/credit.mjs index 1f86f45..a40481e 100644 --- a/scripts/credit.mjs +++ b/scripts/credit.mjs @@ -32,7 +32,7 @@ import { CREDIT } from './config.mjs'; -export const VERSION = '1.2.0'; +export const VERSION = '1.3.0'; /** * The product name written into every localized page's generator tag.