diff --git a/packages/create-jaad/README.md b/packages/create-jaad/README.md index 9d81a23..bb0d408 100644 --- a/packages/create-jaad/README.md +++ b/packages/create-jaad/README.md @@ -22,7 +22,10 @@ Adds the JAAD wiring and dependencies, and leaves everything else alone. Your already has markdown, no sample page is written. If a root page already exists, the `site` template does not add a layout or landing page beside it. -Nothing is ever overwritten: a file that exists is reported as kept. +Nothing is ever overwritten. Configuration files previously generated by JAAD +are reported as kept. If the project has a custom `astro.config.*` or +`src/content.config.*`, the command stops before writing anything and points to +the [manual integration steps](https://jaad.lancher.dev/docs/getting-started/installation#existing-astro-project). ## Options diff --git a/packages/create-jaad/index.mjs b/packages/create-jaad/index.mjs index 8b12bc9..6518637 100755 --- a/packages/create-jaad/index.mjs +++ b/packages/create-jaad/index.mjs @@ -10,6 +10,26 @@ import { styleText } from "node:util"; const JAAD = "^0.6.1"; const ASTRO = "^7.3.1"; const TEMPLATES = ["docs", "site"]; +const ASTRO_CONFIG_NAMES = [ + "astro.config.ts", + "astro.config.mjs", + "astro.config.js", +]; +const CONTENT_CONFIG_NAMES = [ + "src/content.config.ts", + "src/content.config.mjs", + "src/content.config.js", +]; +const JAAD_CONFIG_NAMES = [ + "jaad.config.ts", + "jaad.config.mjs", + "jaad.config.js", +]; +const ASTRO_CONFIG = 'export { default } from "@lancher-dev/jaad/site";\n'; +const CONTENT_CONFIG = + 'export { collections } from "@lancher-dev/jaad/content";\n'; +const MANUAL_SETUP_URL = + "https://jaad.lancher.dev/docs/getting-started/installation#existing-astro-project"; const INDEX_PAGE_NAMES = [ "index.astro", "index.md", @@ -21,7 +41,7 @@ const INDEX_PAGE_NAMES = [ const HELP = `Usage: npm create @lancher-dev/jaad@latest [directory] [options] - --here Add JAAD to the current directory. + --here Set up JAAD in the current directory. --template Docs at /, or a landing page with docs at /docs. --title Site title. --install Install dependencies. @@ -121,12 +141,101 @@ const manifestPath = (target) => join(target, "package.json"); function readManifest(target) { const file = manifestPath(target); - if (!existsSync(file)) return null; + if (!existsSync(file)) return { manifest: null, issue: null }; + + let manifest; try { - return JSON.parse(readFileSync(file, "utf8")); + manifest = JSON.parse(readFileSync(file, "utf8")); } catch { + return { + manifest: null, + issue: "package.json is not valid JSON", + }; + } + + if (!isRecord(manifest)) { + return { + manifest: null, + issue: "package.json must contain a JSON object", + }; + } + + for (const key of ["scripts", "dependencies", "devDependencies"]) { + if (key in manifest && !isRecord(manifest[key])) { + return { + manifest: null, + issue: `package.json field ${key} must contain an object`, + }; + } + } + + return { manifest, issue: null }; +} + +const isRecord = (value) => + typeof value === "object" && value !== null && !Array.isArray(value); + +const normaliseSource = (source) => source.replace(/\r\n/g, "\n").trim(); + +function existingFiles(target, names) { + return names.filter((name) => existsSync(join(target, name))); +} + +function inspectConfig(target, names, canonical, label, issues) { + const files = existingFiles(target, names); + if (files.length > 1) { + issues.push(`multiple ${label} files found: ${files.join(", ")}`); return null; } + + const file = files[0] ?? null; + if ( + file && + canonical !== null && + normaliseSource(readFileSync(join(target, file), "utf8")) !== + normaliseSource(canonical) + ) { + issues.push(`${file} contains a custom ${label}`); + } + return file; +} + +/** Inspect everything before writing: a failed --here setup is a no-op. */ +function inspectExistingProject(target) { + const issues = []; + const { manifest, issue: manifestIssue } = readManifest(target); + if (manifestIssue) issues.push(manifestIssue); + + const astroConfig = inspectConfig( + target, + ASTRO_CONFIG_NAMES, + ASTRO_CONFIG, + "Astro configuration", + issues, + ); + const contentConfig = inspectConfig( + target, + CONTENT_CONFIG_NAMES, + CONTENT_CONFIG, + "content configuration", + issues, + ); + const jaadConfig = inspectConfig( + target, + JAAD_CONFIG_NAMES, + null, + "JAAD configuration", + issues, + ); + + if (issues.length > 0) { + fail( + `cannot safely update this project:\n${issues.map((item) => ` - ${item}`).join("\n")}\n` + + `No files were changed. Integrate JAAD manually: ${MANUAL_SETUP_URL}`, + ); + } + + return { manifest, astroConfig, contentConfig, jaadConfig }; } /** @@ -143,11 +252,21 @@ function writeManifest(target, existing, name) { preview: "astro preview", ...manifest.scripts, }; - manifest.dependencies = { - ...manifest.dependencies, - astro: manifest.dependencies?.astro ?? ASTRO, - "@lancher-dev/jaad": manifest.dependencies?.["@lancher-dev/jaad"] ?? JAAD, - }; + const dependencies = { ...manifest.dependencies }; + if ( + !("astro" in dependencies) && + !("astro" in (manifest.devDependencies ?? {})) + ) { + dependencies.astro = ASTRO; + } + if ( + !("@lancher-dev/jaad" in dependencies) && + !("@lancher-dev/jaad" in (manifest.devDependencies ?? {})) + ) { + dependencies["@lancher-dev/jaad"] = JAAD; + } + if (Object.keys(dependencies).length > 0) + manifest.dependencies = dependencies; writeFileSync(manifestPath(target), JSON.stringify(manifest, null, 2) + "\n"); } @@ -217,6 +336,16 @@ async function collectAnswers(args) { const target = resolve(args.here ? "." : args.dir); assertTargetAvailable(args, target); + const existing = args.here + ? inspectExistingProject(target) + : { + manifest: null, + astroConfig: null, + contentConfig: null, + jaadConfig: null, + }; + const manifest = existing.manifest; + if (!args.template) { const template = answer( await p.select({ @@ -240,7 +369,6 @@ async function collectAnswers(args) { args.template = template; } - const manifest = readManifest(target); const suggested = typeof manifest?.name === "string" && manifest.name ? titleFrom(manifest.name) @@ -268,7 +396,7 @@ async function collectAnswers(args) { args.install = install; } - return { ...args, target, manifest }; + return { ...args, target, manifest, existing }; } function jaadConfig(title, template) { @@ -387,33 +515,30 @@ const docsHref = base + "/docs"; } function scaffold(request) { - const { target, manifest, title, template } = request; + const { target, manifest, title, template, existing } = request; mkdirSync(target, { recursive: true }); const written = []; const skipped = []; - write( - target, - "astro.config.mjs", - 'export { default } from "@lancher-dev/jaad/site";\n', - written, - skipped, - ); - write( - target, - "jaad.config.ts", - jaadConfig(title, template), - written, - skipped, - ); - write( - target, - "src/content.config.ts", - 'export { collections } from "@lancher-dev/jaad/content";\n', - written, - skipped, - ); + if (existing.astroConfig) skipped.push(existing.astroConfig); + else write(target, "astro.config.mjs", ASTRO_CONFIG, written, skipped); + + if (existing.jaadConfig) skipped.push(existing.jaadConfig); + else { + write( + target, + "jaad.config.ts", + jaadConfig(title, template), + written, + skipped, + ); + } + + if (existing.contentConfig) skipped.push(existing.contentConfig); + else { + write(target, "src/content.config.ts", CONTENT_CONFIG, written, skipped); + } const ownsRoot = INDEX_PAGE_NAMES.some((name) => existsSync(join(target, "src", "pages", name)), diff --git a/tests/consumer/create.test.mjs b/tests/consumer/create.test.mjs index 38a7795..53fe1b9 100644 --- a/tests/consumer/create.test.mjs +++ b/tests/consumer/create.test.mjs @@ -28,6 +28,29 @@ function useLocalJaad(project, tarball) { let tarball; const pack = () => (tarball ??= packPackage(PKG)); +function snapshotFiles(directory) { + return walk(directory).map((file) => [ + file.slice(directory.length), + readFileSync(file).toString("base64"), + ]); +} + +function runScaffolderHere(project) { + return spawnSync( + "node", + [ + CLI, + "--here", + "--template", + "site", + "--title", + "Existing Project", + "--no-install", + ], + { cwd: project, encoding: "utf8" }, + ); +} + after(cleanupTemporaryDirectories); test( @@ -235,6 +258,135 @@ test( }, ); +test("--here changes nothing when existing configurations need a manual merge", () => { + const project = temporaryDirectory("jaad-here-config-conflict-"); + mkdirSync(join(project, "src"), { recursive: true }); + writeFileSync( + join(project, "package.json"), + JSON.stringify({ name: "existing-astro-project", private: true }, null, 2), + ); + writeFileSync( + join(project, "astro.config.mjs"), + 'import { defineConfig } from "astro/config";\nexport default defineConfig({});\n', + ); + writeFileSync( + join(project, "src", "content.config.ts"), + "export const collections = {};\n", + ); + + const before = snapshotFiles(project); + const result = runScaffolderHere(project); + const output = `${result.stdout}\n${result.stderr}`; + + assert.equal(result.status, 1); + assert.match(output, /cannot safely update this project/); + assert.match(output, /astro\.config\.mjs/); + assert.match(output, /src\/content\.config\.ts/); + assert.match(output, /No files were changed/); + assert.match(output, /existing-astro-project/); + assert.deepEqual(snapshotFiles(project), before); +}); + +test("--here recognizes canonical JAAD configurations on rerun", () => { + const project = temporaryDirectory("jaad-here-rerun-"); + mkdirSync(join(project, "src"), { recursive: true }); + writeFileSync( + join(project, "package.json"), + JSON.stringify( + { + name: "configured-project", + private: true, + devDependencies: { + astro: "^7.3.1", + "@lancher-dev/jaad": "^0.6.1", + }, + }, + null, + 2, + ), + ); + writeFileSync( + join(project, "astro.config.ts"), + 'export { default } from "@lancher-dev/jaad/site";\r\n', + ); + writeFileSync( + join(project, "src", "content.config.js"), + 'export { collections } from "@lancher-dev/jaad/content";\n', + ); + writeFileSync( + join(project, "jaad.config.mjs"), + 'export default { title: "Already configured" };\n', + ); + + const result = runScaffolderHere(project); + const output = `${result.stdout}\n${result.stderr}`; + assert.equal(result.status, 0, output); + assert.match(output, /kept\s+astro\.config\.ts/); + assert.match(output, /kept\s+src\/content\.config\.js/); + assert.match(output, /kept\s+jaad\.config\.mjs/); + assert.equal(existsSync(join(project, "astro.config.mjs")), false); + assert.equal(existsSync(join(project, "src", "content.config.ts")), false); + assert.equal(existsSync(join(project, "jaad.config.ts")), false); + + const manifest = JSON.parse( + readFileSync(join(project, "package.json"), "utf8"), + ); + assert.equal(manifest.dependencies, undefined); + assert.equal(manifest.devDependencies.astro, "^7.3.1"); + assert.equal(manifest.devDependencies["@lancher-dev/jaad"], "^0.6.1"); +}); + +test("--here recognizes every supported custom configuration filename", () => { + const candidates = [ + "astro.config.ts", + "astro.config.mjs", + "astro.config.js", + "src/content.config.ts", + "src/content.config.mjs", + "src/content.config.js", + ]; + + for (const candidate of candidates) { + const project = temporaryDirectory("jaad-here-config-name-"); + mkdirSync(dirname(join(project, candidate)), { recursive: true }); + writeFileSync(join(project, candidate), "export default {};\n"); + const before = snapshotFiles(project); + + const result = runScaffolderHere(project); + const output = `${result.stdout}\n${result.stderr}`; + assert.equal(result.status, 1, candidate); + assert.match(output, new RegExp(candidate.replaceAll(".", "\\."))); + assert.deepEqual(snapshotFiles(project), before, candidate); + } +}); + +test("--here preserves an invalid package manifest when it stops", () => { + const project = temporaryDirectory("jaad-here-invalid-manifest-"); + writeFileSync(join(project, "package.json"), "{ not json }\n"); + const before = snapshotFiles(project); + + const result = runScaffolderHere(project); + const output = `${result.stdout}\n${result.stderr}`; + assert.equal(result.status, 1); + assert.match(output, /package\.json is not valid JSON/); + assert.match(output, /No files were changed/); + assert.deepEqual(snapshotFiles(project), before); +}); + +test("--here rejects multiple JAAD configuration files without writing", () => { + const project = temporaryDirectory("jaad-here-multiple-config-"); + writeFileSync(join(project, "jaad.config.ts"), "export default {};\n"); + writeFileSync(join(project, "jaad.config.js"), "export default {};\n"); + const before = snapshotFiles(project); + + const result = runScaffolderHere(project); + const output = `${result.stdout}\n${result.stderr}`; + assert.equal(result.status, 1); + assert.match(output, /multiple JAAD configuration files/); + assert.match(output, /jaad\.config\.ts, jaad\.config\.js/); + assert.deepEqual(snapshotFiles(project), before); +}); + test("non-interactive requests are complete before files are written", () => { const cases = [ { diff --git a/www/docs/02-getting-started/01-installation.md b/www/docs/02-getting-started/01-installation.md index cd815cc..90403a3 100644 --- a/www/docs/02-getting-started/01-installation.md +++ b/www/docs/02-getting-started/01-installation.md @@ -33,6 +33,11 @@ npm create @lancher-dev/jaad@latest -- --here --template site It adds JAAD to the project you already have, keeps your `package.json` as it is, and leaves your existing markdown alone. +Before writing, `--here` checks whether it can complete the setup safely. Files +previously generated by JAAD are kept. If the project already has a custom +Astro or Content configuration, the command stops, lists the files that need a +manual merge, and changes nothing. + > [!NOTE] > In CI and other non-interactive environments every answer is required: a > destination or `--here`, `--template`, `--title`, and either `--install` or @@ -97,6 +102,49 @@ export { collections } from "@lancher-dev/jaad/content"; The other two are boilerplate: `astro.config.mjs` hands over to JAAD, `src/content.config.ts` tells Astro where the markdown lives. Neither needs to change again. +## Existing Astro project + +JAAD does not rewrite an existing Astro application configuration. If +`--here` reports a custom `astro.config.*` or `src/content.config.*`, integrate +the two parts manually. + +Install JAAD and create the same `jaad.config.ts` shown above. Then add JAAD to +the integrations in your existing Astro configuration: + +```ts +// astro.config.ts +import { defineConfig } from "astro/config"; +import jaad from "@lancher-dev/jaad"; +import jaadConfig from "./jaad.config"; + +export default defineConfig({ + // Keep the project's existing options and integrations. + integrations: [jaad(jaadConfig)], +}); +``` + +Keep your existing Astro options and integrations; only add the JAAD import and +the `jaad(jaadConfig)` entry. + +If the project already defines content collections, merge JAAD's `docsPages` +collection with them: + +```ts +// src/content.config.ts +import { defineCollection } from "astro:content"; +import { collections as jaadCollections } from "@lancher-dev/jaad/content"; + +const blog = defineCollection({/* your existing loader and schema */}); + +export const collections = { + blog, + ...jaadCollections, +}; +``` + +Keep the names of your existing collections and reserve `docsPages` for JAAD. +Finally, create `docs/` if it does not exist and start adding Markdown files. + > [!IMPORTANT] > Set `site`. Without it Astro cannot build absolute URLs, so canonical links and social card images are left out.