diff --git a/.gitignore b/.gitignore index ea7f0468..ea3fd6e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # build output dist +packages/*/dist +packages/*/dist-test # dependencies node_modules/ @@ -27,4 +29,4 @@ yarn-error.log* .astro # cursor -.cursor \ No newline at end of file +.cursor diff --git a/README.md b/README.md index 569dec22..9fa56c9a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,24 @@ Clone this theme locally and run any of the following commands in your terminal: | `npm run build` | Build your production site to `./dist/` | | `npm run preview` | Preview your build locally, before deploying | +## Create a project with the CLI + +You can now scaffold projects directly from this starter with the create CLI: + +```bash +npm create accessible-astro-starter@latest +``` + +The CLI walks you through: + +- project directory +- site name +- preset selection (`full`, `blog`, `portfolio`, `minimal`, `barebones`) +- whether to keep the Accessible Astro launcher + +Generated projects always strip contributor-only workspace tooling such as `scripts/workspace-config.js` and simplify +`astro.config.mjs` accordingly. + ## Accessible Astro ecosystem The Accessible Astro ecosystem is a collection of projects that are designed to help you build accessible web applications. It includes: diff --git a/package-lock.json b/package-lock.json index a814131e..4c6a4c73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "accessible-astro-starter", - "version": "5.1.1", + "version": "5.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessible-astro-starter", - "version": "5.1.1", + "version": "5.2.0", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@astrojs/sitemap": "^3.7.2", "@tailwindcss/vite": "^4.2.4", @@ -4184,6 +4187,15 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -4298,12 +4310,14 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT" + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } }, "node_modules/cookie": { "version": "1.1.1", @@ -4326,6 +4340,10 @@ "dev": true, "license": "MIT" }, + "node_modules/create-accessible-astro-starter": { + "resolved": "packages/create-accessible-astro-starter", + "link": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4596,7 +4614,6 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, "license": "MIT" }, "node_modules/dequal": { @@ -5905,6 +5922,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -7438,6 +7472,25 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/local-pkg/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8837,7 +8890,6 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, "license": "MIT" }, "node_modules/node-mock-http": { @@ -8870,6 +8922,29 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9316,7 +9391,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pend": { @@ -9351,18 +9425,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", - "pathe": "^2.0.3" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10564,7 +10626,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, "license": "MIT" }, "node_modules/sitemap": { @@ -11018,7 +11079,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11218,7 +11278,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12392,6 +12451,62 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/create-accessible-astro-starter": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.10.1", + "giget": "^2.0.0" + }, + "bin": { + "create-accessible-astro-starter": "dist/src/index.js" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "packages/create-accessible-astro-starter/node_modules/@clack/core": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", + "integrity": "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "packages/create-accessible-astro-starter/node_modules/@clack/prompts": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.10.1.tgz", + "integrity": "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.2", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "packages/create-accessible-astro-starter/node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/create-accessible-astro-starter/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 86394592..78422351 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,23 @@ { "name": "accessible-astro-starter", "description": "An Accessible Starter Theme for Astro including several accessibility features and tools to help you build faster.", - "version": "5.1.1", + "version": "5.2.0", "author": "Incluud", "license": "MIT", "type": "module", "homepage": "https://accessible-astro-starter.incluud.dev/", + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "astro dev", "start": "astro dev", "build": "astro build", - "preview": "astro preview" + "preview": "astro preview", + "build:cli": "npm --workspace create-accessible-astro-starter run build", + "create:local": "node packages/create-accessible-astro-starter/scripts/run-local.mjs", + "test:cli": "npm --workspace create-accessible-astro-starter run test", + "test:cli:e2e": "npm --workspace create-accessible-astro-starter run test:e2e" }, "keywords": [ "astro", diff --git a/packages/create-accessible-astro-starter/README.md b/packages/create-accessible-astro-starter/README.md new file mode 100644 index 00000000..b50d5336 --- /dev/null +++ b/packages/create-accessible-astro-starter/README.md @@ -0,0 +1,31 @@ +# create-accessible-astro-starter + +Create a new Accessible Astro Starter project with guided presets and optional launcher support. + +## Usage + +```bash +npm create accessible-astro-starter@latest +``` + +## Local development + +From the starter repo root, run: + +```bash +npm install +npm run build:cli +npm run test:cli +``` + +From this package directory, use `npm run build` and `npm run test` for package-local checks. + +## Test the CLI locally + +From the starter repo root, run: + +```bash +npm run create:local -- my-demo-site +``` + +The first positional argument sets the output directory. The later site name prompt only changes generated project metadata. diff --git a/packages/create-accessible-astro-starter/package.json b/packages/create-accessible-astro-starter/package.json new file mode 100644 index 00000000..e6c07d38 --- /dev/null +++ b/packages/create-accessible-astro-starter/package.json @@ -0,0 +1,35 @@ +{ + "name": "create-accessible-astro-starter", + "version": "5.2.0", + "description": "Create a new Accessible Astro Starter project with guided presets.", + "license": "MIT", + "type": "module", + "bin": { + "create-accessible-astro-starter": "./dist/src/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "prebuild": "node ./scripts/clean-output.mjs dist", + "build": "tsc -p tsconfig.json", + "prebuild:test": "node ./scripts/clean-output.mjs dist-test", + "build:test": "tsc -p tsconfig.test.json", + "test": "npm run build && npm run build:test && node --test dist-test/test/scaffold.test.js dist-test/test/cli-output.test.js", + "test:e2e": "npm run build && npm run build:test && node dist-test/test/e2e.js" + }, + "dependencies": { + "@clack/prompts": "^0.10.1", + "giget": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-accessible-astro-starter/scripts/clean-output.mjs b/packages/create-accessible-astro-starter/scripts/clean-output.mjs new file mode 100644 index 00000000..88e68bce --- /dev/null +++ b/packages/create-accessible-astro-starter/scripts/clean-output.mjs @@ -0,0 +1,11 @@ +import { rm } from 'node:fs/promises' +import { resolve } from 'node:path' + +const outputDirectory = process.argv[2] + +if (!outputDirectory) { + console.error('Missing output directory.') + process.exitCode = 1 +} else { + await rm(resolve(process.cwd(), outputDirectory), { recursive: true, force: true }) +} diff --git a/packages/create-accessible-astro-starter/scripts/run-local.mjs b/packages/create-accessible-astro-starter/scripts/run-local.mjs new file mode 100644 index 00000000..dc7ccc4c --- /dev/null +++ b/packages/create-accessible-astro-starter/scripts/run-local.mjs @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDirectory, '../../..') +const cliEntry = resolve(repoRoot, 'packages/create-accessible-astro-starter/dist/src/index.js') +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' + +function run(command, args, options = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd: repoRoot, + stdio: 'inherit', + ...options, + }) + + child.on('error', rejectPromise) + child.on('exit', (code) => { + if (code === 0) { + resolvePromise() + return + } + + rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) + }) + }) +} + +try { + await run(npmCommand, ['run', 'build:cli']) + await run(process.execPath, [cliEntry, ...process.argv.slice(2)], { + cwd: process.cwd(), + env: { + ...process.env, + ACCESSIBLE_ASTRO_STARTER_TEMPLATE_DIR: repoRoot, + }, + }) +} catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + console.error(`\nError: ${message}`) + process.exitCode = 1 +} diff --git a/packages/create-accessible-astro-starter/src/cli-output.ts b/packages/create-accessible-astro-starter/src/cli-output.ts new file mode 100644 index 00000000..b40f35d6 --- /dev/null +++ b/packages/create-accessible-astro-starter/src/cli-output.ts @@ -0,0 +1,99 @@ +const RESET = '\u001B[0m' +const GRAY = '\u001B[90m' + +const INTRO_TITLE = 'Accessible Astro' + +const INTRO_BANNER_LINES = [ + '░█▀█░█▀▀░█▀▀░█▀▀░█▀▀░█▀▀░▀█▀░█▀▄░█░░░█▀▀░░░█▀█░█▀▀░▀█▀░█▀▄░█▀█', + '░█▀█░█░░░█░░░█▀▀░▀▀█░▀▀█░░█░░█▀▄░█░░░█▀▀░░░█▀█░▀▀█░░█░░█▀▄░█░█', + '░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀░░▀▀▀░▀▀▀░░░▀░▀░▀▀▀░░▀░░▀░▀░▀▀▀', +] + +const MIN_BANNER_COLUMNS = Math.max(...INTRO_BANNER_LINES.map((line) => line.length)) + 4 + +const INTRO_GRADIENT = [ + { r: 163, g: 230, b: 53 }, + { r: 45, g: 212, b: 191 }, + { r: 168, g: 85, b: 247 }, +] + +export const outroMessage = 'Go make the internet a more accessible place! ✨' + +type RenderOptions = { + color?: boolean + columns?: number +} + +type Rgb = { + r: number + g: number + b: number +} + +function shouldUseColor(): boolean { + if (process.env.FORCE_COLOR === '0') { + return false + } + + if (process.env.FORCE_COLOR !== undefined) { + return true + } + + return !process.env.NO_COLOR && Boolean(process.stdout.isTTY) +} + +function colorize(value: string, open: string, color: boolean): string { + return color ? `${open}${value}${RESET}` : value +} + +function gray(value: string, color: boolean): string { + return colorize(value, GRAY, color) +} + +function interpolateColor(stops: Rgb[], progress: number): Rgb { + if (stops.length === 1) { + return stops[0] + } + + const clampedProgress = Math.min(Math.max(progress, 0), 1) + const scaledProgress = clampedProgress * (stops.length - 1) + const startIndex = Math.min(Math.floor(scaledProgress), stops.length - 2) + const endIndex = startIndex + 1 + const segmentProgress = scaledProgress - startIndex + const start = stops[startIndex] + const end = stops[endIndex] + + return { + r: Math.round(start.r + (end.r - start.r) * segmentProgress), + g: Math.round(start.g + (end.g - start.g) * segmentProgress), + b: Math.round(start.b + (end.b - start.b) * segmentProgress), + } +} + +function rgb(value: string, { r, g, b }: Rgb, color: boolean): string { + return colorize(value, `\u001B[38;2;${r};${g};${b}m`, color) +} + +function gradient(value: string, stops: Rgb[], color: boolean): string { + if (!color || value.length === 0) { + return value + } + + return Array.from(value) + .map((character, index, characters) => + rgb(character, interpolateColor(stops, index / Math.max(characters.length - 1, 1)), true), + ) + .join('') +} + +export function renderIntro(options: RenderOptions = {}): string { + const color = options.color ?? shouldUseColor() + + if (options.columns !== undefined && options.columns < MIN_BANNER_COLUMNS) { + return `${gray('┌', color)} ${INTRO_TITLE}\n` + } + + const banner = INTRO_BANNER_LINES.map((line) => `${gray('│', color)} ${gradient(line, INTRO_GRADIENT, color)}`) + + return `${gray('┌', color)}\n${banner.join('\n')}\n` +} diff --git a/packages/create-accessible-astro-starter/src/cli.ts b/packages/create-accessible-astro-starter/src/cli.ts new file mode 100644 index 00000000..e3bb6d4f --- /dev/null +++ b/packages/create-accessible-astro-starter/src/cli.ts @@ -0,0 +1,152 @@ +import * as p from '@clack/prompts' +import { relative, resolve } from 'node:path' +import { buildManifest } from './presets.js' +import { parseCliArgs, getDefaultLauncher } from './options.js' +import { scaffoldProject } from './scaffold.js' +import { outroMessage, renderIntro } from './cli-output.js' +import type { ParsedFlags, Preset, ResolvedOptions } from './types.js' +import { deriveSiteNameFromDirectory, formatPresetLabel, slugifySiteName } from './utils.js' + +function unwrapPrompt(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Scaffolding cancelled.') + process.exit(0) + } + + return value as T +} + +async function promptForTargetDir(): Promise { + return unwrapPrompt( + await p.text({ + message: 'Where should we create your new project?', + placeholder: './my-accessible-site', + initialValue: './my-accessible-site', + validate(value) { + if (!value.trim()) { + return 'Please choose a project directory.' + } + + return undefined + }, + }), + ) +} + +async function promptForSiteName(initialValue: string): Promise { + return unwrapPrompt( + await p.text({ + message: 'What should we call your site?', + initialValue, + validate(value) { + if (!value.trim()) { + return 'Please enter a site name.' + } + + return undefined + }, + }), + ) +} + +async function promptForPreset(): Promise { + return unwrapPrompt( + await p.select({ + message: 'Which preset should we start from?', + options: [ + { + value: 'full', + label: 'Full', + hint: 'Blog, portfolio, and all demo pages', + }, + { + value: 'blog', + label: 'Blog', + hint: 'Content site with blog + contact pages', + }, + { + value: 'portfolio', + label: 'Portfolio', + hint: 'Project showcase + contact pages', + }, + { + value: 'minimal', + label: 'Minimal', + hint: 'Simple content website with home, about, and contact', + }, + { + value: 'barebones', + label: 'Barebones', + hint: 'Layout, navigation, and styles only', + }, + ], + }), + ) +} + +async function promptForLauncher(preset: Preset): Promise { + return unwrapPrompt( + await p.confirm({ + message: 'Include the Accessible Astro launcher?', + initialValue: getDefaultLauncher(preset), + }), + ) +} + +async function resolveOptions(flags: ParsedFlags): Promise { + const targetDirInput = flags.targetDir ?? (flags.yes ? './my-accessible-site' : await promptForTargetDir()) + const targetDir = resolve(process.cwd(), targetDirInput) + + const inferredSiteName = deriveSiteNameFromDirectory(targetDirInput) + const siteName = flags.siteName ?? (flags.yes ? inferredSiteName : await promptForSiteName(inferredSiteName)) + const preset = flags.preset ?? (flags.yes ? 'full' : await promptForPreset()) + const includeLauncher = + flags.includeLauncher ?? (flags.yes ? getDefaultLauncher(preset) : await promptForLauncher(preset)) + const siteId = slugifySiteName(siteName) + + if (!siteId) { + throw new Error('Could not derive a valid package name from the provided site name.') + } + + return { + targetDir, + siteName: siteName.trim(), + siteId, + preset, + includeLauncher, + } +} + +export async function run(argv = process.argv.slice(2)): Promise { + const flags = parseCliArgs(argv) + + process.stdout.write(renderIntro({ columns: process.stdout.columns })) + + const options = await resolveOptions(flags) + const manifest = buildManifest(options) + const spinner = p.spinner() + + spinner.start('Scaffolding your project') + await scaffoldProject(options, manifest) + spinner.stop(`Created ${options.siteName}`) + + const relativeFromCwd = relative(process.cwd(), options.targetDir).replace(/\\/g, '/') + const relativeDir = + relativeFromCwd === '' ? '.' : relativeFromCwd.startsWith('..') ? options.targetDir : `./${relativeFromCwd}` + + p.note( + [ + `Directory: ${relativeDir}`, + `Preset: ${formatPresetLabel(options.preset)}`, + `Launcher: ${options.includeLauncher ? 'Included' : 'Removed'}`, + '', + 'Next steps:', + `cd ${relativeDir}`, + 'npm install', + 'npm run dev', + ].join('\n'), + 'Project ready', + ) + + p.outro(outroMessage) +} diff --git a/packages/create-accessible-astro-starter/src/index.ts b/packages/create-accessible-astro-starter/src/index.ts new file mode 100644 index 00000000..ee388f23 --- /dev/null +++ b/packages/create-accessible-astro-starter/src/index.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import { run } from './cli.js' + +run().catch((error) => { + const message = error instanceof Error ? error.message : 'Unknown error' + console.error(`\nError: ${message}`) + process.exitCode = 1 +}) diff --git a/packages/create-accessible-astro-starter/src/options.ts b/packages/create-accessible-astro-starter/src/options.ts new file mode 100644 index 00000000..0d6fed66 --- /dev/null +++ b/packages/create-accessible-astro-starter/src/options.ts @@ -0,0 +1,55 @@ +import { parseArgs } from 'node:util' +import { PRESETS, type ParsedFlags, type Preset } from './types.js' + +const PRESET_SET = new Set(PRESETS) + +function normalizePreset(value: string): Preset { + const normalized = value === 'content' ? 'minimal' : value + if (!PRESET_SET.has(normalized as Preset)) { + throw new Error(`Invalid preset "${value}". Use one of: ${PRESETS.join(', ')}.`) + } + + return normalized as Preset +} + +export function getDefaultLauncher(preset: Preset): boolean { + return preset === 'full' +} + +export function parseCliArgs(argv: string[]): ParsedFlags { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + strict: true, + options: { + preset: { + type: 'string', + }, + name: { + type: 'string', + }, + launcher: { + type: 'boolean', + }, + 'no-launcher': { + type: 'boolean', + }, + yes: { + type: 'boolean', + short: 'y', + }, + }, + }) + + if (values.launcher && values['no-launcher']) { + throw new Error('Use either --launcher or --no-launcher, not both.') + } + + return { + targetDir: positionals[0], + siteName: values.name, + preset: values.preset ? normalizePreset(values.preset) : undefined, + includeLauncher: values.launcher ? true : values['no-launcher'] ? false : undefined, + yes: Boolean(values.yes), + } +} diff --git a/packages/create-accessible-astro-starter/src/presets.ts b/packages/create-accessible-astro-starter/src/presets.ts new file mode 100644 index 00000000..18ce561c --- /dev/null +++ b/packages/create-accessible-astro-starter/src/presets.ts @@ -0,0 +1,121 @@ +import type { ProjectManifest, ResolvedOptions } from './types.js' + +const ALWAYS_DELETE = [ + '.astro', + '.github', + '.cursor', + 'AGENTS.md', + 'cliff.toml', + 'dist', + 'package-lock.json', + 'packages', + 'scripts/workspace-config.js', + 'public/accessible-components.webp', + 'public/wcag-compliant.webp', + 'src/components/ContentMedia.astro', + 'src/components/Counter.astro', +] + +const DEMO_PAGE_GROUP = [ + 'src/pages/accessibility-statement.mdx', + 'src/pages/accessible-components.astro', + 'src/pages/accessible-launcher.astro', + 'src/pages/color-contrast-checker.astro', + 'src/pages/markdown-page.md', + 'src/pages/mdx-page.mdx', + 'src/pages/sitemap.astro', + 'src/components/ColorContrast.astro', +] + +export function buildManifest(options: Pick): ProjectManifest { + const keepBlog = options.preset === 'full' || options.preset === 'blog' + const keepPortfolio = options.preset === 'full' || options.preset === 'portfolio' + const keepDemoPages = options.preset === 'full' + const keepAboutPage = options.preset === 'minimal' + const keepContactPage = options.preset !== 'barebones' + const keepThankYouPage = keepContactPage + const keepMdx = keepDemoPages || keepPortfolio + const keepBlogEnv = keepBlog + const keepContentCollection = keepPortfolio + const usePageHeader = options.preset !== 'barebones' + + const pathsToDelete = new Set(ALWAYS_DELETE) + + if (!keepDemoPages) { + for (const path of DEMO_PAGE_GROUP) { + pathsToDelete.add(path) + } + } + + if (options.preset !== 'full' && options.preset !== 'blog' && options.preset !== 'portfolio') { + pathsToDelete.add('public/astronaut-hero-img.webp') + pathsToDelete.add('src/components/Hero.astro') + } + + if (options.preset !== 'full') { + pathsToDelete.add('src/components/CallToAction.astro') + pathsToDelete.add('src/components/Feature.astro') + } + + if (!options.includeLauncher) { + pathsToDelete.add('src/components/LauncherConfig.astro') + pathsToDelete.add('src/pages/accessible-launcher.astro') + } + + if (!keepBlog) { + pathsToDelete.add('.env.example') + pathsToDelete.add('public/posts') + pathsToDelete.add('src/assets/images/posts') + pathsToDelete.add('src/components/FeaturedPosts.astro') + pathsToDelete.add('src/pages/blog') + } + + if (!keepPortfolio) { + pathsToDelete.add('public/projects') + pathsToDelete.add('src/assets/images/projects') + pathsToDelete.add('src/components/BlockQuote.astro') + pathsToDelete.add('src/components/FeaturedProjects.astro') + pathsToDelete.add('src/content') + pathsToDelete.add('src/content.config.ts') + pathsToDelete.add('src/pages/portfolio') + } + + if (!keepContactPage) { + pathsToDelete.add('src/pages/contact.astro') + pathsToDelete.add('src/pages/thank-you.astro') + } + + if (!keepThankYouPage) { + pathsToDelete.add('src/pages/thank-you.astro') + } + + if (!keepMdx) { + pathsToDelete.add('src/layouts/MarkdownLayout.astro') + } + + if (!keepBlog && !keepPortfolio) { + pathsToDelete.add('src/components/BreakoutImage.astro') + pathsToDelete.add('src/components/SocialShares.astro') + pathsToDelete.add('src/utils/slugify.ts') + } + + if (!usePageHeader) { + pathsToDelete.add('src/components/PageHeader.astro') + } + + return { + preset: options.preset, + includeLauncher: options.includeLauncher, + keepBlog, + keepPortfolio, + keepDemoPages, + keepAboutPage, + keepContactPage, + keepThankYouPage, + keepMdx, + keepBlogEnv, + keepContentCollection, + usePageHeader, + pathsToDelete: Array.from(pathsToDelete), + } +} diff --git a/packages/create-accessible-astro-starter/src/scaffold.ts b/packages/create-accessible-astro-starter/src/scaffold.ts new file mode 100644 index 00000000..a934c322 --- /dev/null +++ b/packages/create-accessible-astro-starter/src/scaffold.ts @@ -0,0 +1,162 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { downloadTemplate } from 'giget' +import { buildManifest } from './presets.js' +import { + copyLocalTemplate, + ensureDirectoryIsReady, + pathExists, + readJson, + removeEmptyDirectories, + removeNamedFiles, + removePath, + removePaths, + writeJson, + writeText, +} from './utils.js' +import { + createAboutPage, + createAstroConfig, + createContactPage, + createFooter, + createHeader, + createHero, + createIndexPage, + createLauncherConfig, + createNavigation, + createNavigationItems, + createReadme, + createThankYouPage, + createThemeConfig, +} from './templates.js' +import type { ProjectManifest, ResolvedOptions } from './types.js' + +type PackageJson = { + name: string + version: string + description?: string + private?: boolean + scripts?: Record + dependencies?: Record + devDependencies?: Record + workspaces?: string[] + repository?: unknown + bugs?: unknown + homepage?: string +} + +async function getPublishedTemplateRef(): Promise { + const packageJsonPath = fileURLToPath(new URL('../../package.json', import.meta.url)) + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { version: string } + return `v${packageJson.version}` +} + +async function materializeTemplate(targetDir: string): Promise { + const localTemplateDir = process.env.ACCESSIBLE_ASTRO_STARTER_TEMPLATE_DIR + + if (localTemplateDir) { + await copyLocalTemplate(localTemplateDir, targetDir) + return + } + + const templateRef = await getPublishedTemplateRef() + await downloadTemplate(`gh:incluud/accessible-astro-starter#${templateRef}`, { + dir: targetDir, + force: true, + }) +} + +async function rewritePackageJson(targetDir: string, options: ResolvedOptions, manifest: ProjectManifest): Promise { + const packageJsonPath = resolve(targetDir, 'package.json') + const packageJson = await readJson(packageJsonPath) + + packageJson.name = options.siteId + packageJson.version = '0.0.1' + packageJson.private = true + packageJson.description = `${options.siteName} website built with Accessible Astro Starter.` + packageJson.scripts = { + dev: 'astro dev', + start: 'astro dev', + build: 'astro build', + preview: 'astro preview', + } + + delete packageJson.workspaces + delete packageJson.repository + delete packageJson.bugs + delete packageJson.homepage + + if (packageJson.dependencies && !manifest.includeLauncher) { + delete packageJson.dependencies['accessible-astro-launcher'] + } + + if (packageJson.devDependencies && !manifest.keepMdx) { + delete packageJson.devDependencies['@astrojs/mdx'] + } + + await writeJson(packageJsonPath, packageJson) +} + +async function writeProjectFiles(targetDir: string, options: ResolvedOptions, manifest: ProjectManifest): Promise { + const keepOriginalFullChrome = manifest.preset === 'full' && manifest.includeLauncher + + await writeText(resolve(targetDir, 'README.md'), createReadme(options)) + await writeText(resolve(targetDir, 'astro.config.mjs'), createAstroConfig(manifest)) + await writeText(resolve(targetDir, 'theme.config.ts'), createThemeConfig(options, manifest)) + if (!keepOriginalFullChrome) { + await writeText(resolve(targetDir, 'src/components/Header.astro'), createHeader(manifest.includeLauncher)) + await writeText(resolve(targetDir, 'src/components/Navigation.astro'), createNavigation(manifest.includeLauncher)) + await writeText(resolve(targetDir, 'src/components/Footer.astro'), createFooter(manifest)) + } + await writeText(resolve(targetDir, 'src/components/NavigationItems.astro'), createNavigationItems(manifest.includeLauncher)) + await writeText(resolve(targetDir, 'src/pages/index.astro'), createIndexPage(options)) + + if (manifest.preset === 'full' || manifest.preset === 'blog' || manifest.preset === 'portfolio') { + await writeText(resolve(targetDir, 'src/components/Hero.astro'), createHero(options)) + } + + if (manifest.keepContactPage) { + await writeText(resolve(targetDir, 'src/pages/contact.astro'), createContactPage()) + } + + if (manifest.keepThankYouPage) { + await writeText(resolve(targetDir, 'src/pages/thank-you.astro'), createThankYouPage()) + } + + if (manifest.keepAboutPage) { + await writeText(resolve(targetDir, 'src/pages/about.astro'), createAboutPage(options.siteName)) + } else if (await pathExists(resolve(targetDir, 'src/pages/about.astro'))) { + await removePath(resolve(targetDir, 'src/pages/about.astro')) + } + + if (manifest.includeLauncher) { + await writeText(resolve(targetDir, 'src/components/LauncherConfig.astro'), createLauncherConfig(manifest)) + } +} + +async function cleanupEmptyProjectDirectories(targetDir: string): Promise { + await Promise.all( + [ + 'scripts', + 'src/assets/images', + 'src/content', + 'src/pages/blog', + 'src/pages/portfolio', + 'src/pages/portfolio/tag', + 'public/posts', + 'public/projects', + 'packages', + ].map((relativePath) => removeEmptyDirectories(resolve(targetDir, relativePath))), + ) +} + +export async function scaffoldProject(options: ResolvedOptions, manifest = buildManifest(options)): Promise { + await ensureDirectoryIsReady(options.targetDir) + await materializeTemplate(options.targetDir) + await removeNamedFiles(options.targetDir, '.DS_Store') + await removePaths(options.targetDir, manifest.pathsToDelete) + await writeProjectFiles(options.targetDir, options, manifest) + await rewritePackageJson(options.targetDir, options, manifest) + await cleanupEmptyProjectDirectories(options.targetDir) +} diff --git a/packages/create-accessible-astro-starter/src/templates.ts b/packages/create-accessible-astro-starter/src/templates.ts new file mode 100644 index 00000000..2ffc766c --- /dev/null +++ b/packages/create-accessible-astro-starter/src/templates.ts @@ -0,0 +1,1528 @@ +import type { ProjectManifest, ResolvedOptions } from './types.js' +import { escapeForHtml, escapeForSingleQuotedString, formatPresetLabel } from './utils.js' + +type ThemeNavItem = + | { + type?: 'link' + label: string + href: string + } + | { + type: 'dropdown' + label: string + items: Array<{ + label: string + href: string + }> + } + +function indent(value: string, spaces: number): string { + const prefix = ' '.repeat(spaces) + return value + .split('\n') + .map((line) => (line.length > 0 ? `${prefix}${line}` : line)) + .join('\n') +} + +function buildThemeNavigation(manifest: ProjectManifest): ThemeNavItem[] { + if (manifest.preset === 'full') { + return [ + { + label: 'Home', + href: '/', + }, + { + label: 'Blog', + href: '/blog', + }, + { + label: 'Portfolio', + href: '/portfolio', + }, + { + type: 'dropdown', + label: 'Features', + items: [ + { + label: 'Accessibility statement', + href: '/accessibility-statement', + }, + { + label: 'Accessible components', + href: '/accessible-components', + }, + ...(manifest.includeLauncher + ? [ + { + label: 'Accessible launcher', + href: '/accessible-launcher', + }, + ] + : []), + { + label: 'Color contrast checker', + href: '/color-contrast-checker', + }, + { + label: 'Markdown page', + href: '/markdown-page', + }, + { + label: 'MDX page', + href: '/mdx-page', + }, + { + label: 'Sitemap', + href: '/sitemap', + }, + ], + }, + { + label: 'Contact', + href: '/contact', + }, + ] + } + + if (manifest.preset === 'blog') { + return [ + { + label: 'Home', + href: '/', + }, + { + label: 'Blog', + href: '/blog', + }, + { + label: 'Contact', + href: '/contact', + }, + ] + } + + if (manifest.preset === 'portfolio') { + return [ + { + label: 'Home', + href: '/', + }, + { + label: 'Portfolio', + href: '/portfolio', + }, + { + label: 'Contact', + href: '/contact', + }, + ] + } + + if (manifest.preset === 'minimal') { + return [ + { + label: 'Home', + href: '/', + }, + { + label: 'About', + href: '/about', + }, + { + label: 'Contact', + href: '/contact', + }, + ] + } + + return [ + { + label: 'Home', + href: '/', + }, + ] +} + +function renderThemeNavigationItem(item: ThemeNavItem): string { + if (item.type === 'dropdown') { + const childItems = item.items + .map( + (childItem) => `{ + label: '${escapeForSingleQuotedString(childItem.label)}', + href: '${escapeForSingleQuotedString(childItem.href)}', + }`, + ) + .join(',\n') + + return `{ + type: 'dropdown', + label: '${escapeForSingleQuotedString(item.label)}', + items: [ +${indent(childItems, 10)} + ], + }` + } + + return `{ + type: 'link', + label: '${escapeForSingleQuotedString(item.label)}', + href: '${escapeForSingleQuotedString(item.href)}', + }` +} + +function createFeatureCards(cards: Array<{ title: string; body: string }>): string { + return cards + .map( + (card) => `
+ ${card.title} +

