@beatzball/create-litro 0.1.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +185 -0
  3. package/README.md +89 -0
  4. package/dist/recipes/11ty-blog/recipe.config.d.ts +4 -0
  5. package/dist/recipes/11ty-blog/recipe.config.d.ts.map +1 -0
  6. package/dist/recipes/11ty-blog/recipe.config.js +9 -0
  7. package/dist/recipes/11ty-blog/recipe.config.js.map +1 -0
  8. package/dist/recipes/11ty-blog/recipe.config.ts +11 -0
  9. package/dist/recipes/11ty-blog/template/app.ts +18 -0
  10. package/dist/recipes/11ty-blog/template/content/_data/metadata.js +10 -0
  11. package/dist/recipes/11ty-blog/template/content/blog/blog.11tydata.json +1 -0
  12. package/dist/recipes/11ty-blog/template/content/blog/getting-started/index.md +66 -0
  13. package/dist/recipes/11ty-blog/template/content/blog/hello-world.md +39 -0
  14. package/dist/recipes/11ty-blog/template/litro.recipe.json +7 -0
  15. package/dist/recipes/11ty-blog/template/nitro.config.ts +60 -0
  16. package/dist/recipes/11ty-blog/template/package.json +26 -0
  17. package/dist/recipes/11ty-blog/template/pages/blog/[slug].ts +65 -0
  18. package/dist/recipes/11ty-blog/template/pages/blog/index.ts +43 -0
  19. package/dist/recipes/11ty-blog/template/pages/index.ts +58 -0
  20. package/dist/recipes/11ty-blog/template/pages/tags/[tag].ts +53 -0
  21. package/dist/recipes/11ty-blog/template/public/.gitkeep +0 -0
  22. package/dist/recipes/11ty-blog/template/server/api/hello.ts +6 -0
  23. package/dist/recipes/11ty-blog/template/server/api/posts.ts +8 -0
  24. package/dist/recipes/11ty-blog/template/server/middleware/vite-dev.ts +29 -0
  25. package/dist/recipes/11ty-blog/template/server/routes/[...].ts +55 -0
  26. package/dist/recipes/11ty-blog/template/tsconfig.json +14 -0
  27. package/dist/recipes/11ty-blog/template/vite.config.ts +19 -0
  28. package/dist/recipes/fullstack/recipe.config.d.ts +4 -0
  29. package/dist/recipes/fullstack/recipe.config.d.ts.map +1 -0
  30. package/dist/recipes/fullstack/recipe.config.js +8 -0
  31. package/dist/recipes/fullstack/recipe.config.js.map +1 -0
  32. package/dist/recipes/fullstack/recipe.config.ts +10 -0
  33. package/dist/recipes/fullstack/template/app.ts +20 -0
  34. package/dist/recipes/fullstack/template/nitro.config.ts +67 -0
  35. package/dist/recipes/fullstack/template/package.json +26 -0
  36. package/dist/recipes/fullstack/template/pages/blog/[slug].ts +43 -0
  37. package/dist/recipes/fullstack/template/pages/blog/index.ts +22 -0
  38. package/dist/recipes/fullstack/template/pages/index.ts +43 -0
  39. package/dist/recipes/fullstack/template/public/.gitkeep +0 -0
  40. package/dist/recipes/fullstack/template/server/api/hello.ts +6 -0
  41. package/dist/recipes/fullstack/template/server/middleware/vite-dev.ts +31 -0
  42. package/dist/recipes/fullstack/template/server/routes/[...].ts +59 -0
  43. package/dist/recipes/fullstack/template/tsconfig.json +14 -0
  44. package/dist/recipes/fullstack/template/vite.config.ts +15 -0
  45. package/dist/src/index.d.ts +17 -0
  46. package/dist/src/index.d.ts.map +1 -0
  47. package/dist/src/index.js +200 -0
  48. package/dist/src/index.js.map +1 -0
  49. package/dist/src/scaffold.d.ts +35 -0
  50. package/dist/src/scaffold.d.ts.map +1 -0
  51. package/dist/src/scaffold.js +166 -0
  52. package/dist/src/scaffold.js.map +1 -0
  53. package/dist/src/scaffold.test.d.ts +2 -0
  54. package/dist/src/scaffold.test.d.ts.map +1 -0
  55. package/dist/src/scaffold.test.js +204 -0
  56. package/dist/src/scaffold.test.js.map +1 -0
  57. package/dist/src/types.d.ts +23 -0
  58. package/dist/src/types.d.ts.map +1 -0
  59. package/dist/src/types.js +2 -0
  60. package/dist/src/types.js.map +1 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -0
  62. package/package.json +28 -0
  63. package/recipes/11ty-blog/recipe.config.ts +11 -0
  64. package/recipes/11ty-blog/template/app.ts +18 -0
  65. package/recipes/11ty-blog/template/content/_data/metadata.js +10 -0
  66. package/recipes/11ty-blog/template/content/blog/blog.11tydata.json +1 -0
  67. package/recipes/11ty-blog/template/content/blog/getting-started/index.md +66 -0
  68. package/recipes/11ty-blog/template/content/blog/hello-world.md +39 -0
  69. package/recipes/11ty-blog/template/litro.recipe.json +7 -0
  70. package/recipes/11ty-blog/template/nitro.config.ts +60 -0
  71. package/recipes/11ty-blog/template/package.json +26 -0
  72. package/recipes/11ty-blog/template/pages/blog/[slug].ts +65 -0
  73. package/recipes/11ty-blog/template/pages/blog/index.ts +43 -0
  74. package/recipes/11ty-blog/template/pages/index.ts +58 -0
  75. package/recipes/11ty-blog/template/pages/tags/[tag].ts +53 -0
  76. package/recipes/11ty-blog/template/public/.gitkeep +0 -0
  77. package/recipes/11ty-blog/template/server/api/hello.ts +6 -0
  78. package/recipes/11ty-blog/template/server/api/posts.ts +8 -0
  79. package/recipes/11ty-blog/template/server/middleware/vite-dev.ts +29 -0
  80. package/recipes/11ty-blog/template/server/routes/[...].ts +55 -0
  81. package/recipes/11ty-blog/template/tsconfig.json +14 -0
  82. package/recipes/11ty-blog/template/vite.config.ts +19 -0
  83. package/recipes/fullstack/recipe.config.ts +10 -0
  84. package/recipes/fullstack/template/app.ts +20 -0
  85. package/recipes/fullstack/template/nitro.config.ts +67 -0
  86. package/recipes/fullstack/template/package.json +26 -0
  87. package/recipes/fullstack/template/pages/blog/[slug].ts +43 -0
  88. package/recipes/fullstack/template/pages/blog/index.ts +22 -0
  89. package/recipes/fullstack/template/pages/index.ts +43 -0
  90. package/recipes/fullstack/template/public/.gitkeep +0 -0
  91. package/recipes/fullstack/template/server/api/hello.ts +6 -0
  92. package/recipes/fullstack/template/server/middleware/vite-dev.ts +31 -0
  93. package/recipes/fullstack/template/server/routes/[...].ts +59 -0
  94. package/recipes/fullstack/template/tsconfig.json +14 -0
  95. package/recipes/fullstack/template/vite.config.ts +15 -0
  96. package/src/index.ts +229 -0
  97. package/src/scaffold.test.ts +228 -0
  98. package/src/scaffold.ts +202 -0
  99. package/src/types.ts +26 -0
  100. package/tsconfig.json +10 -0
