create-lacspace-app 1.4.0 → 1.6.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 (2) hide show
  1. package/dist/index.js +578 -4
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -42,7 +42,9 @@ var pkgJson = (ctx) => JSON.stringify({
42
42
  "@lacspace/og": "^1.0.0",
43
43
  "@lacspace/ui": "^1.0.0",
44
44
  "@lacspace/form": "^1.0.0",
45
- "@lacspace/validate": "^1.0.0"
45
+ "@lacspace/validate": "^1.0.0",
46
+ // The blog & docs templates render Markdown with @lacspace/markdown.
47
+ ...ctx.template.key === "blog" || ctx.template.key === "docs" ? { "@lacspace/markdown": "^1.0.0" } : {}
46
48
  },
47
49
  devDependencies: {
48
50
  typescript: "^5.7.0",
@@ -132,6 +134,26 @@ body {
132
134
  @keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
133
135
  main > section, main > * { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
134
136
  @media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation: none !important; scroll-behavior: auto; } }
137
+ ${ctx.template.key === "blog" || ctx.template.key === "docs" ? BLOG_PROSE_CSS : ""}`;
138
+ var BLOG_PROSE_CSS = `
139
+ /* article typography for Markdown posts */
140
+ .prose { line-height: 1.75; color: rgb(229 231 235 / 0.9); }
141
+ .prose > * + * { margin-top: 1.25em; }
142
+ .prose h1, .prose h2, .prose h3 { font-weight: 700; line-height: 1.25; margin-top: 2em; color: #fff; }
143
+ .prose h2 { font-size: 1.6rem; } .prose h3 { font-size: 1.3rem; }
144
+ .prose a { color: var(--accent-to); text-decoration: underline; text-underline-offset: 3px; }
145
+ .prose strong { color: #fff; }
146
+ .prose ul, .prose ol { padding-left: 1.4em; }
147
+ .prose ul { list-style: disc; } .prose ol { list-style: decimal; }
148
+ .prose li { margin-top: 0.4em; }
149
+ .prose blockquote { border-left: 3px solid var(--accent-to); padding-left: 1em; color: rgb(229 231 235 / 0.7); font-style: italic; }
150
+ .prose code { background: rgb(255 255 255 / 0.08); padding: 0.15em 0.4em; border-radius: 6px; font-size: 0.9em; }
151
+ .prose pre { background: #0f0f16; border: 1px solid rgb(255 255 255 / 0.1); border-radius: 12px; padding: 1.1em; overflow-x: auto; }
152
+ .prose pre code { background: none; padding: 0; }
153
+ .prose img { border-radius: 12px; max-width: 100%; height: auto; }
154
+ .prose table { width: 100%; border-collapse: collapse; }
155
+ .prose th, .prose td { border: 1px solid rgb(255 255 255 / 0.12); padding: 0.5em 0.75em; text-align: left; }
156
+ .prose hr { border: none; border-top: 1px solid rgb(255 255 255 / 0.12); }
135
157
  `;
136
158
  var siteTs = (ctx) => `import { defineSite } from "@lacspace/seo";
137
159
 
@@ -655,7 +677,9 @@ export function CommandMenu() {
655
677
  accent="${ctx.template.accent[1]}"
656
678
  items={[
657
679
  { id: "home", label: "Home", group: "Navigate", shortcut: "G H", onSelect: () => router.push("/") },
658
- { id: "about", label: "About", group: "Navigate", onSelect: () => router.push("/about") },
680
+ { id: "about", label: "About", group: "Navigate", onSelect: () => router.push("/about") },${ctx.template.key === "blog" ? `
681
+ { id: "blog", label: "Blog", group: "Navigate", onSelect: () => router.push("/blog") },` : ""}${ctx.template.key === "docs" ? `
682
+ { id: "docs", label: "Docs", group: "Navigate", onSelect: () => router.push("/docs") },` : ""}
659
683
  { id: "contact", label: "Contact", group: "Navigate", onSelect: () => router.push("/contact") },
660
684
  { id: "packages", label: "Lacspace packages", group: "Links", onSelect: () => window.open("https://lacspace.com/packages", "_blank") },
661
685
  ]}
@@ -770,6 +794,312 @@ export default function ContactPage() {
770
794
  );
771
795
  }
772
796
  `;
797
+ var docsLib = () => `import fs from "node:fs";
798
+ import path from "node:path";
799
+ import { markdownToHtml, extractHeadings, type Heading } from "@lacspace/markdown";
800
+
801
+ const DIR = path.join(process.cwd(), "content/docs");
802
+
803
+ export interface DocMeta {
804
+ slug: string;
805
+ title: string;
806
+ group: string;
807
+ order: number;
808
+ description: string;
809
+ }
810
+ export interface Doc extends DocMeta {
811
+ html: string;
812
+ toc: Heading[];
813
+ }
814
+
815
+ function parse(raw: string): { data: Record<string, string>; body: string } {
816
+ const m = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?([\\s\\S]*)$/.exec(raw);
817
+ if (!m) return { data: {}, body: raw };
818
+ const data: Record<string, string> = {};
819
+ for (const line of m[1].split(/\\r?\\n/)) {
820
+ const i = line.indexOf(":");
821
+ if (i === -1) continue;
822
+ data[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
823
+ }
824
+ return { data, body: m[2] };
825
+ }
826
+
827
+ export function getAllDocs(): DocMeta[] {
828
+ if (!fs.existsSync(DIR)) return [];
829
+ return fs
830
+ .readdirSync(DIR)
831
+ .filter((f) => f.endsWith(".md"))
832
+ .map((f) => {
833
+ const { data } = parse(fs.readFileSync(path.join(DIR, f), "utf8"));
834
+ return {
835
+ slug: f.replace(/\\.md$/, ""),
836
+ title: data.title ?? f,
837
+ group: data.group ?? "Docs",
838
+ order: Number(data.order ?? "99"),
839
+ description: data.description ?? "",
840
+ };
841
+ })
842
+ .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
843
+ }
844
+
845
+ export function getDoc(slug: string): Doc | null {
846
+ const file = path.join(DIR, \`\${slug}.md\`);
847
+ if (!fs.existsSync(file)) return null;
848
+ const { data, body } = parse(fs.readFileSync(file, "utf8"));
849
+ return {
850
+ slug,
851
+ title: data.title ?? slug,
852
+ group: data.group ?? "Docs",
853
+ order: Number(data.order ?? "99"),
854
+ description: data.description ?? "",
855
+ html: markdownToHtml(body, { headingOffset: 1 }),
856
+ toc: extractHeadings(body),
857
+ };
858
+ }
859
+
860
+ export function getDocNav(): { group: string; items: DocMeta[] }[] {
861
+ const groups: { group: string; items: DocMeta[] }[] = [];
862
+ for (const doc of getAllDocs()) {
863
+ let g = groups.find((x) => x.group === doc.group);
864
+ if (!g) { g = { group: doc.group, items: [] }; groups.push(g); }
865
+ g.items.push(doc);
866
+ }
867
+ return groups;
868
+ }
869
+
870
+ export function adjacentDocs(slug: string): { prev: DocMeta | null; next: DocMeta | null } {
871
+ const all = getAllDocs();
872
+ const i = all.findIndex((d) => d.slug === slug);
873
+ return { prev: i > 0 ? all[i - 1]! : null, next: i >= 0 && i < all.length - 1 ? all[i + 1]! : null };
874
+ }
875
+ `;
876
+ var docsSidebar = () => `"use client";
877
+ import Link from "next/link";
878
+ import { usePathname } from "next/navigation";
879
+
880
+ interface DocMeta { slug: string; title: string; group: string; order: number; description: string; }
881
+
882
+ export function DocsSidebar({ nav }: { nav: { group: string; items: DocMeta[] }[] }) {
883
+ const path = usePathname();
884
+ return (
885
+ <nav className="flex flex-col gap-6 text-sm">
886
+ {nav.map((group) => (
887
+ <div key={group.group}>
888
+ <p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/40">{group.group}</p>
889
+ <ul className="flex flex-col gap-1">
890
+ {group.items.map((d) => {
891
+ const href = \`/docs/\${d.slug}\`;
892
+ const active = path === href;
893
+ return (
894
+ <li key={d.slug}>
895
+ <Link
896
+ href={href}
897
+ className={\`block rounded-lg px-3 py-1.5 transition \${active ? "gradient-bg font-semibold text-black" : "text-white/70 hover:bg-white/5 hover:text-white"}\`}
898
+ >
899
+ {d.title}
900
+ </Link>
901
+ </li>
902
+ );
903
+ })}
904
+ </ul>
905
+ </div>
906
+ ))}
907
+ </nav>
908
+ );
909
+ }
910
+ `;
911
+ var docsLayout = () => `import Link from "next/link";
912
+ import { getDocNav } from "@/lib/docs";
913
+ import { DocsSidebar } from "@/components/docs-sidebar";
914
+
915
+ export default function DocsLayout({ children }: { children: React.ReactNode }) {
916
+ const nav = getDocNav();
917
+ return (
918
+ <div className="mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[220px_1fr]">
919
+ <aside className="md:sticky md:top-24 md:h-fit">
920
+ <Link href="/docs" className="mb-6 block text-lg font-black gradient-text">Docs</Link>
921
+ <DocsSidebar nav={nav} />
922
+ </aside>
923
+ <div className="min-w-0">{children}</div>
924
+ </div>
925
+ );
926
+ }
927
+ `;
928
+ var docsIndexPage = (ctx) => `import Link from "next/link";
929
+ import { site } from "@/lib/site";
930
+ import { getAllDocs } from "@/lib/docs";
931
+
932
+ export const metadata = site.meta({
933
+ title: "Documentation",
934
+ path: "/docs",
935
+ description: "Documentation for ${ctx.template.siteName}.",
936
+ });
937
+
938
+ export default function DocsIndex() {
939
+ const docs = getAllDocs();
940
+ return (
941
+ <div>
942
+ <h1 className="text-4xl font-black gradient-text">Documentation</h1>
943
+ <p className="mt-4 text-white/60">Everything you need to get started and go deep.</p>
944
+ <div className="mt-10 grid gap-4 sm:grid-cols-2">
945
+ {docs.map((d) => (
946
+ <Link key={d.slug} href={\`/docs/\${d.slug}\`} className="rounded-2xl border border-white/10 bg-white/5 p-5 transition hover:border-white/25">
947
+ <span className="text-xs font-semibold uppercase tracking-widest text-white/40">{d.group}</span>
948
+ <h2 className="mt-1 text-lg font-bold">{d.title}</h2>
949
+ {d.description && <p className="mt-1 text-sm text-white/60">{d.description}</p>}
950
+ </Link>
951
+ ))}
952
+ </div>
953
+ </div>
954
+ );
955
+ }
956
+ `;
957
+ var docsPage = () => `import Link from "next/link";
958
+ import { notFound } from "next/navigation";
959
+ import { site } from "@/lib/site";
960
+ import { getAllDocs, getDoc, adjacentDocs } from "@/lib/docs";
961
+
962
+ export function generateStaticParams() {
963
+ return getAllDocs().map((d) => ({ slug: d.slug }));
964
+ }
965
+
966
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
967
+ const { slug } = await params;
968
+ const doc = getDoc(slug);
969
+ if (!doc) return {};
970
+ return site.meta({ title: doc.title, path: \`/docs/\${slug}\`, description: doc.description });
971
+ }
972
+
973
+ export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
974
+ const { slug } = await params;
975
+ const doc = getDoc(slug);
976
+ if (!doc) notFound();
977
+ const { prev, next } = adjacentDocs(slug);
978
+
979
+ return (
980
+ <article>
981
+ <span className="text-xs font-semibold uppercase tracking-widest text-white/40">{doc.group}</span>
982
+ <h1 className="mt-1 text-4xl font-black leading-tight">{doc.title}</h1>
983
+ {doc.description && <p className="mt-3 text-lg text-white/60">{doc.description}</p>}
984
+
985
+ {doc.toc.length > 2 && (
986
+ <div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
987
+ <p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/40">On this page</p>
988
+ <ul className="flex flex-col gap-1 text-sm">
989
+ {doc.toc.map((h) => (
990
+ <li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}>
991
+ <a href={\`#\${h.id}\`} className="text-white/60 hover:text-white">{h.text}</a>
992
+ </li>
993
+ ))}
994
+ </ul>
995
+ </div>
996
+ )}
997
+
998
+ <div className="prose mt-10" dangerouslySetInnerHTML={{ __html: doc.html }} />
999
+
1000
+ <nav className="mt-14 flex justify-between gap-4 border-t border-white/10 pt-8 text-sm">
1001
+ {prev ? <Link href={\`/docs/\${prev.slug}\`} className="text-white/70 hover:text-white">&larr; {prev.title}</Link> : <span />}
1002
+ {next ? <Link href={\`/docs/\${next.slug}\`} className="text-right text-white/70 hover:text-white">{next.title} &rarr;</Link> : <span />}
1003
+ </nav>
1004
+ </article>
1005
+ );
1006
+ }
1007
+ `;
1008
+ var docsSitemapTs = () => `import { sitemapForSite } from "@lacspace/sitemap";
1009
+ import { site } from "@/lib/site";
1010
+ import { getAllDocs } from "@/lib/docs";
1011
+
1012
+ export function GET() {
1013
+ const paths = ["/", "/about", "/contact", "/docs", ...getAllDocs().map((d) => \`/docs/\${d.slug}\`)];
1014
+ const xml = sitemapForSite(site.config, paths);
1015
+ return new Response(xml, { headers: { "content-type": "application/xml" } });
1016
+ }
1017
+ `;
1018
+ var sampleDocIntro = (ctx) => `---
1019
+ title: Introduction
1020
+ group: Getting Started
1021
+ order: 1
1022
+ description: What ${ctx.template.siteName} is and how these docs work.
1023
+ ---
1024
+
1025
+ # Introduction
1026
+
1027
+ Welcome to the **${ctx.template.siteName}** documentation. These pages are plain
1028
+ **Markdown files** in \`content/docs/\`, rendered to static pages with
1029
+ [\`@lacspace/markdown\`](https://www.npmjs.com/package/@lacspace/markdown).
1030
+
1031
+ ## How the docs are organised
1032
+
1033
+ - Each \`.md\` file becomes a page at \`/docs/<filename>\`.
1034
+ - Front-matter sets the **title**, **group** (sidebar heading) and **order**.
1035
+ - The sidebar, the on-this-page table of contents and prev/next links are all
1036
+ generated for you.
1037
+
1038
+ > Edit these files, add your own, and the navigation updates automatically.
1039
+ `;
1040
+ var sampleDocInstall = () => `---
1041
+ title: Installation
1042
+ group: Getting Started
1043
+ order: 2
1044
+ description: Add a new documentation page in under a minute.
1045
+ ---
1046
+
1047
+ # Installation & setup
1048
+
1049
+ Create a Markdown file in \`content/docs/\` with front-matter at the top:
1050
+
1051
+ \`\`\`md
1052
+ ---
1053
+ title: My page
1054
+ group: Guides
1055
+ order: 1
1056
+ description: A short summary for SEO and the index.
1057
+ ---
1058
+
1059
+ # My page
1060
+
1061
+ Write **Markdown** here \u2014 headings, lists, tables and code all work.
1062
+ \`\`\`
1063
+
1064
+ That's it \u2014 the page appears at \`/docs/my-page\` and in the sidebar under "Guides".
1065
+ `;
1066
+ var sampleDocWriting = () => `---
1067
+ title: Writing content
1068
+ group: Guides
1069
+ order: 1
1070
+ description: Everything the Markdown renderer supports.
1071
+ ---
1072
+
1073
+ # Writing content
1074
+
1075
+ The renderer supports the essentials \u2014 and a bit more.
1076
+
1077
+ ## Text & lists
1078
+
1079
+ **Bold**, *italic*, \`inline code\`, and:
1080
+
1081
+ - bullet lists
1082
+ - that nest
1083
+ - [x] task lists
1084
+
1085
+ ## Tables
1086
+
1087
+ | Feature | Supported |
1088
+ | ------- | :-------: |
1089
+ | Headings + anchors | \u2705 |
1090
+ | Code blocks | \u2705 |
1091
+ | Tables | \u2705 |
1092
+
1093
+ ## Code
1094
+
1095
+ \`\`\`ts
1096
+ export function greet(name: string) {
1097
+ return \`Hello, \${name}!\`;
1098
+ }
1099
+ \`\`\`
1100
+
1101
+ Every heading gets an anchor id, so the on-this-page menu links straight to it.
1102
+ `;
773
1103
  var seoWorkflow = () => `name: SEO
774
1104
 
775
1105
  on:
@@ -796,8 +1126,234 @@ jobs:
796
1126
  - name: Audit every page (fails below grade A)
797
1127
  run: npx @lacspace/seo crawl http://localhost:3000 --min-grade A
798
1128
  `;
799
- function buildFiles(ctx) {
1129
+ var postsLib = () => `import fs from "node:fs";
1130
+ import path from "node:path";
1131
+ import { markdownToHtml, extractHeadings, type Heading } from "@lacspace/markdown";
1132
+
1133
+ const DIR = path.join(process.cwd(), "content/posts");
1134
+
1135
+ export interface PostMeta {
1136
+ slug: string;
1137
+ title: string;
1138
+ date: string;
1139
+ excerpt: string;
1140
+ tag?: string;
1141
+ author?: string;
1142
+ }
1143
+ export interface Post extends PostMeta {
1144
+ html: string;
1145
+ toc: Heading[];
1146
+ }
1147
+
1148
+ // Tiny front-matter parser (no gray-matter needed).
1149
+ function parse(raw: string): { data: Record<string, string>; body: string } {
1150
+ const m = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?([\\s\\S]*)$/.exec(raw);
1151
+ if (!m) return { data: {}, body: raw };
1152
+ const data: Record<string, string> = {};
1153
+ for (const line of m[1].split(/\\r?\\n/)) {
1154
+ const i = line.indexOf(":");
1155
+ if (i === -1) continue;
1156
+ data[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
1157
+ }
1158
+ return { data, body: m[2] };
1159
+ }
1160
+
1161
+ export function getAllPosts(): PostMeta[] {
1162
+ if (!fs.existsSync(DIR)) return [];
1163
+ return fs
1164
+ .readdirSync(DIR)
1165
+ .filter((f) => f.endsWith(".md"))
1166
+ .map((f) => {
1167
+ const { data } = parse(fs.readFileSync(path.join(DIR, f), "utf8"));
1168
+ return {
1169
+ slug: f.replace(/\\.md$/, ""),
1170
+ title: data.title ?? f,
1171
+ date: data.date ?? "",
1172
+ excerpt: data.excerpt ?? "",
1173
+ tag: data.tag,
1174
+ author: data.author,
1175
+ };
1176
+ })
1177
+ .sort((a, b) => (a.date < b.date ? 1 : -1));
1178
+ }
1179
+
1180
+ export function getPost(slug: string): Post | null {
1181
+ const file = path.join(DIR, \`\${slug}.md\`);
1182
+ if (!fs.existsSync(file)) return null;
1183
+ const { data, body } = parse(fs.readFileSync(file, "utf8"));
800
1184
  return {
1185
+ slug,
1186
+ title: data.title ?? slug,
1187
+ date: data.date ?? "",
1188
+ excerpt: data.excerpt ?? "",
1189
+ tag: data.tag,
1190
+ author: data.author,
1191
+ html: markdownToHtml(body, { headingOffset: 1 }),
1192
+ toc: extractHeadings(body),
1193
+ };
1194
+ }
1195
+ `;
1196
+ var blogListPage = (ctx) => `import Link from "next/link";
1197
+ import { site } from "@/lib/site";
1198
+ import { getAllPosts } from "@/lib/posts";
1199
+
1200
+ export const metadata = site.meta({
1201
+ title: "Blog",
1202
+ path: "/blog",
1203
+ description: "Writing, notes and updates from ${ctx.template.siteName}.",
1204
+ });
1205
+
1206
+ export default function Blog() {
1207
+ const posts = getAllPosts();
1208
+ return (
1209
+ <main className="mx-auto max-w-3xl px-6 py-24">
1210
+ <h1 className="text-4xl font-black gradient-text sm:text-5xl">Blog</h1>
1211
+ <p className="mt-4 text-white/60">Thoughts, notes and updates.</p>
1212
+ <div className="mt-12 flex flex-col gap-8">
1213
+ {posts.map((post) => (
1214
+ <Link key={post.slug} href={\`/blog/\${post.slug}\`} className="group rounded-2xl border border-white/10 bg-white/5 p-6 transition hover:border-white/25">
1215
+ {post.tag && <span className="text-xs font-semibold uppercase tracking-widest text-white/40">{post.tag}</span>}
1216
+ <h2 className="mt-1 text-2xl font-bold group-hover:gradient-text">{post.title}</h2>
1217
+ <p className="mt-2 text-white/60">{post.excerpt}</p>
1218
+ {post.date && <time className="mt-3 block text-sm text-white/40">{new Date(post.date).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}</time>}
1219
+ </Link>
1220
+ ))}
1221
+ {posts.length === 0 && <p className="text-white/50">No posts yet \u2014 add a Markdown file in <code>content/posts/</code>.</p>}
1222
+ </div>
1223
+ </main>
1224
+ );
1225
+ }
1226
+ `;
1227
+ var blogPostPage = () => `import Link from "next/link";
1228
+ import { notFound } from "next/navigation";
1229
+ import { site } from "@/lib/site";
1230
+ import { getAllPosts, getPost } from "@/lib/posts";
1231
+
1232
+ // \u2728 Every post is statically generated at build time.
1233
+ export function generateStaticParams() {
1234
+ return getAllPosts().map((p) => ({ slug: p.slug }));
1235
+ }
1236
+
1237
+ // \u2728 Per-post SEO \u2014 title, canonical, OG image and Article JSON-LD, from one call.
1238
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
1239
+ const { slug } = await params;
1240
+ const post = getPost(slug);
1241
+ if (!post) return {};
1242
+ return site.article({
1243
+ title: post.title,
1244
+ path: \`/blog/\${slug}\`,
1245
+ description: post.excerpt,
1246
+ datePublished: post.date,
1247
+ author: post.author,
1248
+ tags: post.tag ? [post.tag] : undefined,
1249
+ }).metadata;
1250
+ }
1251
+
1252
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
1253
+ const { slug } = await params;
1254
+ const post = getPost(slug);
1255
+ if (!post) notFound();
1256
+
1257
+ const jsonLd = site.article({
1258
+ title: post.title,
1259
+ path: \`/blog/\${slug}\`,
1260
+ description: post.excerpt,
1261
+ datePublished: post.date,
1262
+ author: post.author,
1263
+ tags: post.tag ? [post.tag] : undefined,
1264
+ }).jsonLd;
1265
+
1266
+ return (
1267
+ <main className="mx-auto max-w-2xl px-6 py-24">
1268
+ <Link href="/blog" className="text-sm text-white/50 hover:text-white">&larr; All posts</Link>
1269
+ <article className="mt-6">
1270
+ <h1 className="text-4xl font-black leading-tight sm:text-5xl">{post.title}</h1>
1271
+ {post.date && <time className="mt-4 block text-sm text-white/40">{new Date(post.date).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}</time>}
1272
+ <div className="prose mt-10" dangerouslySetInnerHTML={{ __html: post.html }} />
1273
+ </article>
1274
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
1275
+ </main>
1276
+ );
1277
+ }
1278
+ `;
1279
+ var blogSitemapTs = () => `import { sitemapForSite } from "@lacspace/sitemap";
1280
+ import { site } from "@/lib/site";
1281
+ import { getAllPosts } from "@/lib/posts";
1282
+
1283
+ export function GET() {
1284
+ const paths = ["/", "/about", "/contact", "/blog", ...getAllPosts().map((p) => \`/blog/\${p.slug}\`)];
1285
+ const xml = sitemapForSite(site.config, paths);
1286
+ return new Response(xml, { headers: { "content-type": "application/xml" } });
1287
+ }
1288
+ `;
1289
+ var samplePostWelcome = (ctx) => `---
1290
+ title: Welcome to your new blog
1291
+ date: 2026-01-15
1292
+ excerpt: How this Markdown-powered blog works \u2014 and how to add your own posts.
1293
+ tag: Guide
1294
+ author: ${ctx.template.siteName}
1295
+ ---
1296
+
1297
+ # You're up and running
1298
+
1299
+ This blog reads **Markdown files** from \`content/posts/\` and renders them to
1300
+ static pages with [\`@lacspace/markdown\`](https://www.npmjs.com/package/@lacspace/markdown).
1301
+ No CMS, no database \u2014 just files you can version in git.
1302
+
1303
+ ## Add a post
1304
+
1305
+ 1. Create \`content/posts/my-post.md\`.
1306
+ 2. Add front-matter at the top (title, date, excerpt, tag).
1307
+ 3. Write Markdown. That's it \u2014 the post appears at \`/blog/my-post\`.
1308
+
1309
+ ## What Markdown supports
1310
+
1311
+ - **Bold**, *italic*, ~~strikethrough~~ and \`inline code\`
1312
+ - Lists, including
1313
+ - nested items
1314
+ - [x] task lists
1315
+ - Links, images and autolinks
1316
+ - Tables:
1317
+
1318
+ | Feature | Works |
1319
+ | ------- | :---: |
1320
+ | Headings + anchors | \u2705 |
1321
+ | Code blocks | \u2705 |
1322
+
1323
+ \`\`\`ts
1324
+ // even fenced code, with a language class for highlighting
1325
+ export function hello(name: string) {
1326
+ return \`Hello, \${name}!\`;
1327
+ }
1328
+ \`\`\`
1329
+
1330
+ > Every post is statically generated and gets its own SEO metadata and
1331
+ > Article JSON-LD automatically. Happy writing!
1332
+ `;
1333
+ var samplePostSecond = (ctx) => `---
1334
+ title: Why we build in the open
1335
+ date: 2026-02-02
1336
+ excerpt: A short second post so you can see the list and navigation in action.
1337
+ tag: Notes
1338
+ author: ${ctx.template.siteName}
1339
+ ---
1340
+
1341
+ # Building in the open
1342
+
1343
+ This is a second sample post. Delete it whenever you like.
1344
+
1345
+ Because posts are just Markdown files, you can:
1346
+
1347
+ 1. Draft in any editor
1348
+ 2. Preview locally with \`npm run dev\`
1349
+ 3. Commit and deploy
1350
+
1351
+ Check the [first post](/blog/welcome) for the full Markdown reference.
1352
+ `;
1353
+ function buildFiles(ctx) {
1354
+ const isBlog = ctx.template.key === "blog";
1355
+ const isDocs = ctx.template.key === "docs";
1356
+ const files = {
801
1357
  "package.json": pkgJson(ctx),
802
1358
  "tsconfig.json": tsconfig(),
803
1359
  "next.config.mjs": nextConfig(),
@@ -815,7 +1371,7 @@ function buildFiles(ctx) {
815
1371
  "app/apple-icon.tsx": appleIconTsx(ctx),
816
1372
  "app/manifest.ts": manifestTs(ctx),
817
1373
  "app/robots.txt/route.ts": robotsTs(),
818
- "app/sitemap.xml/route.ts": sitemapTs(),
1374
+ "app/sitemap.xml/route.ts": isBlog ? blogSitemapTs() : isDocs ? docsSitemapTs() : sitemapTs(),
819
1375
  "app/contact/page.tsx": contactPage(ctx),
820
1376
  "app/actions.ts": actionsTs(),
821
1377
  "components/command-menu.tsx": commandMenu(ctx),
@@ -824,6 +1380,24 @@ function buildFiles(ctx) {
824
1380
  ".env.example": envExample(),
825
1381
  "WELCOME.md": welcomeMd(ctx)
826
1382
  };
1383
+ if (isBlog) {
1384
+ files["lib/posts.ts"] = postsLib();
1385
+ files["app/blog/page.tsx"] = blogListPage(ctx);
1386
+ files["app/blog/[slug]/page.tsx"] = blogPostPage();
1387
+ files["content/posts/welcome.md"] = samplePostWelcome(ctx);
1388
+ files["content/posts/building-in-the-open.md"] = samplePostSecond(ctx);
1389
+ }
1390
+ if (isDocs) {
1391
+ files["lib/docs.ts"] = docsLib();
1392
+ files["components/docs-sidebar.tsx"] = docsSidebar();
1393
+ files["app/docs/layout.tsx"] = docsLayout();
1394
+ files["app/docs/page.tsx"] = docsIndexPage(ctx);
1395
+ files["app/docs/[slug]/page.tsx"] = docsPage();
1396
+ files["content/docs/introduction.md"] = sampleDocIntro(ctx);
1397
+ files["content/docs/installation.md"] = sampleDocInstall();
1398
+ files["content/docs/writing-content.md"] = sampleDocWriting();
1399
+ }
1400
+ return files;
827
1401
  }
828
1402
  function parseArgs(list) {
829
1403
  const a = { yes: false, install: true, git: true, pm: "npm", help: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-lacspace-app",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template — portfolio, business, e-commerce, SaaS, blog, docs, dashboard or restaurant — pre-wired with SEO, security headers, sitemap and robots. Like create-next-app, but you start gorgeous.",
5
5
  "type": "module",
6
6
  "bin": {