${card.body}

+
`, + ) + .join('\n') +} + +function createPresetDescription(siteName: string, manifest: ProjectManifest): string { + const name = escapeForSingleQuotedString(siteName) + + switch (manifest.preset) { + case 'blog': + return `${name} is set up as an accessible content site with a blog and contact flow.` + case 'portfolio': + return `${name} is set up as an accessible portfolio with project pages and a contact flow.` + case 'minimal': + return `${name} is a lightweight accessible content site with room to grow.` + case 'barebones': + return `${name} is a stripped-back accessible Astro foundation.` + case 'full': + default: + return `${name} is an accessible Astro site with blog, portfolio, and demo pages ready to customize.` + } +} + +export function createAstroConfig(manifest: ProjectManifest): string { + const imports = [ + "import { defineConfig, envField } from 'astro/config'", + "import { fileURLToPath } from 'url'", + "import compress from 'astro-compress'", + "import icon from 'astro-icon'", + ...(manifest.keepMdx ? ["import mdx from '@astrojs/mdx'"] : []), + "import sitemap from '@astrojs/sitemap'", + "import tailwindcss from '@tailwindcss/vite'", + ] + + const integrations = ['compress()', 'icon()', ...(manifest.keepMdx ? ['mdx()'] : []), 'sitemap()'] + + const envBlock = manifest.keepBlogEnv + ? ` env: { + schema: { + BLOG_API_URL: envField.string({ + context: 'server', + access: 'secret', + optional: true, + default: 'https://jsonplaceholder.typicode.com/posts', + }), + }, + },` + : '' + + return `${imports.join('\n')} + +const viteConfig = { + css: { + preprocessorOptions: { + scss: { + logger: { + warn: () => {}, + }, + }, + }, + }, + plugins: [tailwindcss()], + resolve: { + alias: { + '@components': fileURLToPath(new URL('./src/components', import.meta.url)), + '@layouts': fileURLToPath(new URL('./src/layouts', import.meta.url)), + '@assets': fileURLToPath(new URL('./src/assets', import.meta.url)), + '@content': fileURLToPath(new URL('./src/content', import.meta.url)), + '@pages': fileURLToPath(new URL('./src/pages', import.meta.url)), + '@public': fileURLToPath(new URL('./public', import.meta.url)), + '@post-images': fileURLToPath(new URL('./public/posts', import.meta.url)), + '@project-images': fileURLToPath(new URL('./public/projects', import.meta.url)), + '@utils': fileURLToPath(new URL('./src/utils', import.meta.url)), + '@theme-config': fileURLToPath(new URL('./theme.config.ts', import.meta.url)), + }, + }, +} + +export default defineConfig({ + compressHTML: true, + site: 'https://example.com', + integrations: [${integrations.join(', ')}], + vite: viteConfig, +${envBlock} +}) +` +} + +export function createThemeConfig(options: ResolvedOptions, manifest: ProjectManifest): string { + const navigationItems = buildThemeNavigation(manifest).map(renderThemeNavigationItem).join(',\n') + const siteName = escapeForSingleQuotedString(options.siteName) + const description = escapeForSingleQuotedString(createPresetDescription(options.siteName, manifest)) + + return `import { defineThemeConfig } from '@utils/defineThemeConfig' +import previewImage from '@assets/img/social-preview-image.png' +import logoImage from '@assets/img/logo.svg' + +export default defineThemeConfig({ + name: '${siteName}', + id: '${options.siteId}', + seo: { + title: '${siteName}', + description: '${description}', + image: previewImage, + }, + logo: logoImage, + colors: { + primary: '#d648ff', + secondary: '#00d1b7', + neutral: '#b9bec4', + outline: '#ff4500', + }, + navigation: { + darkmode: true, + items: [ +${indent(navigationItems, 6)} + ], + }, + socials: [], +}) +` +} + +export function createHeader(includeLauncher: boolean): string { + const imports = [ + "import Navigation from '@components/Navigation.astro'", + ...(includeLauncher ? ["import LauncherConfig from '@components/LauncherConfig.astro'"] : []), + `import { ${includeLauncher ? 'HighContrast, ReducedMotion, SkipLink' : 'SkipLink'} } from 'accessible-astro-components'`, + ] + + const launcherMarkup = includeLauncher + ? ` + + + ` + : ` + ` + + return `--- +${imports.join('\n')} +--- + +
+ ${launcherMarkup} +
+ + +` +} + +export function createNavigation(includeLauncher: boolean): string { + return `--- +import ResponsiveToggle from './ResponsiveToggle.astro' +import NavigationItems from './NavigationItems.astro' +import Logo from './Logo.astro' +${includeLauncher ? "import { LauncherTrigger } from 'accessible-astro-launcher'" : ''} +--- + + + + + + +` +} + +export function createNavigationItems(includeLauncher: boolean): string { + return `--- +import themeConfig from '@theme-config' +import { Link, DarkMode } from 'accessible-astro-components' +${includeLauncher ? "import { LauncherTrigger } from 'accessible-astro-launcher'" : ''} +import { Icon } from 'astro-icon/components' + +const currentPathname = Astro.url.pathname + +const isCurrentPage = (href: string): boolean => { + if (href === '/') { + return currentPathname === '/' + } + + return currentPathname.startsWith(href) +} +--- + + +` +} + +export function createHero(options: Pick): string { + const [firstWord = '', ...remainingWords] = options.siteName.trim().split(/\s+/) + const remainingTitle = remainingWords.length > 0 ? ` ${escapeForHtml(remainingWords.join(' '))}` : '' + const description = + options.preset === 'blog' + ? 'A clean, accessible blog starter with a contact flow and room to shape your editorial voice.' + : options.preset === 'portfolio' + ? 'Showcase your work with accessible project pages, content collections, and a simple contact flow.' + : 'A flexible starting point for publishing content, sharing work, and building accessibly with Astro.' + const notification = + options.preset === 'blog' + ? ` + +

Update the blog routes, data source, and homepage copy to match how you want to publish content.

+
` + : options.preset === 'portfolio' + ? ` + +

Swap the placeholder project content for your own case studies and update the tags to match your work.

+
` + : '' + const actions = + options.preset === 'blog' + ? `Browse posts` + : options.preset === 'portfolio' + ? `View projects` + : ` +