create-lacspace-app 1.4.0 → 1.5.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 +260 -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 template renders Markdown posts with @lacspace/markdown.
47
+ ...ctx.template.key === "blog" ? { "@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" ? 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,8 @@ 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") },` : ""}
659
682
  { id: "contact", label: "Contact", group: "Navigate", onSelect: () => router.push("/contact") },
660
683
  { id: "packages", label: "Lacspace packages", group: "Links", onSelect: () => window.open("https://lacspace.com/packages", "_blank") },
661
684
  ]}
@@ -796,8 +819,233 @@ jobs:
796
819
  - name: Audit every page (fails below grade A)
797
820
  run: npx @lacspace/seo crawl http://localhost:3000 --min-grade A
798
821
  `;
799
- function buildFiles(ctx) {
822
+ var postsLib = () => `import fs from "node:fs";
823
+ import path from "node:path";
824
+ import { markdownToHtml, extractHeadings, type Heading } from "@lacspace/markdown";
825
+
826
+ const DIR = path.join(process.cwd(), "content/posts");
827
+
828
+ export interface PostMeta {
829
+ slug: string;
830
+ title: string;
831
+ date: string;
832
+ excerpt: string;
833
+ tag?: string;
834
+ author?: string;
835
+ }
836
+ export interface Post extends PostMeta {
837
+ html: string;
838
+ toc: Heading[];
839
+ }
840
+
841
+ // Tiny front-matter parser (no gray-matter needed).
842
+ function parse(raw: string): { data: Record<string, string>; body: string } {
843
+ const m = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?([\\s\\S]*)$/.exec(raw);
844
+ if (!m) return { data: {}, body: raw };
845
+ const data: Record<string, string> = {};
846
+ for (const line of m[1].split(/\\r?\\n/)) {
847
+ const i = line.indexOf(":");
848
+ if (i === -1) continue;
849
+ data[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
850
+ }
851
+ return { data, body: m[2] };
852
+ }
853
+
854
+ export function getAllPosts(): PostMeta[] {
855
+ if (!fs.existsSync(DIR)) return [];
856
+ return fs
857
+ .readdirSync(DIR)
858
+ .filter((f) => f.endsWith(".md"))
859
+ .map((f) => {
860
+ const { data } = parse(fs.readFileSync(path.join(DIR, f), "utf8"));
861
+ return {
862
+ slug: f.replace(/\\.md$/, ""),
863
+ title: data.title ?? f,
864
+ date: data.date ?? "",
865
+ excerpt: data.excerpt ?? "",
866
+ tag: data.tag,
867
+ author: data.author,
868
+ };
869
+ })
870
+ .sort((a, b) => (a.date < b.date ? 1 : -1));
871
+ }
872
+
873
+ export function getPost(slug: string): Post | null {
874
+ const file = path.join(DIR, \`\${slug}.md\`);
875
+ if (!fs.existsSync(file)) return null;
876
+ const { data, body } = parse(fs.readFileSync(file, "utf8"));
800
877
  return {
878
+ slug,
879
+ title: data.title ?? slug,
880
+ date: data.date ?? "",
881
+ excerpt: data.excerpt ?? "",
882
+ tag: data.tag,
883
+ author: data.author,
884
+ html: markdownToHtml(body, { headingOffset: 1 }),
885
+ toc: extractHeadings(body),
886
+ };
887
+ }
888
+ `;
889
+ var blogListPage = (ctx) => `import Link from "next/link";
890
+ import { site } from "@/lib/site";
891
+ import { getAllPosts } from "@/lib/posts";
892
+
893
+ export const metadata = site.meta({
894
+ title: "Blog",
895
+ path: "/blog",
896
+ description: "Writing, notes and updates from ${ctx.template.siteName}.",
897
+ });
898
+
899
+ export default function Blog() {
900
+ const posts = getAllPosts();
901
+ return (
902
+ <main className="mx-auto max-w-3xl px-6 py-24">
903
+ <h1 className="text-4xl font-black gradient-text sm:text-5xl">Blog</h1>
904
+ <p className="mt-4 text-white/60">Thoughts, notes and updates.</p>
905
+ <div className="mt-12 flex flex-col gap-8">
906
+ {posts.map((post) => (
907
+ <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">
908
+ {post.tag && <span className="text-xs font-semibold uppercase tracking-widest text-white/40">{post.tag}</span>}
909
+ <h2 className="mt-1 text-2xl font-bold group-hover:gradient-text">{post.title}</h2>
910
+ <p className="mt-2 text-white/60">{post.excerpt}</p>
911
+ {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>}
912
+ </Link>
913
+ ))}
914
+ {posts.length === 0 && <p className="text-white/50">No posts yet \u2014 add a Markdown file in <code>content/posts/</code>.</p>}
915
+ </div>
916
+ </main>
917
+ );
918
+ }
919
+ `;
920
+ var blogPostPage = () => `import Link from "next/link";
921
+ import { notFound } from "next/navigation";
922
+ import { site } from "@/lib/site";
923
+ import { getAllPosts, getPost } from "@/lib/posts";
924
+
925
+ // \u2728 Every post is statically generated at build time.
926
+ export function generateStaticParams() {
927
+ return getAllPosts().map((p) => ({ slug: p.slug }));
928
+ }
929
+
930
+ // \u2728 Per-post SEO \u2014 title, canonical, OG image and Article JSON-LD, from one call.
931
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
932
+ const { slug } = await params;
933
+ const post = getPost(slug);
934
+ if (!post) return {};
935
+ return site.article({
936
+ title: post.title,
937
+ path: \`/blog/\${slug}\`,
938
+ description: post.excerpt,
939
+ datePublished: post.date,
940
+ author: post.author,
941
+ tags: post.tag ? [post.tag] : undefined,
942
+ }).metadata;
943
+ }
944
+
945
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
946
+ const { slug } = await params;
947
+ const post = getPost(slug);
948
+ if (!post) notFound();
949
+
950
+ const jsonLd = site.article({
951
+ title: post.title,
952
+ path: \`/blog/\${slug}\`,
953
+ description: post.excerpt,
954
+ datePublished: post.date,
955
+ author: post.author,
956
+ tags: post.tag ? [post.tag] : undefined,
957
+ }).jsonLd;
958
+
959
+ return (
960
+ <main className="mx-auto max-w-2xl px-6 py-24">
961
+ <Link href="/blog" className="text-sm text-white/50 hover:text-white">&larr; All posts</Link>
962
+ <article className="mt-6">
963
+ <h1 className="text-4xl font-black leading-tight sm:text-5xl">{post.title}</h1>
964
+ {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>}
965
+ <div className="prose mt-10" dangerouslySetInnerHTML={{ __html: post.html }} />
966
+ </article>
967
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
968
+ </main>
969
+ );
970
+ }
971
+ `;
972
+ var blogSitemapTs = () => `import { sitemapForSite } from "@lacspace/sitemap";
973
+ import { site } from "@/lib/site";
974
+ import { getAllPosts } from "@/lib/posts";
975
+
976
+ export function GET() {
977
+ const paths = ["/", "/about", "/contact", "/blog", ...getAllPosts().map((p) => \`/blog/\${p.slug}\`)];
978
+ const xml = sitemapForSite(site.config, paths);
979
+ return new Response(xml, { headers: { "content-type": "application/xml" } });
980
+ }
981
+ `;
982
+ var samplePostWelcome = (ctx) => `---
983
+ title: Welcome to your new blog
984
+ date: 2026-01-15
985
+ excerpt: How this Markdown-powered blog works \u2014 and how to add your own posts.
986
+ tag: Guide
987
+ author: ${ctx.template.siteName}
988
+ ---
989
+
990
+ # You're up and running
991
+
992
+ This blog reads **Markdown files** from \`content/posts/\` and renders them to
993
+ static pages with [\`@lacspace/markdown\`](https://www.npmjs.com/package/@lacspace/markdown).
994
+ No CMS, no database \u2014 just files you can version in git.
995
+
996
+ ## Add a post
997
+
998
+ 1. Create \`content/posts/my-post.md\`.
999
+ 2. Add front-matter at the top (title, date, excerpt, tag).
1000
+ 3. Write Markdown. That's it \u2014 the post appears at \`/blog/my-post\`.
1001
+
1002
+ ## What Markdown supports
1003
+
1004
+ - **Bold**, *italic*, ~~strikethrough~~ and \`inline code\`
1005
+ - Lists, including
1006
+ - nested items
1007
+ - [x] task lists
1008
+ - Links, images and autolinks
1009
+ - Tables:
1010
+
1011
+ | Feature | Works |
1012
+ | ------- | :---: |
1013
+ | Headings + anchors | \u2705 |
1014
+ | Code blocks | \u2705 |
1015
+
1016
+ \`\`\`ts
1017
+ // even fenced code, with a language class for highlighting
1018
+ export function hello(name: string) {
1019
+ return \`Hello, \${name}!\`;
1020
+ }
1021
+ \`\`\`
1022
+
1023
+ > Every post is statically generated and gets its own SEO metadata and
1024
+ > Article JSON-LD automatically. Happy writing!
1025
+ `;
1026
+ var samplePostSecond = (ctx) => `---
1027
+ title: Why we build in the open
1028
+ date: 2026-02-02
1029
+ excerpt: A short second post so you can see the list and navigation in action.
1030
+ tag: Notes
1031
+ author: ${ctx.template.siteName}
1032
+ ---
1033
+
1034
+ # Building in the open
1035
+
1036
+ This is a second sample post. Delete it whenever you like.
1037
+
1038
+ Because posts are just Markdown files, you can:
1039
+
1040
+ 1. Draft in any editor
1041
+ 2. Preview locally with \`npm run dev\`
1042
+ 3. Commit and deploy
1043
+
1044
+ Check the [first post](/blog/welcome) for the full Markdown reference.
1045
+ `;
1046
+ function buildFiles(ctx) {
1047
+ const isBlog = ctx.template.key === "blog";
1048
+ const files = {
801
1049
  "package.json": pkgJson(ctx),
802
1050
  "tsconfig.json": tsconfig(),
803
1051
  "next.config.mjs": nextConfig(),
@@ -815,7 +1063,7 @@ function buildFiles(ctx) {
815
1063
  "app/apple-icon.tsx": appleIconTsx(ctx),
816
1064
  "app/manifest.ts": manifestTs(ctx),
817
1065
  "app/robots.txt/route.ts": robotsTs(),
818
- "app/sitemap.xml/route.ts": sitemapTs(),
1066
+ "app/sitemap.xml/route.ts": isBlog ? blogSitemapTs() : sitemapTs(),
819
1067
  "app/contact/page.tsx": contactPage(ctx),
820
1068
  "app/actions.ts": actionsTs(),
821
1069
  "components/command-menu.tsx": commandMenu(ctx),
@@ -824,6 +1072,14 @@ function buildFiles(ctx) {
824
1072
  ".env.example": envExample(),
825
1073
  "WELCOME.md": welcomeMd(ctx)
826
1074
  };
1075
+ if (isBlog) {
1076
+ files["lib/posts.ts"] = postsLib();
1077
+ files["app/blog/page.tsx"] = blogListPage(ctx);
1078
+ files["app/blog/[slug]/page.tsx"] = blogPostPage();
1079
+ files["content/posts/welcome.md"] = samplePostWelcome(ctx);
1080
+ files["content/posts/building-in-the-open.md"] = samplePostSecond(ctx);
1081
+ }
1082
+ return files;
827
1083
  }
828
1084
  function parseArgs(list) {
829
1085
  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.5.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": {