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 +79 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +417 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/templates/i18n/astro.config.mjs +57 -0
- package/templates/i18n/src/components/i18n/LanguageSwitcher.astro +120 -0
- package/templates/i18n/src/i18n/config.ts +67 -0
- package/templates/i18n/src/i18n/index.ts +85 -0
- package/templates/i18n/src/i18n/translations/en.ts +53 -0
- package/templates/i18n/src/i18n/translations/es.ts +53 -0
- package/templates/i18n/src/i18n/translations/fr.ts +53 -0
- package/templates/i18n/src/layouts/BaseLayout.astro +121 -0
- package/templates/i18n/src/pages/[lang]/index.astro +36 -0
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
|
package/dist/index.d.ts
ADDED
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":";;;AAAA,OAAO,SAAS;AAChB,SAAS,WAAAA,gBAAe;AACxB,SAAS,cAAAC,mBAAkB;AAC3B,YAAYC,QAAO;AACnB,OAAOC,SAAQ;;;ACJf,YAAY,OAAO;AACnB,OAAO,QAAQ;;;ACER,SAAS,oBAAoB,MAAoD;AACtF,MAAI,CAAC,QAAQ,KAAK,KAAK,MAAM,IAAI;AAC/B,WAAO,EAAE,OAAO,OAAO,SAAS,+BAA+B;AAAA,EACjE;AAGA,MAAI,SAAS,KAAK,YAAY,GAAG;AAC/B,WAAO,EAAE,OAAO,OAAO,SAAS,iCAAiC;AAAA,EACnE;AAGA,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,WAAW,GAAG,GAAG;AAChD,WAAO,EAAE,OAAO,OAAO,SAAS,wCAAwC;AAAA,EAC1E;AAGA,MAAI,KAAK,KAAK,IAAI,GAAG;AACnB,WAAO,EAAE,OAAO,OAAO,SAAS,qCAAqC;AAAA,EACvE;AAGA,MAAI,CAAC,yDAAyD,KAAK,IAAI,GAAG;AACxE,WAAO;AAAA,MACL,OAAO;AAAA,MACP,SAAS;AAAA,IACX;AAAA,EACF;AAGA,MAAI,KAAK,SAAS,KAAK;AACrB,WAAO,EAAE,OAAO,OAAO,SAAS,+CAA+C;AAAA,EACjF;AAEA,SAAO,EAAE,OAAO,KAAK;AACvB;AAKO,SAAS,mBAAmB,MAAsB;AACvD,SAAO,KACJ,KAAK,EACL,YAAY,EACZ,QAAQ,QAAQ,GAAG,EACnB,QAAQ,kBAAkB,GAAG,EAC7B,QAAQ,WAAW,EAAE,EACrB,QAAQ,WAAW,EAAE,EACrB,QAAQ,OAAO,GAAG;AACvB;;;AC9CO,SAAS,uBAAuC;AACrD,QAAM,YAAY,QAAQ,IAAI,yBAAyB;AAEvD,MAAI,UAAU,WAAW,MAAM,EAAG,QAAO;AACzC,MAAI,UAAU,WAAW,MAAM,EAAG,QAAO;AACzC,MAAI,UAAU,WAAW,KAAK,EAAG,QAAO;AACxC,SAAO;AACT;AAKO,SAAS,kBAAkB,IAA4B;AAC5D,UAAQ,IAAI;AAAA,IACV,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL;AACE,aAAO;AAAA,EACX;AACF;;;AFvBA,eAAsB,WACpB,oBACA,aACiC;AACjC,QAAM,aAAa,qBAAqB;AAExC,QAAM,UAAU,MAAQ;AAAA,IACtB;AAAA,MACE,aAAa,MACT,OAAK;AAAA,QACL,SAAS;AAAA,QACT,aAAa,sBAAsB;AAAA,QACnC,cAAc;AAAA,QACd,UAAU,CAAC,UAAU;AACnB,gBAAM,OAAO,SAAS,sBAAsB;AAC5C,gBAAM,SAAS,oBAAoB,mBAAmB,IAAI,CAAC;AAC3D,cAAI,CAAC,OAAO,MAAO,QAAO,OAAO;AAAA,QACnC;AAAA,MACF,CAAC;AAAA,MAEH,MACE,gBAAgB,SACZ,MAAM,QAAQ,QAAQ,WAAW,IACjC,MACI,SAAO;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,UACP;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM;AAAA,UACR;AAAA,UACA;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM;AAAA,UACR;AAAA,QACF;AAAA,QACA,cAAc;AAAA,MAChB,CAAC;AAAA,MAET,gBAAgB,MACZ,SAAO;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,UACP;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM,eAAe,SAAS,aAAa;AAAA,UAC7C;AAAA,UACA;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM,eAAe,QAAQ,aAAa;AAAA,UAC5C;AAAA,UACA;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM,eAAe,SAAS,aAAa;AAAA,UAC7C;AAAA,UACA;AAAA,YACE,OAAO;AAAA,YACP,OAAO;AAAA,YACP,MAAM,eAAe,QAAQ,aAAa;AAAA,UAC5C;AAAA,QACF;AAAA,QACA,cAAc;AAAA,MAChB,CAAC;AAAA,IACL;AAAA,IACA;AAAA,MACE,UAAU,MAAM;AACd,QAAE,SAAO,sBAAsB;AAC/B,gBAAQ,KAAK,CAAC;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,aAAa,mBAAmB,QAAQ,eAAe,sBAAsB,kBAAkB;AAAA,IAC/F,MAAM,QAAQ;AAAA,IACd,gBAAgB,QAAQ;AAAA,EAC1B;AACF;AAEO,SAAS,YAAkB;AAChC,UAAQ,IAAI;AACZ,EAAE,QAAM,GAAG,OAAO,GAAG,MAAM,mBAAmB,CAAC,CAAC;AAClD;AAEO,SAAS,UAAU,aAAqB,gBAAsC;AACnF,QAAM,SAAS,mBAAmB,QAAQ,YAAY;AAEtD,EAAE;AAAA,IACA;AAAA,MACE,MAAM,WAAW;AAAA,MACjB,GAAG,MAAM;AAAA,IACX,EAAE,KAAK,IAAI;AAAA,IACX;AAAA,EACF;AAEA,EAAE,QAAM,GAAG,MAAM,iBAAiB,CAAC;AACrC;AAEO,SAAS,UAAU,SAAuB;AAC/C,EAAE,MAAI,MAAM,GAAG,IAAI,OAAO,CAAC;AAC7B;AAEO,SAAS,YAAY,SAAuB;AACjD,EAAE,MAAI,KAAK,GAAG,OAAO,OAAO,CAAC;AAC/B;AAEO,SAAS,YAAY,SAAuB;AACjD,EAAE,MAAI,QAAQ,GAAG,MAAM,OAAO,CAAC;AACjC;;;AGvHA,SAAS,cAAAC,aAAY,WAAW,aAAa,cAAc,cAAc,qBAAqB;AAC9F,SAAS,MAAM,gBAAgB;AAC/B,YAAYC,QAAO;AACnB,SAAS,SAAAC,cAAa;;;ACHtB,SAAS,kBAAkB;AAC3B,SAAS,SAAS,eAAe;AACjC,SAAS,qBAAqB;AAE9B,IAAMC,aAAY,QAAQ,cAAc,YAAY,GAAG,CAAC;AAKjD,IAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,SAAS,sBAA8B;AAG5C,QAAM,eAAe,QAAQA,YAAW,MAAM,MAAM,IAAI;AAExD,MAAI,WAAW,QAAQ,cAAc,kBAAkB,CAAC,GAAG;AACzD,WAAO;AAAA,EACT;AAGA,QAAM,kBAAkB,QAAQA,YAAW,MAAM,aAAa,MAAM;AACpE,MAAI,WAAW,eAAe,GAAG;AAC/B,WAAO;AAAA,EACT;AAEA,QAAM,IAAI;AAAA,IACR;AAAA,EACF;AACF;AAKO,SAAS,sBAA8B;AAE5C,QAAM,eAAe,QAAQA,YAAW,MAAM,aAAa,MAAM;AAEjE,MAAI,WAAW,YAAY,GAAG;AAC5B,WAAO;AAAA,EACT;AAEA,QAAM,IAAI,MAAM,yDAAyD;AAC3E;;;ACnEA,SAAS,aAAa;AAKtB,eAAsB,QAAQ,WAAqC;AACjE,MAAI;AACF,UAAM,MAAM,OAAO,CAAC,MAAM,GAAG,EAAE,KAAK,UAAU,CAAC;AAC/C,UAAM,MAAM,OAAO,CAAC,OAAO,IAAI,GAAG,EAAE,KAAK,UAAU,CAAC;AACpD,UAAM,MAAM,OAAO,CAAC,UAAU,MAAM,qCAAqC,GAAG;AAAA,MAC1E,KAAK;AAAA,IACP,CAAC;AACD,WAAO;AAAA,EACT,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;;;AFJA,SAAS,kBAAkB,KAAa,MAAc,QAAwB;AAC5E,MAAI,CAACC,YAAW,IAAI,GAAG;AACrB,cAAU,MAAM,EAAE,WAAW,KAAK,CAAC;AAAA,EACrC;AAEA,QAAM,UAAU,YAAY,KAAK,EAAE,eAAe,KAAK,CAAC;AAExD,aAAW,SAAS,SAAS;AAC3B,QAAI,OAAO,SAAS,MAAM,IAAI,EAAG;AAEjC,UAAM,UAAU,KAAK,KAAK,MAAM,IAAI;AACpC,UAAM,WAAW,KAAK,MAAM,MAAM,IAAI;AAEtC,QAAI,MAAM,YAAY,GAAG;AACvB,wBAAkB,SAAS,UAAU,MAAM;AAAA,IAC7C,OAAO;AACL,YAAM,UAAU,KAAK,MAAM,SAAS,KAAK,OAAO,EAAE,MAAM,GAAG,EAAE,MAAM,GAAG,EAAE,EAAE,KAAK,GAAG,CAAC;AACnF,UAAI,WAAW,CAACA,YAAW,OAAO,GAAG;AACnC,kBAAU,SAAS,EAAE,WAAW,KAAK,CAAC;AAAA,MACxC;AACA,mBAAa,SAAS,QAAQ;AAAA,IAChC;AAAA,EACF;AACF;AAKA,SAAS,kBAAkB,WAAmB,aAA2B;AACvE,QAAM,UAAU,KAAK,WAAW,cAAc;AAE9C,MAAI,CAACA,YAAW,OAAO,GAAG;AACxB,UAAM,IAAI,MAAM,oCAAoC;AAAA,EACtD;AAEA,QAAM,MAAM,KAAK,MAAM,aAAa,SAAS,OAAO,CAAC;AACrD,MAAI,OAAO;AACX,MAAI,UAAU;AACd,SAAO,IAAI;AACX,SAAO,IAAI;AACX,SAAO,IAAI;AAEX,gBAAc,SAAS,KAAK,UAAU,KAAK,MAAM,CAAC,IAAI,IAAI;AAC5D;AAKA,SAAS,iBAAiB,WAAyB;AACjD,QAAM,eAAe,oBAAoB;AACzC,oBAAkB,cAAc,WAAW,CAAC,CAAC;AAC/C;AAKA,eAAsB,SAAS,SAAyC;AACtE,QAAM,EAAE,aAAa,WAAW,MAAM,eAAe,IAAI;AACzD,QAAMC,WAAY,WAAQ;AAG1B,EAAAA,SAAQ,MAAM,2BAA2B;AAEzC,MAAI;AACF,UAAM,eAAe,oBAAoB;AACzC,sBAAkB,cAAc,WAAW,eAAe;AAC1D,IAAAA,SAAQ,KAAK,uBAAuB;AAAA,EACtC,SAAS,OAAO;AACd,IAAAA,SAAQ,KAAK,yBAAyB;AACtC,UAAM;AAAA,EACR;AAGA,MAAI,MAAM;AACR,IAAAA,SAAQ,MAAM,wBAAwB;AACtC,QAAI;AACF,uBAAiB,SAAS;AAC1B,MAAAA,SAAQ,KAAK,oBAAoB;AAAA,IACnC,SAAS,OAAO;AACd,MAAAA,SAAQ,KAAK,4BAA4B;AACzC,YAAM;AAAA,IACR;AAAA,EACF;AAGA,EAAAA,SAAQ,MAAM,wBAAwB;AACtC,MAAI;AACF,sBAAkB,WAAW,WAAW;AACxC,IAAAA,SAAQ,KAAK,oBAAoB;AAAA,EACnC,SAAS,OAAO;AACd,IAAAA,SAAQ,KAAK,6BAA6B;AAC1C,UAAM;AAAA,EACR;AAGA,EAAAA,SAAQ,MAAM,gCAAgC;AAC9C,QAAM,iBAAiB,MAAM,QAAQ,SAAS;AAC9C,MAAI,gBAAgB;AAClB,IAAAA,SAAQ,KAAK,4BAA4B;AAAA,EAC3C,OAAO;AACL,IAAAA,SAAQ,KAAK,6BAA6B;AAAA,EAC5C;AAGA,EAAAA,SAAQ,MAAM,gCAAgC,cAAc,KAAK;AACjE,MAAI;AACF,UAAM,aAAa,kBAAkB,cAAc;AACnD,UAAM,CAAC,KAAK,GAAG,IAAI,IAAI,WAAW,MAAM,GAAG;AAC3C,UAAMC,OAAM,KAAM,MAAM,EAAE,KAAK,UAAU,CAAC;AAC1C,IAAAD,SAAQ,KAAK,wBAAwB;AAAA,EACvC,SAAS,OAAO;AACd,IAAAA,SAAQ,KAAK,gCAAgC;AAC7C,gBAAY,QAAQ,kBAAkB,cAAc,CAAC,oCAAoC;AAAA,EAC3F;AAEA,cAAY,YAAY,WAAW,yBAAyB;AAC9D;;;AGjIA,SAAS,cAAAE,aAAY,aAAAC,YAAW,eAAAC,cAAa,UAAU,gBAAAC,eAAc,gBAAAC,eAAc,iBAAAC,sBAAqB;AACxG,SAAS,QAAAC,OAAM,WAAAC,gBAAe;AAqCvB,SAAS,WAAW,MAAuB;AAChD,MAAI,CAACC,YAAW,IAAI,EAAG,QAAO;AAC9B,QAAM,QAAQC,aAAY,IAAI;AAC9B,SAAO,MAAM,WAAW,KAAM,MAAM,WAAW,KAAK,MAAM,CAAC,MAAM;AACnE;;;AP/BA,IAAM,YAAY;AAAA,EAChBC,IAAG,KAAK,uBAAuB,CAAC;AAAA;AAAA,EAEhCA,IAAG,KAAK,QAAQ,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMjBA,IAAG,KAAK,UAAU,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMnBA,IAAG,KAAK,WAAW,CAAC;AAAA;AAAA;AAAA;AAAA;AAMtB,IAAM,UAAU;AAEhB,eAAsB,IAAI,MAA+B;AACvD,QAAM,OAAO,IAAgB,MAAM;AAAA,IACjC,SAAS,CAAC,QAAQ,QAAQ,WAAW,KAAK;AAAA,IAC1C,OAAO;AAAA,MACL,GAAG;AAAA,MACH,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AAAA,EACF,CAAC;AAGD,MAAI,KAAK,MAAM;AACb,YAAQ,IAAI,SAAS;AACrB;AAAA,EACF;AAGA,MAAI,KAAK,SAAS;AAChB,YAAQ,IAAI,OAAO;AACnB;AAAA,EACF;AAEA,YAAU;AAGV,QAAM,iBAAiB,KAAK,EAAE,CAAC;AAG/B,MAAI,KAAK,KAAK;AACZ,UAAMC,eAAc,mBAAmB,kBAAkB,kBAAkB;AAC3E,UAAMC,aAAYC,SAAQ,QAAQ,IAAI,GAAGF,YAAW;AAEpD,QAAIG,YAAWF,UAAS,KAAK,CAAC,WAAWA,UAAS,GAAG;AACnD,gBAAU,cAAcD,YAAW,oCAAoC;AACvE,cAAQ,KAAK,CAAC;AAAA,IAChB;AAEA,UAAM,SAAS;AAAA,MACb,aAAAA;AAAA,MACA,WAAAC;AAAA,MACA,MAAM,KAAK,QAAQ;AAAA,MACnB,gBAAgB;AAAA,IAClB,CAAC;AAED,cAAUD,cAAa,MAAM;AAC7B;AAAA,EACF;AAGA,QAAM,UAAU,MAAM,WAAW,gBAAgB,KAAK,IAAI;AAG1D,MAAI,OAAO,YAAY,UAAU;AAC/B;AAAA,EACF;AAEA,QAAM,EAAE,aAAa,MAAM,eAAe,IAAI;AAC9C,QAAM,YAAYE,SAAQ,QAAQ,IAAI,GAAG,WAAW;AAGpD,MAAIC,YAAW,SAAS,KAAK,CAAC,WAAW,SAAS,GAAG;AACnD,UAAM,kBAAkB,MAAQ,WAAQ;AAAA,MACtC,SAAS,cAAc,WAAW;AAAA,MAClC,cAAc;AAAA,IAChB,CAAC;AAED,QAAI,CAAC,mBAAqB,YAAS,eAAe,GAAG;AACnD,MAAE,UAAO,sBAAsB;AAC/B,cAAQ,KAAK,CAAC;AAAA,IAChB;AAAA,EACF;AAGA,MAAI;AACF,UAAM,SAAS;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AAED,cAAU,aAAa,cAAc;AAAA,EACvC,SAAS,OAAO;AACd,cAAU,iBAAiB,QAAQ,MAAM,UAAU,8BAA8B;AACjF,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;;;AQtHA,IAAI,QAAQ,KAAK,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,UAAU;AAC1C,UAAQ,MAAM,KAAK;AACnB,UAAQ,KAAK,CAAC;AAChB,CAAC;","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>
|