@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,39 @@
1
+ ---
2
+ title: Hello, World!
3
+ date: 2026-01-15
4
+ description: Welcome to your new Litro blog — here's what you need to know to start writing.
5
+ tags:
6
+ - posts
7
+ - welcome
8
+ ---
9
+
10
+ ## Welcome to Your Litro Blog
11
+
12
+ You've just scaffolded a brand-new blog powered by Litro — a fullstack web framework that combines Lit web components with Nitro's battle-tested server engine. This post is here to help you get your bearings and understand how everything fits together.
13
+
14
+ Content lives in the `content/blog/` directory as plain Markdown files. Each file becomes a post. The frontmatter at the top of every file (the `---` fenced block) controls the post's title, date, description, and tags. The rest of the file is your post body, written in standard Markdown — headings, paragraphs, lists, links, code blocks, all of it.
15
+
16
+ ## Writing Your First Post
17
+
18
+ Create a new `.md` file inside `content/blog/` and fill in the frontmatter:
19
+
20
+ ```markdown
21
+ ---
22
+ title: My Second Post
23
+ date: 2026-02-01
24
+ description: A short summary shown on the blog listing page.
25
+ tags:
26
+ - posts
27
+ - my-tag
28
+ ---
29
+
30
+ Your post content goes here.
31
+ ```
32
+
33
+ The `tags` field accepts a list. Every post in `content/blog/` automatically inherits the `posts` tag from `blog.11tydata.json`, so you only need to list additional tags in the post's own frontmatter. Tag pages are generated automatically at `/tags/<tag>` — no extra configuration required.
34
+
35
+ ## How Content Is Served
36
+
37
+ Litro reads your Markdown files at build time (or on-demand in dev mode) using its content layer. The `getPosts()` function returns all published posts sorted newest-first, with each post's Markdown rendered to HTML in the `body` field. The `getPost(slug)` function fetches a single post by its URL slug, derived from the filename. For example, this file — `hello-world.md` — is available at `/blog/hello-world`.
38
+
39
+ Run `litro dev` to start the development server and visit `http://localhost:3030` to see your blog live. Changes to content files are picked up immediately without restarting the server.
@@ -0,0 +1,7 @@
1
+ {
2
+ "recipe": "11ty-blog",
3
+ "version": "{{recipeVersion}}",
4
+ "mode": "{{mode}}",
5
+ "contentDir": "content/blog",
6
+ "options": {}
7
+ }
@@ -0,0 +1,60 @@
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
+ import contentPlugin from '@beatzball/litro/content/plugin';
8
+
9
+ const mode = process.env.LITRO_MODE ?? 'server';
10
+
11
+ export default defineNitroConfig({
12
+ ...(mode === 'static' ? ssgPreset() : ssrPreset()),
13
+
14
+ srcDir: 'server',
15
+
16
+ publicAssets: [
17
+ { dir: '../dist/client', baseURL: '/_litro/', maxAge: 31536000 },
18
+ { dir: '../public', baseURL: '/', maxAge: 0 },
19
+ // Serve co-located content images at their natural paths
20
+ { dir: '../content', baseURL: '/content/', maxAge: 86400 },
21
+ ],
22
+
23
+ externals: { inline: ['@lit-labs/ssr', '@lit-labs/ssr-client'] },
24
+
25
+ esbuild: {
26
+ options: {
27
+ tsconfigRaw: {
28
+ compilerOptions: {
29
+ experimentalDecorators: true,
30
+ useDefineForClassFields: false,
31
+ },
32
+ },
33
+ },
34
+ },
35
+
36
+ ignore: ['**/middleware/vite-dev.ts'],
37
+ handlers: [
38
+ {
39
+ middleware: true,
40
+ handler: resolve('./server/middleware/vite-dev.ts'),
41
+ env: 'dev',
42
+ },
43
+ ],
44
+
45
+ hooks: {
46
+ 'build:before': async (nitro: Nitro) => {
47
+ await contentPlugin(nitro);
48
+ await pagesPlugin(nitro);
49
+ await ssgPlugin(nitro);
50
+ },
51
+ },
52
+
53
+ compatibilityDate: '2025-01-01',
54
+
55
+ routeRules: {
56
+ '/_litro/**': {
57
+ headers: { 'cache-control': 'public, max-age=31536000, immutable' },
58
+ },
59
+ },
60
+ });
@@ -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,65 @@
1
+ import { html } from 'lit';
2
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
3
+ import { customElement, state } from 'lit/decorators.js';
4
+ import { LitroPage } from '@beatzball/litro/runtime';
5
+ import { definePageData } from '@beatzball/litro';
6
+ import { createError } from 'h3';
7
+ import type { Post } from 'litro:content';
8
+ import { getPost, getPosts } from 'litro:content';
9
+
10
+ export interface PostData {
11
+ post: Post;
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 slug = event.context.params?.slug ?? '';
18
+ const post = await getPost(slug);
19
+ if (!post) {
20
+ throw createError({ statusCode: 404, message: `Post not found: ${slug}` });
21
+ }
22
+ return { post } satisfies PostData;
23
+ });
24
+
25
+ export async function generateRoutes(): Promise<string[]> {
26
+ const posts = await getPosts();
27
+ return posts.map(post => post.url);
28
+ }
29
+
30
+ @customElement('page-blog-slug')
31
+ export class BlogPostPage extends LitroPage {
32
+ @state() declare serverData: PostData | null;
33
+
34
+ render() {
35
+ const { post } = this.serverData ?? {};
36
+ if (!post) return html`<p>Loading…</p>`;
37
+ return html`
38
+ <article>
39
+ <header>
40
+ <h1>${post.title}</h1>
41
+ <time datetime="${toDate(post.date).toISOString()}">${toDate(post.date).toLocaleDateString()}</time>
42
+ ${post.tags.length > 0 ? html`
43
+ <ul class="tags">
44
+ ${post.tags.map(tag => html`
45
+ <li><a href="/tags/${tag}">#${tag}</a></li>
46
+ `)}
47
+ </ul>
48
+ ` : ''}
49
+ </header>
50
+ <div class="post-body">
51
+ <!-- unsafeHTML renders the Markdown-generated HTML directly.
52
+ The content directory is trusted-author-only; do not place
53
+ user-submitted or untrusted content here without first
54
+ sanitizing with a library such as rehype-sanitize. -->
55
+ ${unsafeHTML(post.body)}
56
+ </div>
57
+ <footer>
58
+ <a href="/blog">← Back to Blog</a>
59
+ </footer>
60
+ </article>
61
+ `;
62
+ }
63
+ }
64
+
65
+ export default BlogPostPage;
@@ -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;
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,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,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;