@@ -0,0 +1,22 @@
1
+ import { LitElement, html } from 'lit';
2
+ import { customElement } from 'lit/decorators.js';
3
+
4
+ @customElement('page-blog')
5
+ export class BlogPage extends LitElement {
6
+ render() {
7
+ return html`
8
+ <main>
9
+ <h1>Blog</h1>
10
+ <p>Choose a post:</p>
11
+ <ul>
12
+ <li><litro-link href="/blog/hello-world">Hello World</litro-link></li>
13
+ <li><litro-link href="/blog/getting-started">Getting Started</litro-link></li>
14
+ <li><litro-link href="/blog/about-litro">About Litro</litro-link></li>
15
+ </ul>
16
+ <litro-link href="/">← Back Home</litro-link>
17
+ </main>
18
+ `;
19
+ }
20
+ }
21
+
22
+ export default BlogPage;
@@ -0,0 +1,43 @@
1
+ import { html } from 'lit';
2
+ import { customElement, state } from 'lit/decorators.js';
3
+ import { LitroPage } from '@beatzball/litro/runtime';
4
+ import { definePageData } from '@beatzball/litro';
5
+
6
+ export interface HomeData {
7
+ message: string;
8
+ timestamp: string;
9
+ }
10
+
11
+ // Runs on the server before SSR — result injected as JSON into the HTML shell.
12
+ export const pageData = definePageData(async (_event) => {
13
+ return {
14
+ message: 'Hello from {{projectName}}!',
15
+ timestamp: new Date().toISOString(),
16
+ } satisfies HomeData;
17
+ });
18
+
19
+ @customElement('page-home')
20
+ export class HomePage extends LitroPage {
21
+ @state() declare serverData: HomeData | null;
22
+
23
+ // Called on client-side navigation (not on the initial SSR load).
24
+ override async fetchData() {
25
+ const res = await fetch('/api/hello');
26
+ return res.json() as Promise<HomeData>;
27
+ }
28
+
29
+ render() {
30
+ if (this.loading) return html`<p>Loading…</p>`;
31
+ return html`
32
+ <main>
33
+ <h1>${this.serverData?.message ?? 'Welcome to {{projectName}}'}</h1>
34
+ <p><small>Rendered at: ${this.serverData?.timestamp ?? '—'}</small></p>
35
+ <nav>
36
+ <litro-link href="/blog">Go to Blog →</litro-link>
37
+ </nav>
38
+ </main>
39
+ `;
40
+ }
41
+ }
42
+
43
+ export default HomePage;
File without changes
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from 'h3';
2
+
3
+ export default defineEventHandler(() => ({
4
+ message: 'Hello from {{projectName}}!',
5
+ timestamp: new Date().toISOString(),
6
+ }));
@@ -0,0 +1,31 @@
1
+ import { defineEventHandler, fromNodeMiddleware } from 'h3';
2
+
3
+ let viteHandlerPromise: Promise<ReturnType<typeof fromNodeMiddleware>> | null = null;
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ if (!process.dev || !process.env.NITRO_DEV_WORKER_ID) return;
7
+
8
+ if (!viteHandlerPromise) {
9
+ const httpServer = (event.node.req.socket as import('node:net').Socket & {
10
+ server?: import('node:http').Server;
11
+ }).server;
12
+
13
+ viteHandlerPromise = import('vite')
14
+ .then(({ createServer }) =>
15
+ createServer({
16
+ server: {
17
+ middlewareMode: true,
18
+ // Attach Vite HMR WebSocket to Nitro's existing HTTP server so
19
+ // both share a single port with no conflicts.
20
+ hmr: httpServer ? { server: httpServer } : true,
21
+ },
22
+ appType: 'custom',
23
+ root: process.cwd(),
24
+ }),
25
+ )
26
+ .then((server) => fromNodeMiddleware(server.middlewares));
27
+ }
28
+
29
+ const viteHandler = await viteHandlerPromise;
30
+ return viteHandler(event);
31
+ });
@@ -0,0 +1,59 @@
1
+ import { defineEventHandler, setResponseHeader, getRequestURL } from 'h3';
2
+ import { createPageHandler } from '@beatzball/litro/runtime/create-page-handler.js';
3
+ import type { LitroRoute } from '@beatzball/litro';
4
+ import { routes, pageModules } from '#litro/page-manifest';
5
+
6
+ function matchRoute(
7
+ pathname: string,
8
+ ): { route: LitroRoute; params: Record<string, string> } | undefined {
9
+ for (const route of routes) {
10
+ if (route.isCatchAll) return { route, params: {} };
11
+
12
+ if (!route.isDynamic) {
13
+ if (pathname === route.path) return { route, params: {} };
14
+ continue;
15
+ }
16
+
17
+ // Use named capture groups so param values are automatically mapped to names.
18
+ const regexStr =
19
+ '^' +
20
+ route.path
21
+ .replace(/:([^/]+)\(\.\*\)\*/g, '(?<$1>.+)')
22
+ .replace(/:([^/?]+)\?/g, '(?<$1>[^/]*)?')
23
+ .replace(/:([^/]+)/g, '(?<$1>[^/]+)') +
24
+ '$';
25
+
26
+ try {
27
+ const match = pathname.match(new RegExp(regexStr));
28
+ if (match) return { route, params: (match.groups ?? {}) as Record<string, string> };
29
+ } catch {
30
+ // malformed pattern — skip
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export default defineEventHandler(async (event) => {
37
+ const pathname = getRequestURL(event).pathname;
38
+ const result = matchRoute(pathname);
39
+
40
+ if (!result) {
41
+ setResponseHeader(event, 'content-type', 'text/html; charset=utf-8');
42
+ return `<!DOCTYPE html>
43
+ <html lang="en"><head><meta charset="UTF-8" /><title>404</title></head>
44
+ <body><h1>404 — Not Found</h1><p>No page matched <code>${pathname}</code>.</p></body>
45
+ </html>`;
46
+ }
47
+
48
+ const { route: matched, params } = result;
49
+
50
+ // Populate route params (e.g. slug from /blog/:slug) on the event context
51
+ // so pageData fetchers can access them via event.context.params.
52
+ event.context.params = { ...event.context.params, ...params };
53
+
54
+ const handler = createPageHandler({
55
+ route: matched,
56
+ pageModule: pageModules[matched.filePath],
57
+ });
58
+ return handler(event);
59
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "useDefineForClassFields": false
11
+ },
12
+ "include": ["**/*.ts", "**/*.tsx"],
13
+ "exclude": ["node_modules", "dist", ".nitro"]
14
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ build: {
5
+ outDir: 'dist/client',
6
+ rollupOptions: {
7
+ input: 'app.ts',
8
+ output: {
9
+ // Stable (non-hashed) entry filename so the HTML shell can always
10
+ // reference /_litro/app.js without knowing the content hash.
11
+ entryFileNames: '[name].js',
12
+ },
13
+ },
14
+ },
15
+ });
package/src/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-litro — Scaffolding CLI for Litro
4
+ *
5
+ * Usage:
6
+ * npm create litro
7
+ * npx create-litro
8
+ * npx create-litro <project-name> [--recipe <recipe>] [--mode <ssg|ssr>]
9
+ * npx create-litro --list-recipes
10
+ *
11
+ * Prompts for project name, recipe, and mode, then scaffolds a complete
12
+ * Litro project from the selected recipe template.
13
+ *
14
+ * No external dependencies — uses Node.js built-ins only.
15
+ */
16
+
17
+ import { createInterface } from 'node:readline/promises';
18
+ import { stdin as input, stdout as output } from 'node:process';
19
+ import { existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import process from 'node:process';
22
+ import { listRecipes, loadRecipe, scaffold } from './scaffold.js';
23
+ import type { LitroRecipe } from './types.js';
24
+ import type { ScaffoldOptions } from './scaffold.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Prompt helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ async function prompt(question: string, defaultVal = ''): Promise<string> {
31
+ // If stdin is not a TTY (piped/redirected), use the default immediately.
32
+ if (!process.stdin.isTTY) return defaultVal;
33
+
34
+ const rl = createInterface({ input, output });
35
+ const answer = await rl.question(
36
+ defaultVal ? `${question} (${defaultVal}): ` : `${question}: `,
37
+ );
38
+ rl.close();
39
+ return answer.trim() || defaultVal;
40
+ }
41
+
42
+ async function promptSelect(question: string, choices: string[], defaultVal?: string): Promise<string> {
43
+ if (!process.stdin.isTTY) return defaultVal ?? choices[0];
44
+
45
+ const lines = choices.map((c, i) => ` ${i + 1}. ${c}`).join('\n');
46
+ const defaultIdx = defaultVal ? choices.indexOf(defaultVal) + 1 : 1;
47
+ const rl = createInterface({ input, output });
48
+
49
+ // eslint-disable-next-line no-constant-condition
50
+ while (true) {
51
+ const answer = await rl.question(`${question}\n${lines}\n Choice (${defaultIdx}): `);
52
+ const trimmed = answer.trim();
53
+ if (trimmed === '') {
54
+ rl.close();
55
+ return choices[defaultIdx - 1];
56
+ }
57
+ const n = parseInt(trimmed, 10);
58
+ if (!isNaN(n) && n >= 1 && n <= choices.length) {
59
+ rl.close();
60
+ return choices[n - 1];
61
+ }
62
+ // Allow typing the value directly.
63
+ if (choices.includes(trimmed)) {
64
+ rl.close();
65
+ return trimmed;
66
+ }
67
+ process.stdout.write(` Please enter a number between 1 and ${choices.length}.\n`);
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // CLI argument parsing
73
+ // ---------------------------------------------------------------------------
74
+
75
+ interface ParsedArgs {
76
+ projectName: string | undefined;
77
+ recipe: string | undefined;
78
+ mode: 'ssg' | 'ssr' | undefined;
79
+ listRecipes: boolean;
80
+ }
81
+
82
+ function parseArgs(argv: string[]): ParsedArgs {
83
+ // argv = process.argv.slice(2)
84
+ let projectName: string | undefined;
85
+ let recipe: string | undefined;
86
+ let mode: 'ssg' | 'ssr' | undefined;
87
+ let listRecipesFlag = false;
88
+
89
+ for (let i = 0; i < argv.length; i++) {
90
+ const arg = argv[i];
91
+ if (arg === '--list-recipes') {
92
+ listRecipesFlag = true;
93
+ } else if (arg === '--recipe' || arg === '-r') {
94
+ recipe = argv[++i];
95
+ } else if (arg === '--mode' || arg === '-m') {
96
+ const val = argv[++i];
97
+ if (val === 'ssg' || val === 'ssr') mode = val;
98
+ } else if (!arg.startsWith('-') && projectName === undefined) {
99
+ projectName = arg;
100
+ }
101
+ }
102
+
103
+ return { projectName, recipe, mode, listRecipes: listRecipesFlag };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Main
108
+ // ---------------------------------------------------------------------------
109
+
110
+ async function main(): Promise<void> {
111
+ const args = parseArgs(process.argv.slice(2));
112
+
113
+ // --list-recipes: print available recipes and exit.
114
+ if (args.listRecipes) {
115
+ const recipes = await listRecipes();
116
+ if (recipes.length === 0) {
117
+ console.log('\n No recipes found.\n');
118
+ } else {
119
+ console.log('\n Available recipes:\n');
120
+ for (const r of recipes) {
121
+ console.log(` ${r.name.padEnd(20)} ${r.displayName} — ${r.description}`);
122
+ }
123
+ console.log('');
124
+ }
125
+ return;
126
+ }
127
+
128
+ console.log('\n Welcome to Litro!\n');
129
+
130
+ // 1. Project name
131
+ const projectName = args.projectName ?? await prompt('Project name', 'my-litro-app');
132
+
133
+ // 2. Recipe selection
134
+ const recipes = await listRecipes();
135
+ let chosenRecipe: LitroRecipe;
136
+
137
+ if (args.recipe) {
138
+ const found = await loadRecipe(args.recipe);
139
+ if (!found) {
140
+ console.error(`\n Error: recipe "${args.recipe}" not found.\n`);
141
+ process.exit(1);
142
+ }
143
+ chosenRecipe = found;
144
+ } else if (recipes.length === 0) {
145
+ console.error('\n Error: no recipes available.\n');
146
+ process.exit(1);
147
+ } else if (recipes.length === 1) {
148
+ chosenRecipe = recipes[0];
149
+ } else {
150
+ const displayNames = recipes.map((r) => `${r.name} — ${r.description}`);
151
+ const selected = await promptSelect('Select a recipe:', displayNames);
152
+ // Match back to the recipe by index in displayNames.
153
+ const idx = displayNames.indexOf(selected);
154
+ chosenRecipe = recipes[idx !== -1 ? idx : 0];
155
+ }
156
+
157
+ // 3. Mode selection (only if recipe supports both)
158
+ let mode: 'ssg' | 'ssr';
159
+ if (chosenRecipe.mode === 'both') {
160
+ if (args.mode) {
161
+ mode = args.mode;
162
+ } else {
163
+ const selected = await promptSelect(
164
+ 'Deployment mode:',
165
+ ['ssr — Server-side rendering (Node.js / edge)', 'ssg — Static site generation (CDN)'],
166
+ 'ssr — Server-side rendering (Node.js / edge)',
167
+ );
168
+ mode = selected.startsWith('ssg') ? 'ssg' : 'ssr';
169
+ }
170
+ } else {
171
+ mode = chosenRecipe.mode as 'ssg' | 'ssr';
172
+ }
173
+
174
+ // 4. Recipe-specific options (prompt for any that are defined on the recipe)
175
+ const recipeOptions: Record<string, unknown> = {};
176
+ if (chosenRecipe.options && chosenRecipe.options.length > 0) {
177
+ for (const opt of chosenRecipe.options) {
178
+ if (opt.type === 'select' && opt.choices) {
179
+ const selected = await promptSelect(opt.prompt, opt.choices, opt.default as string | undefined);
180
+ recipeOptions[opt.key] = selected;
181
+ } else if (opt.type === 'confirm') {
182
+ const answer = await prompt(`${opt.prompt} (y/n)`, opt.default ? 'y' : 'n');
183
+ recipeOptions[opt.key] = answer.toLowerCase().startsWith('y');
184
+ } else {
185
+ // text
186
+ const answer = await prompt(opt.prompt, String(opt.default ?? ''));
187
+ recipeOptions[opt.key] = answer;
188
+ }
189
+ }
190
+ }
191
+
192
+ // 5. Validate target directory
193
+ const projectDir = join(process.cwd(), projectName);
194
+
195
+ if (existsSync(projectDir)) {
196
+ console.error(`\n Error: directory "${projectName}" already exists.\n`);
197
+ process.exit(1);
198
+ }
199
+
200
+ // 6. Scaffold
201
+ const options: ScaffoldOptions = {
202
+ projectName,
203
+ mode,
204
+ recipeOptions,
205
+ recipeVersion: '0.0.1',
206
+ };
207
+
208
+ await scaffold(chosenRecipe.name, options, projectDir);
209
+
210
+ console.log(`
211
+ Created ${projectName}
212
+
213
+ Next steps:
214
+
215
+ cd ${projectName}
216
+ npm install # or: pnpm install / yarn install
217
+ npm run dev # start dev server on http://localhost:3030
218
+
219
+ Commands:
220
+ npm run dev start development server
221
+ npm run build production build (Vite + Nitro)
222
+ npm run preview preview the production build
223
+ `);
224
+ }
225
+
226
+ main().catch((err: unknown) => {
227
+ console.error('[create-litro] Fatal error:', err);
228
+ process.exit(1);
229
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { scaffold } from './scaffold.js';
3
+ import { mkdtemp, rm, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ async function withTmpDir(fn: (dir: string) => Promise<void>): Promise<void> {
8
+ const dir = await mkdtemp(join(tmpdir(), 'litro-scaffold-test-'));
9
+ try {
10
+ await fn(dir);
11
+ } finally {
12
+ await rm(dir, { recursive: true, force: true });
13
+ }
14
+ }
15
+
16
+ describe('scaffold', () => {
17
+ it('fullstack recipe writes package.json with projectName interpolated', async () => {
18
+ await withTmpDir(async (dir) => {
19
+ const targetDir = join(dir, 'my-app');
20
+ await scaffold('fullstack', { projectName: 'my-app', mode: 'ssr' }, targetDir);
21
+
22
+ const pkg = JSON.parse(await readFile(join(targetDir, 'package.json'), 'utf-8')) as Record<string, unknown>;
23
+ expect(pkg.name).toBe('my-app');
24
+ });
25
+ });
26
+
27
+ it('fullstack recipe writes all expected files', async () => {
28
+ await withTmpDir(async (dir) => {
29
+ const targetDir = join(dir, 'test-app');
30
+ await scaffold('fullstack', { projectName: 'test-app', mode: 'ssr' }, targetDir);
31
+
32
+ const { existsSync } = await import('node:fs');
33
+ expect(existsSync(join(targetDir, 'package.json'))).toBe(true);
34
+ expect(existsSync(join(targetDir, 'tsconfig.json'))).toBe(true);
35
+ expect(existsSync(join(targetDir, 'nitro.config.ts'))).toBe(true);
36
+ expect(existsSync(join(targetDir, 'vite.config.ts'))).toBe(true);
37
+ expect(existsSync(join(targetDir, 'app.ts'))).toBe(true);
38
+ expect(existsSync(join(targetDir, 'pages/index.ts'))).toBe(true);
39
+ expect(existsSync(join(targetDir, 'pages/blog/index.ts'))).toBe(true);
40
+ expect(existsSync(join(targetDir, 'pages/blog/[slug].ts'))).toBe(true);
41
+ expect(existsSync(join(targetDir, 'server/api/hello.ts'))).toBe(true);
42
+ expect(existsSync(join(targetDir, 'server/routes/[...].ts'))).toBe(true);
43
+ expect(existsSync(join(targetDir, 'server/stubs/page-manifest.ts'))).toBe(true);
44
+ expect(existsSync(join(targetDir, '.gitignore'))).toBe(true);
45
+ });
46
+ });
47
+
48
+ it('interpolates {{projectName}} in multiple files', async () => {
49
+ await withTmpDir(async (dir) => {
50
+ const targetDir = join(dir, 'cool-blog');
51
+ await scaffold('fullstack', { projectName: 'cool-blog', mode: 'ssr' }, targetDir);
52
+
53
+ const pkg = await readFile(join(targetDir, 'package.json'), 'utf-8');
54
+ expect(pkg).toContain('cool-blog');
55
+
56
+ const hello = await readFile(join(targetDir, 'server/api/hello.ts'), 'utf-8');
57
+ expect(hello).toContain('cool-blog');
58
+
59
+ const index = await readFile(join(targetDir, 'pages/index.ts'), 'utf-8');
60
+ expect(index).toContain('cool-blog');
61
+ });
62
+ });
63
+
64
+ it('no un-interpolated {{ }} remain in any output file', async () => {
65
+ await withTmpDir(async (dir) => {
66
+ const targetDir = join(dir, 'my-project');
67
+ await scaffold('fullstack', { projectName: 'my-project', mode: 'ssr' }, targetDir);
68
+
69
+ const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.svg',
70
+ '.woff', '.woff2', '.ttf', '.eot', '.otf', '.pdf', '.zip', '.gz', '.tar', '.gitkeep']);
71
+
72
+ async function collectFiles(d: string): Promise<string[]> {
73
+ const { readdir: rd } = await import('node:fs/promises');
74
+ const entries = await rd(d, { withFileTypes: true });
75
+ const results: string[] = [];
76
+ for (const e of entries) {
77
+ const p = join(d, e.name);
78
+ if (e.isDirectory()) {
79
+ results.push(...(await collectFiles(p)));
80
+ } else {
81
+ results.push(p);
82
+ }
83
+ }
84
+ return results;
85
+ }
86
+
87
+ const files = await collectFiles(targetDir);
88
+
89
+ for (const file of files) {
90
+ const extPart = file.includes('.') ? `.${file.split('.').pop()}` : '';
91
+ if (binaryExts.has(extPart.toLowerCase())) continue;
92
+ const content = await readFile(file, 'utf-8');
93
+ const matches = content.match(/\{\{[^}]+\}\}/g);
94
+ if (matches) {
95
+ throw new Error(`Un-interpolated placeholder in ${file}: ${matches.join(', ')}`);
96
+ }
97
+ }
98
+ });
99
+ });
100
+
101
+ it('11ty-blog recipe with mode=ssg writes litro.recipe.json', async () => {
102
+ await withTmpDir(async (dir) => {
103
+ const targetDir = join(dir, 'my-blog');
104
+ await scaffold('11ty-blog', { projectName: 'my-blog', mode: 'ssg' }, targetDir);
105
+
106
+ const { existsSync } = await import('node:fs');
107
+ expect(existsSync(join(targetDir, 'litro.recipe.json'))).toBe(true);
108
+
109
+ const manifest = JSON.parse(await readFile(join(targetDir, 'litro.recipe.json'), 'utf-8')) as Record<string, unknown>;
110
+ expect(manifest.recipe).toBe('11ty-blog');
111
+ expect(manifest.mode).toBe('ssg');
112
+ expect(manifest.contentDir).toBe('content/blog');
113
+ });
114
+ });
115
+
116
+ it('11ty-blog recipe writes content directory structure', async () => {
117
+ await withTmpDir(async (dir) => {
118
+ const targetDir = join(dir, 'my-blog');
119
+ await scaffold('11ty-blog', { projectName: 'my-blog', mode: 'ssr' }, targetDir);
120
+
121
+ const { existsSync } = await import('node:fs');
122
+ expect(existsSync(join(targetDir, 'content/blog/hello-world.md'))).toBe(true);
123
+ expect(existsSync(join(targetDir, 'content/blog/blog.11tydata.json'))).toBe(true);
124
+ expect(existsSync(join(targetDir, 'content/_data/metadata.js'))).toBe(true);
125
+ });
126
+ });
127
+
128
+ it('11ty-blog recipe interpolates {{projectName}} in metadata.js', async () => {
129
+ await withTmpDir(async (dir) => {
130
+ const targetDir = join(dir, 'awesome-blog');
131
+ await scaffold('11ty-blog', { projectName: 'awesome-blog', mode: 'ssr' }, targetDir);
132
+
133
+ const metadata = await readFile(join(targetDir, 'content/_data/metadata.js'), 'utf-8');
134
+ expect(metadata).toContain('awesome-blog');
135
+ expect(metadata).not.toContain('{{projectName}}');
136
+ });
137
+ });
138
+
139
+ it('11ty-blog recipe writes all expected files', async () => {
140
+ await withTmpDir(async (dir) => {
141
+ const targetDir = join(dir, 'my-blog');
142
+ await scaffold('11ty-blog', { projectName: 'my-blog', mode: 'ssg' }, targetDir);
143
+
144
+ const { existsSync } = await import('node:fs');
145
+ // Core config
146
+ expect(existsSync(join(targetDir, 'package.json'))).toBe(true);
147
+ expect(existsSync(join(targetDir, 'tsconfig.json'))).toBe(true);
148
+ expect(existsSync(join(targetDir, 'nitro.config.ts'))).toBe(true);
149
+ expect(existsSync(join(targetDir, 'vite.config.ts'))).toBe(true);
150
+ expect(existsSync(join(targetDir, 'app.ts'))).toBe(true);
151
+ expect(existsSync(join(targetDir, '.gitignore'))).toBe(true);
152
+ expect(existsSync(join(targetDir, 'litro.recipe.json'))).toBe(true);
153
+ // Pages — including tags page
154
+ expect(existsSync(join(targetDir, 'pages/index.ts'))).toBe(true);
155
+ expect(existsSync(join(targetDir, 'pages/blog/index.ts'))).toBe(true);
156
+ expect(existsSync(join(targetDir, 'pages/blog/[slug].ts'))).toBe(true);
157
+ expect(existsSync(join(targetDir, 'pages/tags/[tag].ts'))).toBe(true);
158
+ // Server
159
+ expect(existsSync(join(targetDir, 'server/routes/[...].ts'))).toBe(true);
160
+ expect(existsSync(join(targetDir, 'server/middleware/vite-dev.ts'))).toBe(true);
161
+ expect(existsSync(join(targetDir, 'server/api/posts.ts'))).toBe(true);
162
+ expect(existsSync(join(targetDir, 'server/stubs/page-manifest.ts'))).toBe(true);
163
+ });
164
+ });
165
+
166
+ it('11ty-blog recipe has no un-interpolated {{ }} in any output file', async () => {
167
+ await withTmpDir(async (dir) => {
168
+ const targetDir = join(dir, 'my-blog');
169
+ await scaffold('11ty-blog', { projectName: 'my-blog', mode: 'ssg' }, targetDir);
170
+
171
+ const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.svg',
172
+ '.woff', '.woff2', '.ttf', '.eot', '.otf', '.pdf', '.zip', '.gz', '.tar', '.gitkeep']);
173
+
174
+ async function collectFiles(d: string): Promise<string[]> {
175
+ const { readdir: rd } = await import('node:fs/promises');
176
+ const entries = await rd(d, { withFileTypes: true });
177
+ const results: string[] = [];
178
+ for (const e of entries) {
179
+ const p = join(d, e.name);
180
+ if (e.isDirectory()) results.push(...(await collectFiles(p)));
181
+ else results.push(p);
182
+ }
183
+ return results;
184
+ }
185
+
186
+ const files = await collectFiles(targetDir);
187
+ for (const file of files) {
188
+ const extPart = file.includes('.') ? `.${file.split('.').pop()}` : '';
189
+ if (binaryExts.has(extPart.toLowerCase())) continue;
190
+ const content = await readFile(file, 'utf-8');
191
+ const matches = content.match(/\{\{[^}]+\}\}/g);
192
+ if (matches) {
193
+ throw new Error(`Un-interpolated placeholder in ${file}: ${matches.join(', ')}`);
194
+ }
195
+ }
196
+ });
197
+ });
198
+
199
+ it('11ty-blog page files use plain <a> tags, not <litro-link>', async () => {
200
+ // SSG pages must use <a> for full-page-reload navigation so each pre-rendered
201
+ // page loads its own __litro_data__. <litro-link> triggers SPA navigation which
202
+ // leaves serverData=null on the navigated page (no __litro_data__ in DOM).
203
+ await withTmpDir(async (dir) => {
204
+ const targetDir = join(dir, 'my-blog');
205
+ await scaffold('11ty-blog', { projectName: 'my-blog', mode: 'ssg' }, targetDir);
206
+
207
+ const pageFiles = [
208
+ join(targetDir, 'pages/index.ts'),
209
+ join(targetDir, 'pages/blog/index.ts'),
210
+ join(targetDir, 'pages/blog/[slug].ts'),
211
+ join(targetDir, 'pages/tags/[tag].ts'),
212
+ ];
213
+
214
+ for (const file of pageFiles) {
215
+ const content = await readFile(file, 'utf-8');
216
+ expect(content, `${file} should not contain <litro-link>`).not.toContain('litro-link');
217
+ }
218
+ });
219
+ });
220
+
221
+ it('throws for unknown recipe', async () => {
222
+ await withTmpDir(async (dir) => {
223
+ const targetDir = join(dir, 'unknown');
224
+ await expect(scaffold('does-not-exist', { projectName: 'test', mode: 'ssr' }, targetDir))
225
+ .rejects.toThrow();
226
+ });
227
+ });
228
+ });