create-velocity-astro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # create-velocity-astro
2
+
3
+ Scaffold production-ready [Velocity](https://github.com/southwell-media/velocity) projects in seconds.
4
+
5
+ Velocity is an opinionated Astro 6 + Tailwind CSS v4 starter kit used by Southwell Media to deliver production client sites.
6
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ # npm
11
+ npm create velocity-astro@latest my-site
12
+
13
+ # pnpm
14
+ pnpm create velocity-astro my-site
15
+
16
+ # yarn
17
+ yarn create velocity-astro my-site
18
+
19
+ # bun
20
+ bun create velocity-astro my-site
21
+ ```
22
+
23
+ ## Options
24
+
25
+ | Flag | Description |
26
+ |------|-------------|
27
+ | `--i18n` | Add internationalization support with locale routing |
28
+ | `--yes`, `-y` | Skip prompts and use default options |
29
+ | `--help`, `-h` | Show help message |
30
+ | `--version`, `-v` | Show version number |
31
+
32
+ ## Examples
33
+
34
+ ### Create a basic project
35
+
36
+ ```bash
37
+ npm create velocity-astro@latest my-site
38
+ ```
39
+
40
+ ### Create a project with i18n support
41
+
42
+ ```bash
43
+ npm create velocity-astro@latest my-site --i18n
44
+ ```
45
+
46
+ This adds:
47
+ - Locale routing (`/en`, `/es`, `/fr`)
48
+ - Translation utilities with type-safe keys
49
+ - Language switcher component
50
+ - SEO hreflang tags
51
+
52
+ ### Skip prompts with defaults
53
+
54
+ ```bash
55
+ npm create velocity-astro@latest my-site -y
56
+ ```
57
+
58
+ ## What's Included
59
+
60
+ Every Velocity project comes with:
61
+
62
+ - **Astro 6** - The web framework for content-driven websites
63
+ - **Tailwind CSS v4** - Utility-first CSS framework
64
+ - **TypeScript** - Type safety out of the box
65
+ - **React** - For interactive islands
66
+ - **MDX** - Write content with JSX components
67
+ - **SEO** - Meta tags, Open Graph, JSON-LD schemas
68
+ - **Sitemap** - Auto-generated sitemap.xml
69
+ - **ESLint + Prettier** - Code quality and formatting
70
+ - **Deployment configs** - Vercel, Netlify, Cloudflare ready
71
+
72
+ ## Requirements
73
+
74
+ - Node.js 18.0.0 or higher
75
+ - pnpm, npm, yarn, or bun
76
+
77
+ ## License
78
+
79
+ MIT - Southwell Media
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import mri from "mri";
5
+ import { resolve as resolve2 } from "path";
6
+ import { existsSync as existsSync4 } from "fs";
7
+ import * as p3 from "@clack/prompts";
8
+ import pc2 from "picocolors";
9
+
10
+ // src/prompts.ts
11
+ import * as p from "@clack/prompts";
12
+ import pc from "picocolors";
13
+
14
+ // src/utils/validate.ts
15
+ function validateProjectName(name) {
16
+ if (!name || name.trim() === "") {
17
+ return { valid: false, message: "Project name cannot be empty" };
18
+ }
19
+ if (name !== name.toLowerCase()) {
20
+ return { valid: false, message: "Project name must be lowercase" };
21
+ }
22
+ if (name.startsWith(".") || name.startsWith("_")) {
23
+ return { valid: false, message: "Project name cannot start with . or _" };
24
+ }
25
+ if (/\s/.test(name)) {
26
+ return { valid: false, message: "Project name cannot contain spaces" };
27
+ }
28
+ if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name)) {
29
+ return {
30
+ valid: false,
31
+ message: "Project name can only contain lowercase letters, numbers, hyphens, and underscores"
32
+ };
33
+ }
34
+ if (name.length > 214) {
35
+ return { valid: false, message: "Project name must be 214 characters or fewer" };
36
+ }
37
+ return { valid: true };
38
+ }
39
+ function toValidProjectName(name) {
40
+ return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-_~.]/g, "-").replace(/^[-._]+/, "").replace(/[-._]+$/, "").replace(/-+/g, "-");
41
+ }
42
+
43
+ // src/utils/package-manager.ts
44
+ function detectPackageManager() {
45
+ const userAgent = process.env.npm_config_user_agent || "";
46
+ if (userAgent.startsWith("pnpm")) return "pnpm";
47
+ if (userAgent.startsWith("yarn")) return "yarn";
48
+ if (userAgent.startsWith("bun")) return "bun";
49
+ return "npm";
50
+ }
51
+ function getInstallCommand(pm) {
52
+ switch (pm) {
53
+ case "pnpm":
54
+ return "pnpm install";
55
+ case "yarn":
56
+ return "yarn";
57
+ case "bun":
58
+ return "bun install";
59
+ case "npm":
60
+ default:
61
+ return "npm install";
62
+ }
63
+ }
64
+
65
+ // src/prompts.ts
66
+ async function runPrompts(defaultProjectName, defaultI18n) {
67
+ const detectedPm = detectPackageManager();
68
+ const answers = await p.group(
69
+ {
70
+ projectName: () => p.text({
71
+ message: "What is your project name?",
72
+ placeholder: defaultProjectName || "my-velocity-site",
73
+ defaultValue: defaultProjectName,
74
+ validate: (value) => {
75
+ const name = value || defaultProjectName || "my-velocity-site";
76
+ const result = validateProjectName(toValidProjectName(name));
77
+ if (!result.valid) return result.message;
78
+ }
79
+ }),
80
+ i18n: defaultI18n !== void 0 ? () => Promise.resolve(defaultI18n) : () => p.select({
81
+ message: "Add internationalization (i18n)?",
82
+ options: [
83
+ {
84
+ value: false,
85
+ label: "No",
86
+ hint: "English only (default)"
87
+ },
88
+ {
89
+ value: true,
90
+ label: "Yes",
91
+ hint: "Locale routing, translations"
92
+ }
93
+ ],
94
+ initialValue: false
95
+ }),
96
+ packageManager: () => p.select({
97
+ message: "Which package manager?",
98
+ options: [
99
+ {
100
+ value: "pnpm",
101
+ label: "pnpm",
102
+ hint: detectedPm === "pnpm" ? "detected" : "recommended"
103
+ },
104
+ {
105
+ value: "npm",
106
+ label: "npm",
107
+ hint: detectedPm === "npm" ? "detected" : void 0
108
+ },
109
+ {
110
+ value: "yarn",
111
+ label: "yarn",
112
+ hint: detectedPm === "yarn" ? "detected" : void 0
113
+ },
114
+ {
115
+ value: "bun",
116
+ label: "bun",
117
+ hint: detectedPm === "bun" ? "detected" : void 0
118
+ }
119
+ ],
120
+ initialValue: detectedPm
121
+ })
122
+ },
123
+ {
124
+ onCancel: () => {
125
+ p.cancel("Operation cancelled.");
126
+ process.exit(0);
127
+ }
128
+ }
129
+ );
130
+ return {
131
+ projectName: toValidProjectName(answers.projectName || defaultProjectName || "my-velocity-site"),
132
+ i18n: answers.i18n,
133
+ packageManager: answers.packageManager
134
+ };
135
+ }
136
+ function showIntro() {
137
+ console.log();
138
+ p.intro(pc.bgCyan(pc.black(" Create Velocity ")));
139
+ }
140
+ function showOutro(projectName, packageManager) {
141
+ const runCmd = packageManager === "npm" ? "npm run" : packageManager;
142
+ p.note(
143
+ [
144
+ `cd ${projectName}`,
145
+ `${runCmd} dev`
146
+ ].join("\n"),
147
+ "Next steps"
148
+ );
149
+ p.outro(pc.green("Happy building!"));
150
+ }
151
+ function showError(message) {
152
+ p.log.error(pc.red(message));
153
+ }
154
+ function showWarning(message) {
155
+ p.log.warn(pc.yellow(message));
156
+ }
157
+ function showSuccess(message) {
158
+ p.log.success(pc.green(message));
159
+ }
160
+
161
+ // src/scaffold.ts
162
+ import { existsSync as existsSync2, mkdirSync, readdirSync, copyFileSync, readFileSync, writeFileSync } from "fs";
163
+ import { join, relative } from "path";
164
+ import * as p2 from "@clack/prompts";
165
+ import { execa as execa2 } from "execa";
166
+
167
+ // src/template.ts
168
+ import { existsSync } from "fs";
169
+ import { resolve, dirname } from "path";
170
+ import { fileURLToPath } from "url";
171
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
172
+ var TEMPLATE_IGNORE = [
173
+ "node_modules",
174
+ ".git",
175
+ "dist",
176
+ ".astro",
177
+ ".vercel",
178
+ ".netlify",
179
+ ".wrangler",
180
+ "pnpm-lock.yaml",
181
+ "package-lock.json",
182
+ "yarn.lock",
183
+ "bun.lockb",
184
+ "packages",
185
+ "pnpm-workspace.yaml",
186
+ ".claude",
187
+ ".playwright-mcp",
188
+ "southwell-astro-boilerplate-docs.md",
189
+ "southwell-astro-boilerplate-prd.md",
190
+ "nul"
191
+ ];
192
+ function getBaseTemplatePath() {
193
+ const monorepoRoot = resolve(__dirname2, "..", "..", "..");
194
+ if (existsSync(resolve(monorepoRoot, "astro.config.mjs"))) {
195
+ return monorepoRoot;
196
+ }
197
+ const packageTemplate = resolve(__dirname2, "..", "templates", "base");
198
+ if (existsSync(packageTemplate)) {
199
+ return packageTemplate;
200
+ }
201
+ throw new Error(
202
+ "Could not find base template. Please ensure you are running from the Velocity monorepo."
203
+ );
204
+ }
205
+ function getI18nTemplatePath() {
206
+ const templatePath = resolve(__dirname2, "..", "templates", "i18n");
207
+ if (existsSync(templatePath)) {
208
+ return templatePath;
209
+ }
210
+ throw new Error("Could not find i18n template. Package may be corrupted.");
211
+ }
212
+
213
+ // src/utils/git.ts
214
+ import { execa } from "execa";
215
+ async function initGit(targetDir) {
216
+ try {
217
+ await execa("git", ["init"], { cwd: targetDir });
218
+ await execa("git", ["add", "-A"], { cwd: targetDir });
219
+ await execa("git", ["commit", "-m", "Initial commit from create-velocity"], {
220
+ cwd: targetDir
221
+ });
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // src/scaffold.ts
229
+ function copyTemplateFiles(src, dest, ignore) {
230
+ if (!existsSync2(dest)) {
231
+ mkdirSync(dest, { recursive: true });
232
+ }
233
+ const entries = readdirSync(src, { withFileTypes: true });
234
+ for (const entry of entries) {
235
+ if (ignore.includes(entry.name)) continue;
236
+ const srcPath = join(src, entry.name);
237
+ const destPath = join(dest, entry.name);
238
+ if (entry.isDirectory()) {
239
+ copyTemplateFiles(srcPath, destPath, ignore);
240
+ } else {
241
+ const destDir = join(dest, relative(src, srcPath).split("/").slice(0, -1).join("/"));
242
+ if (destDir && !existsSync2(destDir)) {
243
+ mkdirSync(destDir, { recursive: true });
244
+ }
245
+ copyFileSync(srcPath, destPath);
246
+ }
247
+ }
248
+ }
249
+ function updatePackageJson(targetDir, projectName) {
250
+ const pkgPath = join(targetDir, "package.json");
251
+ if (!existsSync2(pkgPath)) {
252
+ throw new Error("package.json not found in template");
253
+ }
254
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
255
+ pkg.name = projectName;
256
+ pkg.version = "0.1.0";
257
+ delete pkg.repository;
258
+ delete pkg.bugs;
259
+ delete pkg.homepage;
260
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
261
+ }
262
+ function applyI18nOverlay(targetDir) {
263
+ const i18nTemplate = getI18nTemplatePath();
264
+ copyTemplateFiles(i18nTemplate, targetDir, []);
265
+ }
266
+ async function scaffold(options) {
267
+ const { projectName, targetDir, i18n, packageManager } = options;
268
+ const spinner2 = p2.spinner();
269
+ spinner2.start("Copying template files...");
270
+ try {
271
+ const baseTemplate = getBaseTemplatePath();
272
+ copyTemplateFiles(baseTemplate, targetDir, TEMPLATE_IGNORE);
273
+ spinner2.stop("Template files copied");
274
+ } catch (error) {
275
+ spinner2.stop("Failed to copy template");
276
+ throw error;
277
+ }
278
+ if (i18n) {
279
+ spinner2.start("Adding i18n support...");
280
+ try {
281
+ applyI18nOverlay(targetDir);
282
+ spinner2.stop("i18n support added");
283
+ } catch (error) {
284
+ spinner2.stop("Failed to add i18n support");
285
+ throw error;
286
+ }
287
+ }
288
+ spinner2.start("Configuring project...");
289
+ try {
290
+ updatePackageJson(targetDir, projectName);
291
+ spinner2.stop("Project configured");
292
+ } catch (error) {
293
+ spinner2.stop("Failed to configure project");
294
+ throw error;
295
+ }
296
+ spinner2.start("Initializing git repository...");
297
+ const gitInitialized = await initGit(targetDir);
298
+ if (gitInitialized) {
299
+ spinner2.stop("Git repository initialized");
300
+ } else {
301
+ spinner2.stop("Git not available, skipping");
302
+ }
303
+ spinner2.start(`Installing dependencies with ${packageManager}...`);
304
+ try {
305
+ const installCmd = getInstallCommand(packageManager);
306
+ const [cmd, ...args] = installCmd.split(" ");
307
+ await execa2(cmd, args, { cwd: targetDir });
308
+ spinner2.stop("Dependencies installed");
309
+ } catch (error) {
310
+ spinner2.stop("Failed to install dependencies");
311
+ showWarning(`Run "${getInstallCommand(packageManager)}" manually to install dependencies`);
312
+ }
313
+ showSuccess(`Project "${projectName}" created successfully!`);
314
+ }
315
+
316
+ // src/utils/fs.ts
317
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync, copyFileSync as copyFileSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
318
+ import { join as join2, dirname as dirname2 } from "path";
319
+ function isEmptyDir(path) {
320
+ if (!existsSync3(path)) return true;
321
+ const files = readdirSync2(path);
322
+ return files.length === 0 || files.length === 1 && files[0] === ".git";
323
+ }
324
+
325
+ // src/cli.ts
326
+ var HELP_TEXT = `
327
+ ${pc2.bold("create-velocity-astro")} - Create a new Velocity project
328
+
329
+ ${pc2.bold("Usage:")}
330
+ npm create velocity-astro@latest [project-name] [options]
331
+ pnpm create velocity-astro [project-name] [options]
332
+ yarn create velocity-astro [project-name] [options]
333
+ bun create velocity-astro [project-name] [options]
334
+
335
+ ${pc2.bold("Options:")}
336
+ --i18n Add internationalization support
337
+ --yes, -y Skip prompts and use defaults
338
+ --help, -h Show this help message
339
+ --version, -v Show version number
340
+
341
+ ${pc2.bold("Examples:")}
342
+ npm create velocity-astro@latest my-site
343
+ npm create velocity-astro@latest my-site --i18n
344
+ pnpm create velocity-astro my-site -y
345
+ `;
346
+ var VERSION = "1.0.0";
347
+ async function run(argv) {
348
+ const args = mri(argv, {
349
+ boolean: ["i18n", "help", "version", "yes"],
350
+ alias: {
351
+ h: "help",
352
+ v: "version",
353
+ y: "yes"
354
+ }
355
+ });
356
+ if (args.help) {
357
+ console.log(HELP_TEXT);
358
+ return;
359
+ }
360
+ if (args.version) {
361
+ console.log(VERSION);
362
+ return;
363
+ }
364
+ showIntro();
365
+ const argProjectName = args._[0];
366
+ if (args.yes) {
367
+ const projectName2 = toValidProjectName(argProjectName || "my-velocity-site");
368
+ const targetDir2 = resolve2(process.cwd(), projectName2);
369
+ if (existsSync4(targetDir2) && !isEmptyDir(targetDir2)) {
370
+ showError(`Directory "${projectName2}" already exists and is not empty.`);
371
+ process.exit(1);
372
+ }
373
+ await scaffold({
374
+ projectName: projectName2,
375
+ targetDir: targetDir2,
376
+ i18n: args.i18n || false,
377
+ packageManager: "pnpm"
378
+ });
379
+ showOutro(projectName2, "pnpm");
380
+ return;
381
+ }
382
+ const answers = await runPrompts(argProjectName, args.i18n);
383
+ if (typeof answers === "symbol") {
384
+ return;
385
+ }
386
+ const { projectName, i18n, packageManager } = answers;
387
+ const targetDir = resolve2(process.cwd(), projectName);
388
+ if (existsSync4(targetDir) && !isEmptyDir(targetDir)) {
389
+ const shouldOverwrite = await p3.confirm({
390
+ message: `Directory "${projectName}" already exists. Continue and overwrite?`,
391
+ initialValue: false
392
+ });
393
+ if (!shouldOverwrite || p3.isCancel(shouldOverwrite)) {
394
+ p3.cancel("Operation cancelled.");
395
+ process.exit(0);
396
+ }
397
+ }
398
+ try {
399
+ await scaffold({
400
+ projectName,
401
+ targetDir,
402
+ i18n,
403
+ packageManager
404
+ });
405
+ showOutro(projectName, packageManager);
406
+ } catch (error) {
407
+ showError(error instanceof Error ? error.message : "An unexpected error occurred");
408
+ process.exit(1);
409
+ }
410
+ }
411
+
412
+ // src/index.ts
413
+ run(process.argv.slice(2)).catch((error) => {
414
+ console.error(error);
415
+ process.exit(1);
416
+ });
417
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/prompts.ts","../src/utils/validate.ts","../src/utils/package-manager.ts","../src/scaffold.ts","../src/template.ts","../src/utils/git.ts","../src/utils/fs.ts","../src/index.ts"],"sourcesContent":["import mri from 'mri';\nimport { resolve } from 'node:path';\nimport { existsSync } from 'node:fs';\nimport * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport type { CliOptions } from './types.js';\nimport { runPrompts, showIntro, showOutro, showError } from './prompts.js';\nimport { scaffold } from './scaffold.js';\nimport { isEmptyDir } from './utils/fs.js';\nimport { toValidProjectName } from './utils/validate.js';\n\nconst HELP_TEXT = `\n${pc.bold('create-velocity-astro')} - Create a new Velocity project\n\n${pc.bold('Usage:')}\n npm create velocity-astro@latest [project-name] [options]\n pnpm create velocity-astro [project-name] [options]\n yarn create velocity-astro [project-name] [options]\n bun create velocity-astro [project-name] [options]\n\n${pc.bold('Options:')}\n --i18n Add internationalization support\n --yes, -y Skip prompts and use defaults\n --help, -h Show this help message\n --version, -v Show version number\n\n${pc.bold('Examples:')}\n npm create velocity-astro@latest my-site\n npm create velocity-astro@latest my-site --i18n\n pnpm create velocity-astro my-site -y\n`;\n\nconst VERSION = '1.0.0';\n\nexport async function run(argv: string[]): Promise<void> {\n const args = mri<CliOptions>(argv, {\n boolean: ['i18n', 'help', 'version', 'yes'],\n alias: {\n h: 'help',\n v: 'version',\n y: 'yes',\n },\n });\n\n // Handle help\n if (args.help) {\n console.log(HELP_TEXT);\n return;\n }\n\n // Handle version\n if (args.version) {\n console.log(VERSION);\n return;\n }\n\n showIntro();\n\n // Get project name from args or prompt\n const argProjectName = args._[0] as string | undefined;\n\n // Skip prompts mode\n if (args.yes) {\n const projectName = toValidProjectName(argProjectName || 'my-velocity-site');\n const targetDir = resolve(process.cwd(), projectName);\n\n if (existsSync(targetDir) && !isEmptyDir(targetDir)) {\n showError(`Directory \"${projectName}\" already exists and is not empty.`);\n process.exit(1);\n }\n\n await scaffold({\n projectName,\n targetDir,\n i18n: args.i18n || false,\n packageManager: 'pnpm',\n });\n\n showOutro(projectName, 'pnpm');\n return;\n }\n\n // Interactive mode\n const answers = await runPrompts(argProjectName, args.i18n);\n\n // User cancelled\n if (typeof answers === 'symbol') {\n return;\n }\n\n const { projectName, i18n, packageManager } = answers;\n const targetDir = resolve(process.cwd(), projectName);\n\n // Check if directory exists and is not empty\n if (existsSync(targetDir) && !isEmptyDir(targetDir)) {\n const shouldOverwrite = await p.confirm({\n message: `Directory \"${projectName}\" already exists. Continue and overwrite?`,\n initialValue: false,\n });\n\n if (!shouldOverwrite || p.isCancel(shouldOverwrite)) {\n p.cancel('Operation cancelled.');\n process.exit(0);\n }\n }\n\n // Run scaffold\n try {\n await scaffold({\n projectName,\n targetDir,\n i18n,\n packageManager,\n });\n\n showOutro(projectName, packageManager);\n } catch (error) {\n showError(error instanceof Error ? error.message : 'An unexpected error occurred');\n process.exit(1);\n }\n}\n","import * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport type { PackageManager, PromptAnswers } from './types.js';\nimport { validateProjectName, toValidProjectName } from './utils/validate.js';\nimport { detectPackageManager } from './utils/package-manager.js';\n\nexport async function runPrompts(\n defaultProjectName?: string,\n defaultI18n?: boolean\n): Promise<PromptAnswers | symbol> {\n const detectedPm = detectPackageManager();\n\n const answers = await p.group(\n {\n projectName: () =>\n p.text({\n message: 'What is your project name?',\n placeholder: defaultProjectName || 'my-velocity-site',\n defaultValue: defaultProjectName,\n validate: (value) => {\n const name = value || defaultProjectName || 'my-velocity-site';\n const result = validateProjectName(toValidProjectName(name));\n if (!result.valid) return result.message;\n },\n }),\n\n i18n:\n defaultI18n !== undefined\n ? () => Promise.resolve(defaultI18n)\n : () =>\n p.select({\n message: 'Add internationalization (i18n)?',\n options: [\n {\n value: false,\n label: 'No',\n hint: 'English only (default)',\n },\n {\n value: true,\n label: 'Yes',\n hint: 'Locale routing, translations',\n },\n ],\n initialValue: false,\n }),\n\n packageManager: () =>\n p.select({\n message: 'Which package manager?',\n options: [\n {\n value: 'pnpm' as PackageManager,\n label: 'pnpm',\n hint: detectedPm === 'pnpm' ? 'detected' : 'recommended',\n },\n {\n value: 'npm' as PackageManager,\n label: 'npm',\n hint: detectedPm === 'npm' ? 'detected' : undefined,\n },\n {\n value: 'yarn' as PackageManager,\n label: 'yarn',\n hint: detectedPm === 'yarn' ? 'detected' : undefined,\n },\n {\n value: 'bun' as PackageManager,\n label: 'bun',\n hint: detectedPm === 'bun' ? 'detected' : undefined,\n },\n ],\n initialValue: detectedPm,\n }),\n },\n {\n onCancel: () => {\n p.cancel('Operation cancelled.');\n process.exit(0);\n },\n }\n );\n\n return {\n projectName: toValidProjectName(answers.projectName || defaultProjectName || 'my-velocity-site'),\n i18n: answers.i18n as boolean,\n packageManager: answers.packageManager as PackageManager,\n };\n}\n\nexport function showIntro(): void {\n console.log();\n p.intro(pc.bgCyan(pc.black(' Create Velocity ')));\n}\n\nexport function showOutro(projectName: string, packageManager: PackageManager): void {\n const runCmd = packageManager === 'npm' ? 'npm run' : packageManager;\n\n p.note(\n [\n `cd ${projectName}`,\n `${runCmd} dev`,\n ].join('\\n'),\n 'Next steps'\n );\n\n p.outro(pc.green('Happy building!'));\n}\n\nexport function showError(message: string): void {\n p.log.error(pc.red(message));\n}\n\nexport function showWarning(message: string): void {\n p.log.warn(pc.yellow(message));\n}\n\nexport function showSuccess(message: string): void {\n p.log.success(pc.green(message));\n}\n\nexport function showStep(message: string): void {\n p.log.step(message);\n}\n","/**\n * Validates a project name for npm package naming conventions\n */\nexport function validateProjectName(name: string): { valid: boolean; message?: string } {\n if (!name || name.trim() === '') {\n return { valid: false, message: 'Project name cannot be empty' };\n }\n\n // Must be lowercase\n if (name !== name.toLowerCase()) {\n return { valid: false, message: 'Project name must be lowercase' };\n }\n\n // Cannot start with . or _\n if (name.startsWith('.') || name.startsWith('_')) {\n return { valid: false, message: 'Project name cannot start with . or _' };\n }\n\n // Cannot contain spaces\n if (/\\s/.test(name)) {\n return { valid: false, message: 'Project name cannot contain spaces' };\n }\n\n // Cannot contain special characters except - and @/\n if (!/^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name)) {\n return {\n valid: false,\n message: 'Project name can only contain lowercase letters, numbers, hyphens, and underscores',\n };\n }\n\n // Length check\n if (name.length > 214) {\n return { valid: false, message: 'Project name must be 214 characters or fewer' };\n }\n\n return { valid: true };\n}\n\n/**\n * Sanitizes a string to be a valid project name\n */\nexport function toValidProjectName(name: string): string {\n return name\n .trim()\n .toLowerCase()\n .replace(/\\s+/g, '-')\n .replace(/[^a-z0-9-_~.]/g, '-')\n .replace(/^[-._]+/, '')\n .replace(/[-._]+$/, '')\n .replace(/-+/g, '-');\n}\n","import type { PackageManager } from '../types.js';\n\n/**\n * Detects the package manager used to run this command\n */\nexport function detectPackageManager(): PackageManager {\n const userAgent = process.env.npm_config_user_agent || '';\n\n if (userAgent.startsWith('pnpm')) return 'pnpm';\n if (userAgent.startsWith('yarn')) return 'yarn';\n if (userAgent.startsWith('bun')) return 'bun';\n return 'npm';\n}\n\n/**\n * Gets the install command for a package manager\n */\nexport function getInstallCommand(pm: PackageManager): string {\n switch (pm) {\n case 'pnpm':\n return 'pnpm install';\n case 'yarn':\n return 'yarn';\n case 'bun':\n return 'bun install';\n case 'npm':\n default:\n return 'npm install';\n }\n}\n\n/**\n * Gets the run command for a package manager\n */\nexport function getRunCommand(pm: PackageManager): string {\n switch (pm) {\n case 'pnpm':\n return 'pnpm';\n case 'yarn':\n return 'yarn';\n case 'bun':\n return 'bun';\n case 'npm':\n default:\n return 'npm run';\n }\n}\n","import { existsSync, mkdirSync, readdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join, relative } from 'node:path';\nimport * as p from '@clack/prompts';\nimport { execa } from 'execa';\nimport type { ScaffoldOptions } from './types.js';\nimport { getBaseTemplatePath, getI18nTemplatePath, TEMPLATE_IGNORE } from './template.js';\nimport { getInstallCommand } from './utils/package-manager.js';\nimport { initGit } from './utils/git.js';\nimport { showSuccess, showWarning } from './prompts.js';\n\n/**\n * Copies template files recursively, excluding ignored paths\n */\nfunction copyTemplateFiles(src: string, dest: string, ignore: string[]): void {\n if (!existsSync(dest)) {\n mkdirSync(dest, { recursive: true });\n }\n\n const entries = readdirSync(src, { withFileTypes: true });\n\n for (const entry of entries) {\n if (ignore.includes(entry.name)) continue;\n\n const srcPath = join(src, entry.name);\n const destPath = join(dest, entry.name);\n\n if (entry.isDirectory()) {\n copyTemplateFiles(srcPath, destPath, ignore);\n } else {\n const destDir = join(dest, relative(src, srcPath).split('/').slice(0, -1).join('/'));\n if (destDir && !existsSync(destDir)) {\n mkdirSync(destDir, { recursive: true });\n }\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\n/**\n * Updates the package.json with the new project name\n */\nfunction updatePackageJson(targetDir: string, projectName: string): void {\n const pkgPath = join(targetDir, 'package.json');\n\n if (!existsSync(pkgPath)) {\n throw new Error('package.json not found in template');\n }\n\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));\n pkg.name = projectName;\n pkg.version = '0.1.0';\n delete pkg.repository;\n delete pkg.bugs;\n delete pkg.homepage;\n\n writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\\n');\n}\n\n/**\n * Applies the i18n overlay to the project\n */\nfunction applyI18nOverlay(targetDir: string): void {\n const i18nTemplate = getI18nTemplatePath();\n copyTemplateFiles(i18nTemplate, targetDir, []);\n}\n\n/**\n * Main scaffold function\n */\nexport async function scaffold(options: ScaffoldOptions): Promise<void> {\n const { projectName, targetDir, i18n, packageManager } = options;\n const spinner = p.spinner();\n\n // Step 1: Copy base template\n spinner.start('Copying template files...');\n\n try {\n const baseTemplate = getBaseTemplatePath();\n copyTemplateFiles(baseTemplate, targetDir, TEMPLATE_IGNORE);\n spinner.stop('Template files copied');\n } catch (error) {\n spinner.stop('Failed to copy template');\n throw error;\n }\n\n // Step 2: Apply i18n overlay if requested\n if (i18n) {\n spinner.start('Adding i18n support...');\n try {\n applyI18nOverlay(targetDir);\n spinner.stop('i18n support added');\n } catch (error) {\n spinner.stop('Failed to add i18n support');\n throw error;\n }\n }\n\n // Step 3: Update package.json\n spinner.start('Configuring project...');\n try {\n updatePackageJson(targetDir, projectName);\n spinner.stop('Project configured');\n } catch (error) {\n spinner.stop('Failed to configure project');\n throw error;\n }\n\n // Step 4: Initialize git\n spinner.start('Initializing git repository...');\n const gitInitialized = await initGit(targetDir);\n if (gitInitialized) {\n spinner.stop('Git repository initialized');\n } else {\n spinner.stop('Git not available, skipping');\n }\n\n // Step 5: Install dependencies\n spinner.start(`Installing dependencies with ${packageManager}...`);\n try {\n const installCmd = getInstallCommand(packageManager);\n const [cmd, ...args] = installCmd.split(' ');\n await execa(cmd!, args, { cwd: targetDir });\n spinner.stop('Dependencies installed');\n } catch (error) {\n spinner.stop('Failed to install dependencies');\n showWarning(`Run \"${getInstallCommand(packageManager)}\" manually to install dependencies`);\n }\n\n showSuccess(`Project \"${projectName}\" created successfully!`);\n}\n","import { existsSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\n/**\n * Files and directories to exclude when copying the base template\n */\nexport const TEMPLATE_IGNORE = [\n 'node_modules',\n '.git',\n 'dist',\n '.astro',\n '.vercel',\n '.netlify',\n '.wrangler',\n 'pnpm-lock.yaml',\n 'package-lock.json',\n 'yarn.lock',\n 'bun.lockb',\n 'packages',\n 'pnpm-workspace.yaml',\n '.claude',\n '.playwright-mcp',\n 'southwell-astro-boilerplate-docs.md',\n 'southwell-astro-boilerplate-prd.md',\n 'nul',\n];\n\n/**\n * Resolves the path to the base template (velocity root)\n * In development, this is the parent repo\n * In production (published package), the template is bundled\n */\nexport function getBaseTemplatePath(): string {\n // When running from the CLI package in the monorepo\n // Go up from packages/create-velocity/dist to the repo root\n const monorepoRoot = resolve(__dirname, '..', '..', '..');\n\n if (existsSync(resolve(monorepoRoot, 'astro.config.mjs'))) {\n return monorepoRoot;\n }\n\n // Fallback: look for template in the package\n const packageTemplate = resolve(__dirname, '..', 'templates', 'base');\n if (existsSync(packageTemplate)) {\n return packageTemplate;\n }\n\n throw new Error(\n 'Could not find base template. Please ensure you are running from the Velocity monorepo.'\n );\n}\n\n/**\n * Resolves the path to the i18n overlay template\n */\nexport function getI18nTemplatePath(): string {\n // In the package templates directory\n const templatePath = resolve(__dirname, '..', 'templates', 'i18n');\n\n if (existsSync(templatePath)) {\n return templatePath;\n }\n\n throw new Error('Could not find i18n template. Package may be corrupted.');\n}\n","import { execa } from 'execa';\n\n/**\n * Initializes a git repository in the target directory\n */\nexport async function initGit(targetDir: string): Promise<boolean> {\n try {\n await execa('git', ['init'], { cwd: targetDir });\n await execa('git', ['add', '-A'], { cwd: targetDir });\n await execa('git', ['commit', '-m', 'Initial commit from create-velocity'], {\n cwd: targetDir,\n });\n return true;\n } catch {\n // Git may not be installed or configured\n return false;\n }\n}\n\n/**\n * Checks if git is available\n */\nexport async function isGitInstalled(): Promise<boolean> {\n try {\n await execa('git', ['--version']);\n return true;\n } catch {\n return false;\n }\n}\n","import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\n\n/**\n * Recursively copies a directory\n */\nexport function copyDirectory(src: string, dest: string, overwrite = false): void {\n if (!existsSync(src)) {\n throw new Error(`Source directory does not exist: ${src}`);\n }\n\n if (!existsSync(dest)) {\n mkdirSync(dest, { recursive: true });\n }\n\n const entries = readdirSync(src, { withFileTypes: true });\n\n for (const entry of entries) {\n const srcPath = join(src, entry.name);\n const destPath = join(dest, entry.name);\n\n if (entry.isDirectory()) {\n copyDirectory(srcPath, destPath, overwrite);\n } else {\n if (overwrite || !existsSync(destPath)) {\n const destDir = dirname(destPath);\n if (!existsSync(destDir)) {\n mkdirSync(destDir, { recursive: true });\n }\n copyFileSync(srcPath, destPath);\n }\n }\n }\n}\n\n/**\n * Checks if a directory is empty\n */\nexport function isEmptyDir(path: string): boolean {\n if (!existsSync(path)) return true;\n const files = readdirSync(path);\n return files.length === 0 || (files.length === 1 && files[0] === '.git');\n}\n\n/**\n * Reads a JSON file and parses it\n */\nexport function readJson<T = Record<string, unknown>>(path: string): T {\n const content = readFileSync(path, 'utf-8');\n return JSON.parse(content) as T;\n}\n\n/**\n * Writes an object as JSON to a file\n */\nexport function writeJson(path: string, data: unknown): void {\n writeFileSync(path, JSON.stringify(data, null, 2) + '\\n');\n}\n\n/**\n * Checks if path exists and is a directory\n */\nexport function isDirectory(path: string): boolean {\n return existsSync(path) && statSync(path).isDirectory();\n}\n","import { run } from './cli.js';\n\nrun(process.argv.slice(2)).catch((error) => {\n console.error(error);\n process.exit(1);\n});\n"],"mappings":"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","names":["resolve","existsSync","p","pc","existsSync","p","execa","__dirname","existsSync","spinner","execa","existsSync","mkdirSync","readdirSync","copyFileSync","readFileSync","writeFileSync","join","dirname","existsSync","readdirSync","pc","projectName","targetDir","resolve","existsSync"]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "create-velocity-astro",
3
+ "version": "1.0.0",
4
+ "description": "Create Velocity - A CLI to scaffold production-ready Astro 6 + Tailwind v4 projects",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Southwell Media <info@southwellmedia.com>",
8
+ "homepage": "https://github.com/southwell-media/velocity#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/southwell-media/velocity.git",
12
+ "directory": "packages/create-velocity"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/southwell-media/velocity/issues"
16
+ },
17
+ "keywords": [
18
+ "create",
19
+ "velocity",
20
+ "astro",
21
+ "tailwind",
22
+ "starter",
23
+ "template",
24
+ "boilerplate",
25
+ "cli",
26
+ "scaffold",
27
+ "i18n",
28
+ "internationalization"
29
+ ],
30
+ "bin": {
31
+ "create-velocity-astro": "./dist/index.js"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "templates"
36
+ ],
37
+ "exports": {
38
+ ".": {
39
+ "import": "./dist/index.js"
40
+ }
41
+ },
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsup --watch",
45
+ "start": "node dist/index.js",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest",
48
+ "prepublishOnly": "npm run build"
49
+ },
50
+ "dependencies": {
51
+ "@clack/prompts": "^0.8.2",
52
+ "picocolors": "^1.1.1",
53
+ "mri": "^1.2.0",
54
+ "execa": "^9.5.2"
55
+ },
56
+ "devDependencies": {
57
+ "@types/mri": "^1.1.4",
58
+ "@types/node": "^22.10.0",
59
+ "tsup": "^8.3.5",
60
+ "typescript": "^5.7.0",
61
+ "vitest": "^2.1.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=18.0.0"
65
+ }
66
+ }
@@ -0,0 +1,57 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import mdx from '@astrojs/mdx';
3
+ import sitemap from '@astrojs/sitemap';
4
+ import react from '@astrojs/react';
5
+ import tailwindcss from '@tailwindcss/vite';
6
+
7
+ export default defineConfig({
8
+ site: process.env.SITE_URL || 'https://example.com',
9
+
10
+ // i18n configuration
11
+ i18n: {
12
+ defaultLocale: 'en',
13
+ locales: ['en', 'es', 'fr'],
14
+ routing: {
15
+ prefixDefaultLocale: false,
16
+ },
17
+ },
18
+
19
+ integrations: [
20
+ react(),
21
+ mdx(),
22
+ sitemap({
23
+ i18n: {
24
+ defaultLocale: 'en',
25
+ locales: {
26
+ en: 'en',
27
+ es: 'es',
28
+ fr: 'fr',
29
+ },
30
+ },
31
+ }),
32
+ ],
33
+
34
+ vite: {
35
+ plugins: [tailwindcss()],
36
+ },
37
+
38
+ security: {
39
+ checkOrigin: true,
40
+ },
41
+
42
+ experimental: {
43
+ contentIntellisense: true,
44
+ },
45
+
46
+ markdown: {
47
+ shikiConfig: {
48
+ theme: 'github-dark',
49
+ wrap: true,
50
+ },
51
+ },
52
+
53
+ prefetch: {
54
+ prefetchAll: true,
55
+ defaultStrategy: 'viewport',
56
+ },
57
+ });
@@ -0,0 +1,120 @@
1
+ ---
2
+ /**
3
+ * Language Switcher Component
4
+ * Allows users to switch between available locales
5
+ */
6
+
7
+ import { locales, localeNames, localeFlags, type Locale, localePath, getLocaleFromPath } from '@/i18n/config';
8
+
9
+ interface Props {
10
+ class?: string;
11
+ }
12
+
13
+ const { class: className } = Astro.props;
14
+
15
+ const currentPath = Astro.url.pathname;
16
+ const currentLocale = getLocaleFromPath(currentPath);
17
+ ---
18
+
19
+ <div class:list={['relative inline-block', className]}>
20
+ <button
21
+ type="button"
22
+ id="language-switcher-btn"
23
+ class="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
24
+ aria-expanded="false"
25
+ aria-haspopup="true"
26
+ >
27
+ <span class="text-base">{localeFlags[currentLocale]}</span>
28
+ <span>{localeNames[currentLocale]}</span>
29
+ <svg
30
+ class="h-4 w-4 transition-transform"
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ viewBox="0 0 20 20"
33
+ fill="currentColor"
34
+ aria-hidden="true"
35
+ >
36
+ <path
37
+ fill-rule="evenodd"
38
+ d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
39
+ clip-rule="evenodd"></path>
40
+ </svg>
41
+ </button>
42
+
43
+ <div
44
+ id="language-switcher-menu"
45
+ class="absolute right-0 z-50 mt-2 hidden w-40 origin-top-right rounded-md border border-border bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
46
+ role="menu"
47
+ aria-orientation="vertical"
48
+ aria-labelledby="language-switcher-btn"
49
+ >
50
+ <div class="py-1" role="none">
51
+ {
52
+ locales.map((locale) => {
53
+ const isActive = locale === currentLocale;
54
+ const href = localePath(currentPath, locale);
55
+
56
+ return (
57
+ <a
58
+ href={href}
59
+ class:list={[
60
+ 'flex items-center gap-2 px-4 py-2 text-sm transition-colors',
61
+ isActive
62
+ ? 'bg-primary/10 text-primary font-medium'
63
+ : 'text-foreground hover:bg-muted',
64
+ ]}
65
+ role="menuitem"
66
+ aria-current={isActive ? 'page' : undefined}
67
+ >
68
+ <span class="text-base">{localeFlags[locale]}</span>
69
+ <span>{localeNames[locale]}</span>
70
+ {isActive && (
71
+ <svg
72
+ class="ml-auto h-4 w-4"
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ viewBox="0 0 20 20"
75
+ fill="currentColor"
76
+ >
77
+ <path
78
+ fill-rule="evenodd"
79
+ d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
80
+ clip-rule="evenodd"
81
+ />
82
+ </svg>
83
+ )}
84
+ </a>
85
+ );
86
+ })
87
+ }
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <script>
93
+ const btn = document.getElementById('language-switcher-btn');
94
+ const menu = document.getElementById('language-switcher-menu');
95
+
96
+ if (btn && menu) {
97
+ // Toggle menu
98
+ btn.addEventListener('click', () => {
99
+ const isOpen = menu.classList.contains('hidden');
100
+ menu.classList.toggle('hidden', !isOpen);
101
+ btn.setAttribute('aria-expanded', String(isOpen));
102
+ });
103
+
104
+ // Close when clicking outside
105
+ document.addEventListener('click', (e) => {
106
+ if (!btn.contains(e.target as Node) && !menu.contains(e.target as Node)) {
107
+ menu.classList.add('hidden');
108
+ btn.setAttribute('aria-expanded', 'false');
109
+ }
110
+ });
111
+
112
+ // Close on escape
113
+ document.addEventListener('keydown', (e) => {
114
+ if (e.key === 'Escape') {
115
+ menu.classList.add('hidden');
116
+ btn.setAttribute('aria-expanded', 'false');
117
+ }
118
+ });
119
+ }
120
+ </script>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * i18n Configuration
3
+ * Defines supported locales and default language settings
4
+ */
5
+
6
+ export const locales = ['en', 'es', 'fr'] as const;
7
+ export type Locale = (typeof locales)[number];
8
+
9
+ export const defaultLocale: Locale = 'en';
10
+
11
+ export const localeNames: Record<Locale, string> = {
12
+ en: 'English',
13
+ es: 'Español',
14
+ fr: 'Français',
15
+ };
16
+
17
+ export const localeFlags: Record<Locale, string> = {
18
+ en: '🇺🇸',
19
+ es: '🇪🇸',
20
+ fr: '🇫🇷',
21
+ };
22
+
23
+ /**
24
+ * Check if a string is a valid locale
25
+ */
26
+ export function isValidLocale(locale: string): locale is Locale {
27
+ return locales.includes(locale as Locale);
28
+ }
29
+
30
+ /**
31
+ * Get locale from URL path
32
+ */
33
+ export function getLocaleFromPath(path: string): Locale {
34
+ const segments = path.split('/').filter(Boolean);
35
+ const firstSegment = segments[0];
36
+
37
+ if (firstSegment && isValidLocale(firstSegment)) {
38
+ return firstSegment;
39
+ }
40
+
41
+ return defaultLocale;
42
+ }
43
+
44
+ /**
45
+ * Remove locale prefix from path
46
+ */
47
+ export function removeLocaleFromPath(path: string): string {
48
+ const segments = path.split('/').filter(Boolean);
49
+ const firstSegment = segments[0];
50
+
51
+ if (firstSegment && isValidLocale(firstSegment)) {
52
+ return '/' + segments.slice(1).join('/');
53
+ }
54
+
55
+ return path;
56
+ }
57
+
58
+ /**
59
+ * Add locale prefix to path
60
+ */
61
+ export function localePath(path: string, locale: Locale): string {
62
+ const cleanPath = removeLocaleFromPath(path);
63
+ if (locale === defaultLocale) {
64
+ return cleanPath || '/';
65
+ }
66
+ return `/${locale}${cleanPath}`;
67
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * i18n Utilities
3
+ * Translation functions and helpers
4
+ */
5
+
6
+ import { type Locale, defaultLocale, locales } from './config';
7
+ import { en } from './translations/en';
8
+ import { es } from './translations/es';
9
+ import { fr } from './translations/fr';
10
+
11
+ // Translation map
12
+ const translations = {
13
+ en,
14
+ es,
15
+ fr,
16
+ } as const;
17
+
18
+ type NestedKeyOf<T> = T extends object
19
+ ? {
20
+ [K in keyof T]: K extends string
21
+ ? T[K] extends object
22
+ ? `${K}.${NestedKeyOf<T[K]>}`
23
+ : K
24
+ : never;
25
+ }[keyof T]
26
+ : never;
27
+
28
+ export type TranslationKey = NestedKeyOf<typeof en>;
29
+
30
+ /**
31
+ * Get a nested value from an object using dot notation
32
+ */
33
+ function getNestedValue(obj: Record<string, unknown>, path: string): string {
34
+ const keys = path.split('.');
35
+ let result: unknown = obj;
36
+
37
+ for (const key of keys) {
38
+ if (result && typeof result === 'object' && key in result) {
39
+ result = (result as Record<string, unknown>)[key];
40
+ } else {
41
+ return path; // Return the key if not found
42
+ }
43
+ }
44
+
45
+ return typeof result === 'string' ? result : path;
46
+ }
47
+
48
+ /**
49
+ * Get translation for a key
50
+ */
51
+ export function t(
52
+ key: TranslationKey,
53
+ locale: Locale = defaultLocale,
54
+ params?: Record<string, string | number>
55
+ ): string {
56
+ const translation = translations[locale] || translations[defaultLocale];
57
+ let text = getNestedValue(translation as unknown as Record<string, unknown>, key);
58
+
59
+ // Replace parameters like {year}, {name}
60
+ if (params) {
61
+ Object.entries(params).forEach(([param, value]) => {
62
+ text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), String(value));
63
+ });
64
+ }
65
+
66
+ return text;
67
+ }
68
+
69
+ /**
70
+ * Create a translation function bound to a specific locale
71
+ */
72
+ export function useTranslations(locale: Locale) {
73
+ return (key: TranslationKey, params?: Record<string, string | number>) =>
74
+ t(key, locale, params);
75
+ }
76
+
77
+ /**
78
+ * Get all translations for a locale
79
+ */
80
+ export function getTranslations(locale: Locale) {
81
+ return translations[locale] || translations[defaultLocale];
82
+ }
83
+
84
+ // Re-export config
85
+ export { locales, defaultLocale, type Locale } from './config';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * English translations
3
+ */
4
+ export const en = {
5
+ // Site
6
+ site: {
7
+ name: 'Velocity',
8
+ description: 'A modern Astro starter template',
9
+ },
10
+
11
+ // Navigation
12
+ nav: {
13
+ home: 'Home',
14
+ about: 'About',
15
+ blog: 'Blog',
16
+ contact: 'Contact',
17
+ },
18
+
19
+ // Common
20
+ common: {
21
+ readMore: 'Read more',
22
+ loading: 'Loading...',
23
+ error: 'An error occurred',
24
+ notFound: 'Page not found',
25
+ backHome: 'Back to home',
26
+ },
27
+
28
+ // Home page
29
+ home: {
30
+ title: 'Welcome to Velocity',
31
+ subtitle: 'The opinionated Astro starter you actually want to use',
32
+ cta: 'Get Started',
33
+ },
34
+
35
+ // Footer
36
+ footer: {
37
+ copyright: '© {year} Velocity. All rights reserved.',
38
+ madeWith: 'Made with',
39
+ },
40
+
41
+ // Forms
42
+ form: {
43
+ name: 'Name',
44
+ email: 'Email',
45
+ message: 'Message',
46
+ submit: 'Submit',
47
+ sending: 'Sending...',
48
+ success: 'Message sent successfully!',
49
+ error: 'Failed to send message. Please try again.',
50
+ },
51
+ } as const;
52
+
53
+ export type TranslationKeys = typeof en;
@@ -0,0 +1,53 @@
1
+ import type { TranslationKeys } from './en';
2
+
3
+ /**
4
+ * Spanish translations
5
+ */
6
+ export const es: TranslationKeys = {
7
+ // Site
8
+ site: {
9
+ name: 'Velocity',
10
+ description: 'Una plantilla moderna de inicio con Astro',
11
+ },
12
+
13
+ // Navigation
14
+ nav: {
15
+ home: 'Inicio',
16
+ about: 'Acerca de',
17
+ blog: 'Blog',
18
+ contact: 'Contacto',
19
+ },
20
+
21
+ // Common
22
+ common: {
23
+ readMore: 'Leer más',
24
+ loading: 'Cargando...',
25
+ error: 'Ocurrió un error',
26
+ notFound: 'Página no encontrada',
27
+ backHome: 'Volver al inicio',
28
+ },
29
+
30
+ // Home page
31
+ home: {
32
+ title: 'Bienvenido a Velocity',
33
+ subtitle: 'El starter de Astro con opiniones que realmente quieres usar',
34
+ cta: 'Comenzar',
35
+ },
36
+
37
+ // Footer
38
+ footer: {
39
+ copyright: '© {year} Velocity. Todos los derechos reservados.',
40
+ madeWith: 'Hecho con',
41
+ },
42
+
43
+ // Forms
44
+ form: {
45
+ name: 'Nombre',
46
+ email: 'Correo electrónico',
47
+ message: 'Mensaje',
48
+ submit: 'Enviar',
49
+ sending: 'Enviando...',
50
+ success: '¡Mensaje enviado con éxito!',
51
+ error: 'Error al enviar el mensaje. Por favor, inténtalo de nuevo.',
52
+ },
53
+ } as const;
@@ -0,0 +1,53 @@
1
+ import type { TranslationKeys } from './en';
2
+
3
+ /**
4
+ * French translations
5
+ */
6
+ export const fr: TranslationKeys = {
7
+ // Site
8
+ site: {
9
+ name: 'Velocity',
10
+ description: 'Un template de démarrage Astro moderne',
11
+ },
12
+
13
+ // Navigation
14
+ nav: {
15
+ home: 'Accueil',
16
+ about: 'À propos',
17
+ blog: 'Blog',
18
+ contact: 'Contact',
19
+ },
20
+
21
+ // Common
22
+ common: {
23
+ readMore: 'Lire la suite',
24
+ loading: 'Chargement...',
25
+ error: 'Une erreur est survenue',
26
+ notFound: 'Page non trouvée',
27
+ backHome: "Retour à l'accueil",
28
+ },
29
+
30
+ // Home page
31
+ home: {
32
+ title: 'Bienvenue sur Velocity',
33
+ subtitle: "Le starter Astro opinioné que vous voulez vraiment utiliser",
34
+ cta: 'Commencer',
35
+ },
36
+
37
+ // Footer
38
+ footer: {
39
+ copyright: '© {year} Velocity. Tous droits réservés.',
40
+ madeWith: 'Fait avec',
41
+ },
42
+
43
+ // Forms
44
+ form: {
45
+ name: 'Nom',
46
+ email: 'E-mail',
47
+ message: 'Message',
48
+ submit: 'Envoyer',
49
+ sending: 'Envoi en cours...',
50
+ success: 'Message envoyé avec succès !',
51
+ error: "Échec de l'envoi du message. Veuillez réessayer.",
52
+ },
53
+ } as const;
@@ -0,0 +1,121 @@
1
+ ---
2
+ import '@/styles/global.css';
3
+ import SEO from '@/components/seo/SEO.astro';
4
+ import JsonLd from '@/components/seo/JsonLd.astro';
5
+ import { createWebsiteSchema, createOrganizationSchema } from '@/lib/schema';
6
+ import type { WebSite, Organization, WithContext } from 'schema-dts';
7
+ import siteConfig from '@/config/site.config';
8
+ import { type Locale, defaultLocale, getLocaleFromPath } from '@/i18n/config';
9
+
10
+ interface Props {
11
+ title?: string;
12
+ description?: string;
13
+ image?: string;
14
+ imageAlt?: string;
15
+ article?: {
16
+ publishedTime?: Date;
17
+ modifiedTime?: Date;
18
+ authors?: string[];
19
+ tags?: string[];
20
+ };
21
+ noindex?: boolean;
22
+ nofollow?: boolean;
23
+ includeOrgSchema?: boolean;
24
+ lang?: Locale;
25
+ }
26
+
27
+ const {
28
+ title,
29
+ description,
30
+ image,
31
+ imageAlt,
32
+ article,
33
+ noindex = false,
34
+ nofollow = false,
35
+ includeOrgSchema = false,
36
+ lang,
37
+ } = Astro.props;
38
+
39
+ // Get locale from props or URL path
40
+ const currentLocale = lang || getLocaleFromPath(Astro.url.pathname);
41
+
42
+ // Build JSON-LD schemas
43
+ const schemas: Array<WithContext<WebSite> | WithContext<Organization>> = [createWebsiteSchema()];
44
+ if (includeOrgSchema) {
45
+ schemas.push(createOrganizationSchema());
46
+ }
47
+ ---
48
+
49
+ <!doctype html>
50
+ <html lang={currentLocale} class="scroll-smooth">
51
+ <head>
52
+ <meta charset="UTF-8" />
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
54
+ <meta name="generator" content={Astro.generator} />
55
+
56
+ <!-- Velocity Fonts: Outfit (display), Manrope (body), JetBrains Mono (code) -->
57
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
58
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
59
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
60
+
61
+ <!-- Favicon -->
62
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
63
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
64
+ <link rel="manifest" href="/manifest.webmanifest" />
65
+
66
+ <!-- SEO -->
67
+ <SEO
68
+ title={title}
69
+ description={description}
70
+ image={image}
71
+ imageAlt={imageAlt}
72
+ article={article}
73
+ noindex={noindex}
74
+ nofollow={nofollow}
75
+ />
76
+
77
+ <!-- Canonical URL -->
78
+ <link rel="canonical" href={new URL(Astro.url.pathname, siteConfig.url).toString()} />
79
+
80
+ <!-- Alternate language links for SEO -->
81
+ <link rel="alternate" hreflang="en" href={new URL(Astro.url.pathname.replace(/^\/(es|fr)/, ''), siteConfig.url).toString()} />
82
+ <link rel="alternate" hreflang="es" href={new URL(`/es${Astro.url.pathname.replace(/^\/(en|es|fr)/, '')}`, siteConfig.url).toString()} />
83
+ <link rel="alternate" hreflang="fr" href={new URL(`/fr${Astro.url.pathname.replace(/^\/(en|es|fr)/, '')}`, siteConfig.url).toString()} />
84
+ <link rel="alternate" hreflang="x-default" href={new URL(Astro.url.pathname.replace(/^\/(es|fr)/, ''), siteConfig.url).toString()} />
85
+
86
+ <!-- JSON-LD Structured Data -->
87
+ <JsonLd schema={schemas} />
88
+
89
+ <!-- Theme script (runs before render to prevent flash) -->
90
+ <script is:inline>
91
+ (function () {
92
+ const theme = localStorage.getItem('theme');
93
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
94
+
95
+ if (theme === 'dark' || (!theme && systemDark)) {
96
+ document.documentElement.classList.add('dark');
97
+ } else {
98
+ document.documentElement.classList.remove('dark');
99
+ }
100
+ })();
101
+ </script>
102
+ </head>
103
+
104
+ <body class="min-h-screen bg-background text-foreground antialiased">
105
+ <!-- Skip to content link -->
106
+ <a
107
+ href="#main-content"
108
+ class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
109
+ >
110
+ Skip to content
111
+ </a>
112
+
113
+ <slot name="header" />
114
+
115
+ <main id="main-content" class="flex-1">
116
+ <slot />
117
+ </main>
118
+
119
+ <slot name="footer" />
120
+ </body>
121
+ </html>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import LandingLayout from '@/layouts/LandingLayout.astro';
3
+ import Hero from '@/components/landing/Hero.astro';
4
+ import TechStack from '@/components/landing/TechStack.astro';
5
+ import FeatureTabs from '@/components/landing/FeatureTabs.tsx';
6
+ import Credibility from '@/components/landing/Credibility.astro';
7
+ import CTA from '@/components/landing/CTA.astro';
8
+ import { locales, isValidLocale, type Locale } from '@/i18n/config';
9
+ import { useTranslations } from '@/i18n/index';
10
+
11
+ export function getStaticPaths() {
12
+ return locales.map((lang) => ({
13
+ params: { lang },
14
+ }));
15
+ }
16
+
17
+ const { lang } = Astro.params;
18
+
19
+ if (!lang || !isValidLocale(lang)) {
20
+ return Astro.redirect('/');
21
+ }
22
+
23
+ const t = useTranslations(lang as Locale);
24
+ ---
25
+
26
+ <LandingLayout
27
+ title={t('home.title')}
28
+ description={t('home.subtitle')}
29
+ lang={lang as Locale}
30
+ >
31
+ <Hero />
32
+ <TechStack />
33
+ <FeatureTabs client:visible />
34
+ <Credibility />
35
+ <CTA />
36
+ </LandingLayout>