@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,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
+ import type { Post } from 'litro:content';
6
+ import { getPosts } from 'litro:content';
7
+
8
+ export interface BlogIndexData {
9
+ posts: Post[];
10
+ }
11
+
12
+ function toDate(d: Date | string): Date { return d instanceof Date ? d : new Date(d as string); }
13
+
14
+ export const pageData = definePageData(async (_event) => {
15
+ const posts = await getPosts();
16
+ return { posts } satisfies BlogIndexData;
17
+ });
18
+
19
+ @customElement('page-blog')
20
+ export class BlogIndexPage extends LitroPage {
21
+ @state() declare serverData: BlogIndexData | null;
22
+
23
+ render() {
24
+ const { posts = [] } = this.serverData ?? {};
25
+ return html`
26
+ <main>
27
+ <h1>Blog</h1>
28
+ <ul>
29
+ ${posts.map(post => html`
30
+ <li>
31
+ <a href="${post.url}">${post.title}</a>
32
+ <time datetime="${toDate(post.date).toISOString()}">${toDate(post.date).toLocaleDateString()}</time>
33
+ ${post.description ? html`<p>${post.description}</p>` : ''}
34
+ </li>
35
+ `)}
36
+ </ul>
37
+ <a href="/">← Home</a>
38
+ </main>
39
+ `;
40
+ }
41
+ }
42
+
43
+ export default BlogIndexPage;
@@ -0,0 +1,58 @@
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
+ import type { Post } from 'litro:content';
6
+ import { getPosts, getGlobalData } from 'litro:content';
7
+
8
+ export interface HomeData {
9
+ recentPosts: Post[];
10
+ siteTitle: string;
11
+ siteDescription: string;
12
+ }
13
+
14
+ function toDate(d: Date | string): Date { return d instanceof Date ? d : new Date(d as string); }
15
+
16
+ export const pageData = definePageData(async (_event) => {
17
+ const [recentPosts, metadata] = await Promise.all([
18
+ getPosts({ limit: 5 }),
19
+ getGlobalData(),
20
+ ]);
21
+ return {
22
+ recentPosts,
23
+ siteTitle: String(metadata.title ?? '{{projectName}}'),
24
+ siteDescription: String(metadata.description ?? ''),
25
+ } satisfies HomeData;
26
+ });
27
+
28
+ @customElement('page-home')
29
+ export class HomePage extends LitroPage {
30
+ @state() declare serverData: HomeData | null;
31
+
32
+ render() {
33
+ const { recentPosts = [], siteTitle = '{{projectName}}', siteDescription = '' } = this.serverData ?? {};
34
+ return html`
35
+ <main>
36
+ <header>
37
+ <h1>${siteTitle}</h1>
38
+ ${siteDescription ? html`<p>${siteDescription}</p>` : ''}
39
+ </header>
40
+ <section>
41
+ <h2>Recent Posts</h2>
42
+ <ul>
43
+ ${recentPosts.map(post => html`
44
+ <li>
45
+ <a href="${post.url}">${post.title}</a>
46
+ <time datetime="${toDate(post.date).toISOString()}">${toDate(post.date).toLocaleDateString()}</time>
47
+ ${post.description ? html`<p>${post.description}</p>` : ''}
48
+ </li>
49
+ `)}
50
+ </ul>
51
+ <p><a href="/blog">All Posts →</a></p>
52
+ </section>
53
+ </main>
54
+ `;
55
+ }
56
+ }
57
+
58
+ export default HomePage;
@@ -0,0 +1,53 @@
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
+ import type { Post } from 'litro:content';
6
+ import { getPosts, getTags } from 'litro:content';
7
+
8
+ export interface TagData {
9
+ tag: string;
10
+ posts: Post[];
11
+ }
12
+
13
+ function toDate(d: Date | string): Date { return d instanceof Date ? d : new Date(d as string); }
14
+
15
+ export const pageData = definePageData(async (event) => {
16
+ const tag = event.context.params?.tag ?? '';
17
+ const posts = await getPosts({ tag });
18
+ return { tag, posts } satisfies TagData;
19
+ });
20
+
21
+ export async function generateRoutes(): Promise<string[]> {
22
+ const tags = await getTags();
23
+ return tags.map(tag => `/tags/${tag}`);
24
+ }
25
+
26
+ @customElement('page-tags-tag')
27
+ export class TagPage extends LitroPage {
28
+ @state() declare serverData: TagData | null;
29
+
30
+ render() {
31
+ const { tag = '', posts = [] } = this.serverData ?? {};
32
+ return html`
33
+ <main>
34
+ <h1>Posts tagged: #${tag}</h1>
35
+ <ul>
36
+ ${posts.map(post => html`
37
+ <li>
38
+ <a href="${post.url}">${post.title}</a>
39
+ <time datetime="${toDate(post.date).toISOString()}">${toDate(post.date).toLocaleDateString()}</time>
40
+ </li>
41
+ `)}
42
+ </ul>
43
+ <p>
44
+ <a href="/blog">← All Posts</a>
45
+ &nbsp;|&nbsp;
46
+ <a href="/">← Home</a>
47
+ </p>
48
+ </main>
49
+ `;
50
+ }
51
+ }
52
+
53
+ export default TagPage;
@@ -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,8 @@
1
+ import { defineEventHandler, getQuery } from 'h3';
2
+ import { getPosts } from 'litro:content';
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const query = getQuery(event);
6
+ const tag = typeof query.tag === 'string' ? query.tag : undefined;
7
+ return getPosts({ tag });
8
+ });
@@ -0,0 +1,29 @@
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
+ hmr: httpServer ? { server: httpServer } : true,
19
+ },
20
+ appType: 'custom',
21
+ root: process.cwd(),
22
+ }),
23
+ )
24
+ .then((server) => fromNodeMiddleware(server.middlewares));
25
+ }
26
+
27
+ const viteHandler = await viteHandlerPromise;
28
+ return viteHandler(event);
29
+ });
@@ -0,0 +1,55 @@
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
+ const regexStr =
18
+ '^' +
19
+ route.path
20
+ .replace(/:([^/]+)\(\.\*\)\*/g, '(?<$1>.+)')
21
+ .replace(/:([^/?]+)\?/g, '(?<$1>[^/]*)?')
22
+ .replace(/:([^/]+)/g, '(?<$1>[^/]+)') +
23
+ '$';
24
+
25
+ try {
26
+ const match = pathname.match(new RegExp(regexStr));
27
+ if (match) return { route, params: (match.groups ?? {}) as Record<string, string> };
28
+ } catch {
29
+ // malformed pattern — skip
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ export default defineEventHandler(async (event) => {
36
+ const pathname = getRequestURL(event).pathname;
37
+ const result = matchRoute(pathname);
38
+
39
+ if (!result) {
40
+ setResponseHeader(event, 'content-type', 'text/html; charset=utf-8');
41
+ return `<!DOCTYPE html>
42
+ <html lang="en"><head><meta charset="UTF-8" /><title>404</title></head>
43
+ <body><h1>404 — Not Found</h1><p>No page matched <code>${pathname}</code>.</p></body>
44
+ </html>`;
45
+ }
46
+
47
+ const { route: matched, params } = result;
48
+ event.context.params = { ...event.context.params, ...params };
49
+
50
+ const handler = createPageHandler({
51
+ route: matched,
52
+ pageModule: pageModules[matched.filePath],
53
+ });
54
+ return handler(event);
55
+ });
@@ -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,19 @@
1
+ import { defineConfig } from 'vite';
2
+ import litroContentPlugin from '@beatzball/litro/vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [litroContentPlugin()],
6
+ base: '/_litro/',
7
+ resolve: {
8
+ conditions: ['source', 'browser', 'module', 'import', 'default'],
9
+ },
10
+ build: {
11
+ outDir: 'dist/client',
12
+ rollupOptions: {
13
+ input: 'app.ts',
14
+ output: {
15
+ entryFileNames: '[name].js',
16
+ },
17
+ },
18
+ },
19
+ });
@@ -0,0 +1,4 @@
1
+ import type { LitroRecipe } from '../../src/types.js';
2
+ declare const recipe: LitroRecipe;
3
+ export default recipe;
4
+ //# sourceMappingURL=recipe.config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipe.config.d.ts","sourceRoot":"","sources":["../../../recipes/fullstack/recipe.config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,QAAA,MAAM,MAAM,EAAE,WAKb,CAAC;AAEF,eAAe,MAAM,CAAC"}
@@ -0,0 +1,8 @@
1
+ const recipe = {
2
+ name: 'fullstack',
3
+ displayName: 'Fullstack App',
4
+ description: 'Full-stack Lit + Nitro app with SSR and blog example pages',
5
+ mode: 'both',
6
+ };
7
+ export default recipe;
8
+ //# sourceMappingURL=recipe.config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipe.config.js","sourceRoot":"","sources":["../../../recipes/fullstack/recipe.config.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,GAAgB;IAC1B,IAAI,EAAE,WAAW;IACjB,WAAW,EAAE,eAAe;IAC5B,WAAW,EAAE,4DAA4D;IACzE,IAAI,EAAE,MAAM;CACb,CAAC;AAEF,eAAe,MAAM,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { LitroRecipe } from '../../src/types.js';
2
+
3
+ const recipe: LitroRecipe = {
4
+ name: 'fullstack',
5
+ displayName: 'Fullstack App',
6
+ description: 'Full-stack Lit + Nitro app with SSR and blog example pages',
7
+ mode: 'both',
8
+ };
9
+
10
+ export default recipe;
@@ -0,0 +1,20 @@
1
+ // CRITICAL: must be first — patches LitElement before any component is loaded.
2
+ import '@lit-labs/ssr-client/lit-element-hydrate-support.js';
3
+
4
+ // Client runtime: router outlet and link custom elements.
5
+ import 'litro/runtime/LitroOutlet.js';
6
+ import 'litro/runtime/LitroLink.js';
7
+
8
+ // Routes generated by the page scanner before each vite build.
9
+ // routes.generated.ts lives at the project root (not in dist/) so Vite's
10
+ // emptyOutDir does not delete it between builds.
11
+ import { routes } from './routes.generated.js';
12
+
13
+ document.addEventListener('DOMContentLoaded', () => {
14
+ const outlet = document.querySelector('litro-outlet') as (Element & { routes: unknown }) | null;
15
+ if (outlet) {
16
+ outlet.routes = routes;
17
+ } else {
18
+ console.warn('[litro] <litro-outlet> not found — router will not start.');
19
+ }
20
+ });
@@ -0,0 +1,67 @@
1
+ import { defineNitroConfig } from 'nitropack/config';
2
+ import type { Nitro } from 'nitropack';
3
+ import { resolve } from 'node:path';
4
+ import { ssrPreset, ssgPreset } from '@beatzball/litro/config';
5
+ import pagesPlugin from '@beatzball/litro/plugins';
6
+ import ssgPlugin from '@beatzball/litro/plugins/ssg';
7
+
8
+ // LITRO_MODE controls the deployment target at build time:
9
+ // LITRO_MODE=server litro build (default — Node.js server)
10
+ // LITRO_MODE=static litro generate (SSG — static HTML for CDN)
11
+ const mode = process.env.LITRO_MODE ?? 'server';
12
+
13
+ export default defineNitroConfig({
14
+ ...(mode === 'static' ? ssgPreset() : ssrPreset()),
15
+
16
+ // Nitro auto-discovers server/routes/, server/api/, server/middleware/
17
+ srcDir: 'server',
18
+
19
+ // publicAssets.dir is resolved relative to srcDir ('server/').
20
+ // Use '../dist/client' (not 'dist/client') to reach <rootDir>/dist/client.
21
+ publicAssets: [
22
+ { dir: '../dist/client', baseURL: '/_litro/', maxAge: 31536000 },
23
+ { dir: '../public', baseURL: '/', maxAge: 0 },
24
+ ],
25
+
26
+ // Must be bundled for edge runtimes (Cloudflare Workers, Vercel Edge).
27
+ externals: { inline: ['@lit-labs/ssr', '@lit-labs/ssr-client'] },
28
+
29
+ // Lit uses legacy experimental decorators.
30
+ esbuild: {
31
+ options: {
32
+ tsconfigRaw: {
33
+ compilerOptions: {
34
+ experimentalDecorators: true,
35
+ useDefineForClassFields: false,
36
+ },
37
+ },
38
+ },
39
+ },
40
+
41
+ // Exclude vite-dev.ts from auto-discovery so import('vite') never enters
42
+ // the production module graph. Re-register with env:'dev' so it only runs
43
+ // during development.
44
+ ignore: ['**/middleware/vite-dev.ts'],
45
+ handlers: [
46
+ {
47
+ middleware: true,
48
+ handler: resolve('./server/middleware/vite-dev.ts'),
49
+ env: 'dev',
50
+ },
51
+ ],
52
+
53
+ hooks: {
54
+ 'build:before': async (nitro: Nitro) => {
55
+ await pagesPlugin(nitro);
56
+ await ssgPlugin(nitro);
57
+ },
58
+ },
59
+
60
+ compatibilityDate: '2025-01-01',
61
+
62
+ routeRules: {
63
+ '/_litro/**': {
64
+ headers: { 'cache-control': 'public, max-age=31536000, immutable' },
65
+ },
66
+ },
67
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "imports": {
6
+ "#litro/page-manifest": "./server/stubs/page-manifest.ts"
7
+ },
8
+ "scripts": {
9
+ "dev": "litro dev",
10
+ "build": "litro build",
11
+ "preview": "litro preview",
12
+ "generate": "litro generate"
13
+ },
14
+ "dependencies": {
15
+ "@beatzball/litro": "latest",
16
+ "lit": "^3.2.1",
17
+ "@lit-labs/ssr": "^3.3.0",
18
+ "@lit-labs/ssr-client": "^1.1.7",
19
+ "h3": "^1.13.0"
20
+ },
21
+ "devDependencies": {
22
+ "nitropack": "^2.10.4",
23
+ "vite": "^5.4.11",
24
+ "typescript": "^5.7.3"
25
+ }
26
+ }
@@ -0,0 +1,43 @@
1
+ import { LitElement, html } from 'lit';
2
+ import { customElement, state } from 'lit/decorators.js';
3
+ import { definePageData } from '@beatzball/litro';
4
+
5
+ export interface PostData {
6
+ slug: string;
7
+ title: string;
8
+ content: string;
9
+ }
10
+
11
+ // Runs on the server; event.context.params contains the matched route params.
12
+ export const pageData = definePageData(async (event) => {
13
+ const slug = event.context.params?.slug ?? '';
14
+ return {
15
+ slug,
16
+ title: `Post: ${slug}`,
17
+ content: `This is the content for the "${slug}" post.`,
18
+ } satisfies PostData;
19
+ });
20
+
21
+ // Tells the SSG which concrete paths to prerender when LITRO_MODE=static.
22
+ export async function generateRoutes(): Promise<string[]> {
23
+ return ['/blog/hello-world', '/blog/getting-started', '/blog/about-litro'];
24
+ }
25
+
26
+ @customElement('page-blog-slug')
27
+ export class BlogPostPage extends LitElement {
28
+ @state() declare serverData: PostData | null;
29
+
30
+ render() {
31
+ return html`
32
+ <article>
33
+ <h1>${this.serverData?.title ?? 'Loading…'}</h1>
34
+ <p>${this.serverData?.content ?? ''}</p>
35
+ <litro-link href="/blog">← Back to Blog</litro-link>
36
+ &nbsp;|&nbsp;
37
+ <litro-link href="/">← Home</litro-link>
38
+ </article>
39
+ `;
40
+ }
41
+ }
42
+
43
+ export default BlogPostPage;
@@ -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;
@@ -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
+ });