create-lacspace-app 1.3.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.
- package/dist/index.js +454 -24
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -35,10 +35,16 @@ var pkgJson = (ctx) => JSON.stringify({
|
|
|
35
35
|
next: "^15.1.0",
|
|
36
36
|
react: "^19.0.0",
|
|
37
37
|
"react-dom": "^19.0.0",
|
|
38
|
-
"@lacspace/seo": "^1.
|
|
38
|
+
"@lacspace/seo": "^1.6.0",
|
|
39
39
|
"@lacspace/headers": "^1.1.1",
|
|
40
40
|
"@lacspace/robots": "^1.2.0",
|
|
41
|
-
"@lacspace/sitemap": "^1.1.0"
|
|
41
|
+
"@lacspace/sitemap": "^1.1.0",
|
|
42
|
+
"@lacspace/og": "^1.0.0",
|
|
43
|
+
"@lacspace/ui": "^1.0.0",
|
|
44
|
+
"@lacspace/form": "^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" } : {}
|
|
42
48
|
},
|
|
43
49
|
devDependencies: {
|
|
44
50
|
typescript: "^5.7.0",
|
|
@@ -128,6 +134,26 @@ body {
|
|
|
128
134
|
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
|
129
135
|
main > section, main > * { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
|
130
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); }
|
|
131
157
|
`;
|
|
132
158
|
var siteTs = (ctx) => `import { defineSite } from "@lacspace/seo";
|
|
133
159
|
|
|
@@ -143,6 +169,7 @@ export const site = defineSite({
|
|
|
143
169
|
var layout = (ctx) => `import type { Metadata } from "next";
|
|
144
170
|
import { Inter } from "next/font/google";
|
|
145
171
|
import { site } from "@/lib/site";
|
|
172
|
+
import { CommandMenu } from "@/components/command-menu";
|
|
146
173
|
import "./globals.css";
|
|
147
174
|
|
|
148
175
|
const inter = Inter({ subsets: ["latin"], display: "swap" });
|
|
@@ -153,6 +180,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
153
180
|
return (
|
|
154
181
|
<html lang="en" className={inter.className}>
|
|
155
182
|
<body className="antialiased">
|
|
183
|
+
{/* \u2728 Press \u2318K / Ctrl-K anywhere \u2014 powered by @lacspace/ui */}
|
|
184
|
+
<CommandMenu />
|
|
156
185
|
{children}
|
|
157
186
|
<script
|
|
158
187
|
type="application/ld+json"
|
|
@@ -164,28 +193,29 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
164
193
|
}
|
|
165
194
|
`;
|
|
166
195
|
var ogRoute = (ctx) => `import { ImageResponse } from "next/og";
|
|
196
|
+
import { ogCard } from "@lacspace/og";
|
|
167
197
|
import { site } from "@/lib/site";
|
|
168
198
|
|
|
169
|
-
// \u2728 Dynamic Open Graph images \u2014 every page gets a gorgeous social
|
|
170
|
-
//
|
|
199
|
+
// \u2728 Dynamic Open Graph images \u2014 every page gets a gorgeous, auto-fitting social
|
|
200
|
+
// card at /og?title=Your+Page+Title, designed by @lacspace/og. No design tool.
|
|
171
201
|
export const runtime = "edge";
|
|
172
202
|
|
|
173
203
|
export function GET(req: Request) {
|
|
174
|
-
const
|
|
204
|
+
const { searchParams } = new URL(req.url);
|
|
205
|
+
const title = searchParams.get("title") ?? site.config.name;
|
|
206
|
+
const eyebrow = searchParams.get("eyebrow") ?? undefined;
|
|
207
|
+
|
|
175
208
|
return new ImageResponse(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<div style={{ marginTop: 28, fontSize: 34, opacity: 0.85 }}>{site.config.name}</div>
|
|
187
|
-
</div>
|
|
188
|
-
),
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
210
|
+
ogCard({
|
|
211
|
+
title,
|
|
212
|
+
eyebrow,
|
|
213
|
+
subtitle: site.config.name,
|
|
214
|
+
footer: site.config.url.replace(/^https?:\\/\\//, ""),
|
|
215
|
+
logo: "${ctx.template.siteName.trim().charAt(0).toUpperCase()}",
|
|
216
|
+
from: "${ctx.template.accent[0]}",
|
|
217
|
+
to: "${ctx.template.accent[1]}",
|
|
218
|
+
}) as any,
|
|
189
219
|
{ width: 1200, height: 630 },
|
|
190
220
|
);
|
|
191
221
|
}
|
|
@@ -229,10 +259,14 @@ boring-but-essential stuff already done. Here's what's in the box.
|
|
|
229
259
|
|
|
230
260
|
- **Beautiful home page** \u2014 styled with Tailwind v4, Inter font, dark theme + gradient accent.
|
|
231
261
|
- **SEO** \u2014 metadata, Open Graph, Twitter cards & JSON-LD, all from one file (\`lib/site.ts\`).
|
|
232
|
-
- **\u2728 Dynamic OG images** \u2014 every page auto-generates a social-share card at \`/og
|
|
262
|
+
- **\u2728 Dynamic OG images** \u2014 every page auto-generates a social-share card at \`/og\` (\`@lacspace/og\`, auto-fitting titles). Share a link and see.
|
|
263
|
+
- **\u2728 A working contact form** \u2014 \`/contact\` is live, typed, validated and spam-protected (honeypot + timing) via \`@lacspace/form\` + \`@lacspace/validate\`. Just point it at your inbox.
|
|
264
|
+
- **\u2728 A \u2318K command palette** \u2014 press \`\u2318K\` / \`Ctrl-K\` anywhere, powered by \`@lacspace/ui\`. Also try \`<Reveal>\`, \`<Counter>\`, \`<GradientText>\`, \`<TiltCard>\`, \`<Marquee>\`, \`<Typewriter>\`.
|
|
265
|
+
- **\u2728 Per-page SEO** \u2014 see \`app/about\` & \`app/contact\`: one \`site.meta()\` call gives each route its own title, canonical & OG image.
|
|
266
|
+
- **\u2728 An SEO CI gate** \u2014 \`.github/workflows/seo.yml\` audits every page on each push and fails below grade A, so SEO can never regress.
|
|
233
267
|
- **Security headers** \u2014 HSTS, CSP, X-Frame-Options and more, via \`next.config.mjs\`.
|
|
234
268
|
- **robots.txt + sitemap.xml** \u2014 generated from your site config. No hand-editing.
|
|
235
|
-
- **A styled 404
|
|
269
|
+
- **A styled 404**, a **PWA manifest**, and **auto favicon + Apple icon** \u2014 the finishing touches most starters skip.
|
|
236
270
|
|
|
237
271
|
## \u{1F680} Run it
|
|
238
272
|
|
|
@@ -246,7 +280,7 @@ npm run dev # http://localhost:3000
|
|
|
246
280
|
2. Edit **\`app/page.tsx\`** \u2014 your home page.
|
|
247
281
|
3. Set **\`NEXT_PUBLIC_SITE_URL\`** in \`.env\` before deploying (copy \`.env.example\`).
|
|
248
282
|
|
|
249
|
-
## \u{1F389} Surprise:
|
|
283
|
+
## \u{1F389} Surprise: 50+ more Lacspace packages, one install away
|
|
250
284
|
|
|
251
285
|
Your app is wired for the whole ecosystem. Drop any of these in \u2014 all zero-dependency:
|
|
252
286
|
|
|
@@ -319,7 +353,12 @@ var FOOTER = (name) => `<footer className="border-t border-white/10 py-10 text-c
|
|
|
319
353
|
</footer>`;
|
|
320
354
|
function homePage(ctx) {
|
|
321
355
|
const n = ctx.template.siteName;
|
|
322
|
-
const shell = (inner) => `
|
|
356
|
+
const shell = (inner) => `import { site } from "@/lib/site";
|
|
357
|
+
|
|
358
|
+
// \u2728 Self-canonical home page \u2014 one line, full SEO (title, canonical, OG, Twitter).
|
|
359
|
+
export const metadata = site.meta({ title: ${JSON.stringify(n)}, path: "/" });
|
|
360
|
+
|
|
361
|
+
export default function Home() {
|
|
323
362
|
return (
|
|
324
363
|
<main className="min-h-screen">
|
|
325
364
|
${inner}
|
|
@@ -627,8 +666,386 @@ export default function AboutPage() {
|
|
|
627
666
|
);
|
|
628
667
|
}
|
|
629
668
|
`;
|
|
630
|
-
|
|
669
|
+
var commandMenu = (ctx) => `"use client";
|
|
670
|
+
import { useRouter } from "next/navigation";
|
|
671
|
+
import { CommandPalette } from "@lacspace/ui";
|
|
672
|
+
|
|
673
|
+
export function CommandMenu() {
|
|
674
|
+
const router = useRouter();
|
|
675
|
+
return (
|
|
676
|
+
<CommandPalette
|
|
677
|
+
accent="${ctx.template.accent[1]}"
|
|
678
|
+
items={[
|
|
679
|
+
{ id: "home", label: "Home", group: "Navigate", shortcut: "G H", onSelect: () => router.push("/") },
|
|
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") },` : ""}
|
|
682
|
+
{ id: "contact", label: "Contact", group: "Navigate", onSelect: () => router.push("/contact") },
|
|
683
|
+
{ id: "packages", label: "Lacspace packages", group: "Links", onSelect: () => window.open("https://lacspace.com/packages", "_blank") },
|
|
684
|
+
]}
|
|
685
|
+
/>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
`;
|
|
689
|
+
var actionsTs = () => `"use server";
|
|
690
|
+
import { createForm } from "@lacspace/form";
|
|
691
|
+
import { v } from "@lacspace/validate";
|
|
692
|
+
|
|
693
|
+
const contact = createForm({
|
|
694
|
+
schema: v.object({
|
|
695
|
+
name: v.string().min(2, "Please tell us your name").trim(),
|
|
696
|
+
email: v.string().email("Enter a valid email").toLowerCase(),
|
|
697
|
+
message: v.string().min(10, "A little more detail, please"),
|
|
698
|
+
}),
|
|
699
|
+
honeypot: "company", // bots fill this hidden field; humans never see it
|
|
700
|
+
minSubmitMs: 800, // reject sub-second (bot-speed) submissions
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
export type ContactState =
|
|
704
|
+
| { ok: true }
|
|
705
|
+
| { ok: false; errors: Record<string, string>; values: Record<string, unknown> }
|
|
706
|
+
| null;
|
|
707
|
+
|
|
708
|
+
export async function submitContact(prev: ContactState, formData: FormData): Promise<ContactState> {
|
|
709
|
+
const r = contact.action(prev, formData);
|
|
710
|
+
if (!r.ok) return r;
|
|
711
|
+
|
|
712
|
+
// \u2705 r.data is fully typed: { name, email, message }
|
|
713
|
+
// TODO: send it with @lacspace/mailer, or save it to your database.
|
|
714
|
+
console.log("New contact message:", r.data);
|
|
715
|
+
return { ok: true };
|
|
716
|
+
}
|
|
717
|
+
`;
|
|
718
|
+
var contactForm = () => `"use client";
|
|
719
|
+
import { useActionState } from "react";
|
|
720
|
+
import { honeypotProps, timestampValue } from "@lacspace/form";
|
|
721
|
+
import { submitContact, type ContactState } from "@/app/actions";
|
|
722
|
+
|
|
723
|
+
const field = "w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-white/30";
|
|
724
|
+
const label = "mb-1 block text-sm text-white/60";
|
|
725
|
+
const errCls = "mt-1 text-sm text-red-400";
|
|
726
|
+
|
|
727
|
+
export function ContactForm() {
|
|
728
|
+
const [state, action, pending] = useActionState<ContactState, FormData>(submitContact, null);
|
|
729
|
+
|
|
730
|
+
if (state?.ok) {
|
|
731
|
+
return (
|
|
732
|
+
<p className="rounded-2xl border border-white/10 bg-white/5 p-8 text-center text-lg">
|
|
733
|
+
Thanks \u2014 we’ll be in touch! \u2705
|
|
734
|
+
</p>
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const err = (k: string) => (state && !state.ok ? state.errors[k] : undefined);
|
|
739
|
+
const val = (k: string) => (state && !state.ok ? ((state.values[k] as string) ?? "") : "");
|
|
740
|
+
|
|
741
|
+
return (
|
|
742
|
+
<form action={action} className="flex flex-col gap-5">
|
|
743
|
+
<div>
|
|
744
|
+
<label className={label} htmlFor="name">Name</label>
|
|
745
|
+
<input id="name" name="name" defaultValue={val("name")} className={field} />
|
|
746
|
+
{err("name") && <p className={errCls}>{err("name")}</p>}
|
|
747
|
+
</div>
|
|
748
|
+
<div>
|
|
749
|
+
<label className={label} htmlFor="email">Email</label>
|
|
750
|
+
<input id="email" name="email" type="email" defaultValue={val("email")} className={field} />
|
|
751
|
+
{err("email") && <p className={errCls}>{err("email")}</p>}
|
|
752
|
+
</div>
|
|
753
|
+
<div>
|
|
754
|
+
<label className={label} htmlFor="message">Message</label>
|
|
755
|
+
<textarea id="message" name="message" rows={5} defaultValue={val("message")} className={field} />
|
|
756
|
+
{err("message") && <p className={errCls}>{err("message")}</p>}
|
|
757
|
+
</div>
|
|
758
|
+
|
|
759
|
+
{/* spam protection \u2014 one line each */}
|
|
760
|
+
<input {...honeypotProps("company")} />
|
|
761
|
+
<input type="hidden" name="_ts" defaultValue={timestampValue()} />
|
|
762
|
+
|
|
763
|
+
{err("_form") && <p className={errCls}>{err("_form")}</p>}
|
|
764
|
+
<button
|
|
765
|
+
disabled={pending}
|
|
766
|
+
className="gradient-bg rounded-full px-8 py-3 font-semibold text-black disabled:opacity-60"
|
|
767
|
+
>
|
|
768
|
+
{pending ? "Sending\u2026" : "Send message"}
|
|
769
|
+
</button>
|
|
770
|
+
</form>
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
`;
|
|
774
|
+
var contactPage = (ctx) => `import { site } from "@/lib/site";
|
|
775
|
+
import { ContactForm } from "@/components/contact-form";
|
|
776
|
+
|
|
777
|
+
// \u2728 Per-page SEO in one line \u2014 title, canonical, Open Graph & Twitter, all set.
|
|
778
|
+
export const metadata = site.meta({
|
|
779
|
+
title: "Contact",
|
|
780
|
+
path: "/contact",
|
|
781
|
+
description: "Get in touch with ${ctx.template.siteName}.",
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
export default function ContactPage() {
|
|
785
|
+
return (
|
|
786
|
+
<main className="mx-auto max-w-2xl px-6 py-24">
|
|
787
|
+
<h1 className="text-4xl font-black gradient-text sm:text-5xl">Get in touch</h1>
|
|
788
|
+
<p className="mt-4 text-white/60">Have a question or a project in mind? Drop a message below.</p>
|
|
789
|
+
<div className="mt-10">
|
|
790
|
+
<ContactForm />
|
|
791
|
+
</div>
|
|
792
|
+
</main>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
`;
|
|
796
|
+
var seoWorkflow = () => `name: SEO
|
|
797
|
+
|
|
798
|
+
on:
|
|
799
|
+
push:
|
|
800
|
+
branches: [main]
|
|
801
|
+
pull_request:
|
|
802
|
+
|
|
803
|
+
jobs:
|
|
804
|
+
audit:
|
|
805
|
+
runs-on: ubuntu-latest
|
|
806
|
+
steps:
|
|
807
|
+
- uses: actions/checkout@v4
|
|
808
|
+
- uses: actions/setup-node@v4
|
|
809
|
+
with:
|
|
810
|
+
node-version: 20
|
|
811
|
+
- run: npm ci
|
|
812
|
+
- run: npm run build
|
|
813
|
+
env:
|
|
814
|
+
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
|
815
|
+
- name: Start the app
|
|
816
|
+
run: npm run start &
|
|
817
|
+
- name: Wait for it
|
|
818
|
+
run: npx wait-on http://localhost:3000 -t 60000
|
|
819
|
+
- name: Audit every page (fails below grade A)
|
|
820
|
+
run: npx @lacspace/seo crawl http://localhost:3000 --min-grade A
|
|
821
|
+
`;
|
|
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"));
|
|
631
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">← 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 = {
|
|
632
1049
|
"package.json": pkgJson(ctx),
|
|
633
1050
|
"tsconfig.json": tsconfig(),
|
|
634
1051
|
"next.config.mjs": nextConfig(),
|
|
@@ -646,10 +1063,23 @@ function buildFiles(ctx) {
|
|
|
646
1063
|
"app/apple-icon.tsx": appleIconTsx(ctx),
|
|
647
1064
|
"app/manifest.ts": manifestTs(ctx),
|
|
648
1065
|
"app/robots.txt/route.ts": robotsTs(),
|
|
649
|
-
"app/sitemap.xml/route.ts": sitemapTs(),
|
|
1066
|
+
"app/sitemap.xml/route.ts": isBlog ? blogSitemapTs() : sitemapTs(),
|
|
1067
|
+
"app/contact/page.tsx": contactPage(ctx),
|
|
1068
|
+
"app/actions.ts": actionsTs(),
|
|
1069
|
+
"components/command-menu.tsx": commandMenu(ctx),
|
|
1070
|
+
"components/contact-form.tsx": contactForm(),
|
|
1071
|
+
".github/workflows/seo.yml": seoWorkflow(),
|
|
650
1072
|
".env.example": envExample(),
|
|
651
1073
|
"WELCOME.md": welcomeMd(ctx)
|
|
652
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;
|
|
653
1083
|
}
|
|
654
1084
|
function parseArgs(list) {
|
|
655
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.
|
|
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": {
|