create-lacspace-app 1.2.0 → 1.4.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 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -35,10 +35,14 @@ 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.5.0",
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"
42
46
  },
43
47
  devDependencies: {
44
48
  typescript: "^5.7.0",
@@ -143,6 +147,7 @@ export const site = defineSite({
143
147
  var layout = (ctx) => `import type { Metadata } from "next";
144
148
  import { Inter } from "next/font/google";
145
149
  import { site } from "@/lib/site";
150
+ import { CommandMenu } from "@/components/command-menu";
146
151
  import "./globals.css";
147
152
 
148
153
  const inter = Inter({ subsets: ["latin"], display: "swap" });
@@ -153,6 +158,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
153
158
  return (
154
159
  <html lang="en" className={inter.className}>
155
160
  <body className="antialiased">
161
+ {/* \u2728 Press \u2318K / Ctrl-K anywhere \u2014 powered by @lacspace/ui */}
162
+ <CommandMenu />
156
163
  {children}
157
164
  <script
158
165
  type="application/ld+json"
@@ -164,28 +171,29 @@ export default function RootLayout({ children }: { children: React.ReactNode })
164
171
  }
165
172
  `;
166
173
  var ogRoute = (ctx) => `import { ImageResponse } from "next/og";
174
+ import { ogCard } from "@lacspace/og";
167
175
  import { site } from "@/lib/site";
168
176
 
169
- // \u2728 Dynamic Open Graph images \u2014 every page gets a gorgeous social card,
170
- // generated on the fly at /og?title=Your+Page+Title. No design tool needed.
177
+ // \u2728 Dynamic Open Graph images \u2014 every page gets a gorgeous, auto-fitting social
178
+ // card at /og?title=Your+Page+Title, designed by @lacspace/og. No design tool.
171
179
  export const runtime = "edge";
172
180
 
173
181
  export function GET(req: Request) {
174
- const title = new URL(req.url).searchParams.get("title") ?? site.config.name;
182
+ const { searchParams } = new URL(req.url);
183
+ const title = searchParams.get("title") ?? site.config.name;
184
+ const eyebrow = searchParams.get("eyebrow") ?? undefined;
185
+
175
186
  return new ImageResponse(
176
- (
177
- <div
178
- style={{
179
- width: "100%", height: "100%", display: "flex", flexDirection: "column",
180
- justifyContent: "center", padding: "90px",
181
- background: "linear-gradient(135deg, ${ctx.template.accent[0]}, ${ctx.template.accent[1]})",
182
- color: "white", fontFamily: "sans-serif",
183
- }}
184
- >
185
- <div style={{ fontSize: 72, fontWeight: 800, lineHeight: 1.05, letterSpacing: "-0.02em" }}>{title}</div>
186
- <div style={{ marginTop: 28, fontSize: 34, opacity: 0.85 }}>{site.config.name}</div>
187
- </div>
188
- ),
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ ogCard({
189
+ title,
190
+ eyebrow,
191
+ subtitle: site.config.name,
192
+ footer: site.config.url.replace(/^https?:\\/\\//, ""),
193
+ logo: "${ctx.template.siteName.trim().charAt(0).toUpperCase()}",
194
+ from: "${ctx.template.accent[0]}",
195
+ to: "${ctx.template.accent[1]}",
196
+ }) as any,
189
197
  { width: 1200, height: 630 },
190
198
  );
191
199
  }
@@ -229,10 +237,14 @@ boring-but-essential stuff already done. Here's what's in the box.
229
237
 
230
238
  - **Beautiful home page** \u2014 styled with Tailwind v4, Inter font, dark theme + gradient accent.
231
239
  - **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\`. Share a link and see.
240
+ - **\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.
241
+ - **\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.
242
+ - **\u2728 A \u2318K command palette** \u2014 press \`\u2318K\` / \`Ctrl-K\` anywhere, powered by \`@lacspace/ui\`. Also try \`<Reveal>\`, \`<Counter>\`, \`<GradientText>\`, \`<TiltCard>\`, \`<Marquee>\`, \`<Typewriter>\`.
243
+ - **\u2728 Per-page SEO** \u2014 see \`app/about\` & \`app/contact\`: one \`site.meta()\` call gives each route its own title, canonical & OG image.
244
+ - **\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
245
  - **Security headers** \u2014 HSTS, CSP, X-Frame-Options and more, via \`next.config.mjs\`.
234
246
  - **robots.txt + sitemap.xml** \u2014 generated from your site config. No hand-editing.
235
- - **A styled 404** and a **PWA manifest** \u2014 the finishing touches most starters skip.
247
+ - **A styled 404**, a **PWA manifest**, and **auto favicon + Apple icon** \u2014 the finishing touches most starters skip.
236
248
 
237
249
  ## \u{1F680} Run it
238
250
 
@@ -246,7 +258,7 @@ npm run dev # http://localhost:3000
246
258
  2. Edit **\`app/page.tsx\`** \u2014 your home page.
247
259
  3. Set **\`NEXT_PUBLIC_SITE_URL\`** in \`.env\` before deploying (copy \`.env.example\`).
248
260
 
249
- ## \u{1F389} Surprise: 49 more Lacspace packages, one install away
261
+ ## \u{1F389} Surprise: 50+ more Lacspace packages, one install away
250
262
 
251
263
  Your app is wired for the whole ecosystem. Drop any of these in \u2014 all zero-dependency:
252
264
 
@@ -319,7 +331,12 @@ var FOOTER = (name) => `<footer className="border-t border-white/10 py-10 text-c
319
331
  </footer>`;
320
332
  function homePage(ctx) {
321
333
  const n = ctx.template.siteName;
322
- const shell = (inner) => `export default function Home() {
334
+ const shell = (inner) => `import { site } from "@/lib/site";
335
+
336
+ // \u2728 Self-canonical home page \u2014 one line, full SEO (title, canonical, OG, Twitter).
337
+ export const metadata = site.meta({ title: ${JSON.stringify(n)}, path: "/" });
338
+
339
+ export default function Home() {
323
340
  return (
324
341
  <main className="min-h-screen">
325
342
  ${inner}
@@ -566,6 +583,219 @@ function homePage(ctx) {
566
583
  </section>
567
584
  ${FOOTER(n)}`);
568
585
  }
586
+ var glyph = (ctx) => (ctx.template.siteName.trim()[0] ?? "A").toUpperCase();
587
+ var iconTsx = (ctx) => `import { ImageResponse } from "next/og";
588
+
589
+ // \u2728 Auto-generated favicon \u2014 a branded icon from your accent, no design file.
590
+ export const size = { width: 64, height: 64 };
591
+ export const contentType = "image/png";
592
+
593
+ export default function Icon() {
594
+ return new ImageResponse(
595
+ (
596
+ <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, ${ctx.template.accent[0]}, ${ctx.template.accent[1]})", color: "white", fontSize: 40, fontWeight: 800, fontFamily: "sans-serif", borderRadius: 14 }}>
597
+ ${glyph(ctx)}
598
+ </div>
599
+ ),
600
+ { ...size },
601
+ );
602
+ }
603
+ `;
604
+ var appleIconTsx = (ctx) => `import { ImageResponse } from "next/og";
605
+
606
+ // \u2728 Auto-generated Apple touch icon.
607
+ export const size = { width: 180, height: 180 };
608
+ export const contentType = "image/png";
609
+
610
+ export default function AppleIcon() {
611
+ return new ImageResponse(
612
+ (
613
+ <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, ${ctx.template.accent[0]}, ${ctx.template.accent[1]})", color: "white", fontSize: 104, fontWeight: 800, fontFamily: "sans-serif" }}>
614
+ ${glyph(ctx)}
615
+ </div>
616
+ ),
617
+ { ...size },
618
+ );
619
+ }
620
+ `;
621
+ var aboutPage = (ctx) => `import type { Metadata } from "next";
622
+ import Link from "next/link";
623
+ import { site } from "@/lib/site";
624
+
625
+ // \u2728 Per-page SEO, auto-generated: title template, canonical, Open Graph,
626
+ // Twitter card and its OWN dynamic OG image \u2014 all from one line.
627
+ export const metadata: Metadata = site.meta({
628
+ title: "About",
629
+ path: "/about",
630
+ description: "Learn more about ${ctx.template.siteName}.",
631
+ });
632
+
633
+ export default function AboutPage() {
634
+ return (
635
+ <main className="mx-auto max-w-3xl px-6 py-28">
636
+ <Link href="/" className="text-sm text-white/50 hover:text-white">\u2190 Back home</Link>
637
+ <h1 className="mt-6 text-4xl font-black sm:text-5xl gradient-text">About</h1>
638
+ <p className="mt-6 text-lg leading-relaxed text-white/60">
639
+ This page already has its own SEO \u2014 a unique title, canonical URL, Open Graph tags and a
640
+ generated social image \u2014 from a single <code>site.meta()</code> call. Duplicate this file for
641
+ any new route and it just works.
642
+ </p>
643
+ </main>
644
+ );
645
+ }
646
+ `;
647
+ var commandMenu = (ctx) => `"use client";
648
+ import { useRouter } from "next/navigation";
649
+ import { CommandPalette } from "@lacspace/ui";
650
+
651
+ export function CommandMenu() {
652
+ const router = useRouter();
653
+ return (
654
+ <CommandPalette
655
+ accent="${ctx.template.accent[1]}"
656
+ items={[
657
+ { id: "home", label: "Home", group: "Navigate", shortcut: "G H", onSelect: () => router.push("/") },
658
+ { id: "about", label: "About", group: "Navigate", onSelect: () => router.push("/about") },
659
+ { id: "contact", label: "Contact", group: "Navigate", onSelect: () => router.push("/contact") },
660
+ { id: "packages", label: "Lacspace packages", group: "Links", onSelect: () => window.open("https://lacspace.com/packages", "_blank") },
661
+ ]}
662
+ />
663
+ );
664
+ }
665
+ `;
666
+ var actionsTs = () => `"use server";
667
+ import { createForm } from "@lacspace/form";
668
+ import { v } from "@lacspace/validate";
669
+
670
+ const contact = createForm({
671
+ schema: v.object({
672
+ name: v.string().min(2, "Please tell us your name").trim(),
673
+ email: v.string().email("Enter a valid email").toLowerCase(),
674
+ message: v.string().min(10, "A little more detail, please"),
675
+ }),
676
+ honeypot: "company", // bots fill this hidden field; humans never see it
677
+ minSubmitMs: 800, // reject sub-second (bot-speed) submissions
678
+ });
679
+
680
+ export type ContactState =
681
+ | { ok: true }
682
+ | { ok: false; errors: Record<string, string>; values: Record<string, unknown> }
683
+ | null;
684
+
685
+ export async function submitContact(prev: ContactState, formData: FormData): Promise<ContactState> {
686
+ const r = contact.action(prev, formData);
687
+ if (!r.ok) return r;
688
+
689
+ // \u2705 r.data is fully typed: { name, email, message }
690
+ // TODO: send it with @lacspace/mailer, or save it to your database.
691
+ console.log("New contact message:", r.data);
692
+ return { ok: true };
693
+ }
694
+ `;
695
+ var contactForm = () => `"use client";
696
+ import { useActionState } from "react";
697
+ import { honeypotProps, timestampValue } from "@lacspace/form";
698
+ import { submitContact, type ContactState } from "@/app/actions";
699
+
700
+ const field = "w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-white/30";
701
+ const label = "mb-1 block text-sm text-white/60";
702
+ const errCls = "mt-1 text-sm text-red-400";
703
+
704
+ export function ContactForm() {
705
+ const [state, action, pending] = useActionState<ContactState, FormData>(submitContact, null);
706
+
707
+ if (state?.ok) {
708
+ return (
709
+ <p className="rounded-2xl border border-white/10 bg-white/5 p-8 text-center text-lg">
710
+ Thanks \u2014 we&rsquo;ll be in touch! \u2705
711
+ </p>
712
+ );
713
+ }
714
+
715
+ const err = (k: string) => (state && !state.ok ? state.errors[k] : undefined);
716
+ const val = (k: string) => (state && !state.ok ? ((state.values[k] as string) ?? "") : "");
717
+
718
+ return (
719
+ <form action={action} className="flex flex-col gap-5">
720
+ <div>
721
+ <label className={label} htmlFor="name">Name</label>
722
+ <input id="name" name="name" defaultValue={val("name")} className={field} />
723
+ {err("name") && <p className={errCls}>{err("name")}</p>}
724
+ </div>
725
+ <div>
726
+ <label className={label} htmlFor="email">Email</label>
727
+ <input id="email" name="email" type="email" defaultValue={val("email")} className={field} />
728
+ {err("email") && <p className={errCls}>{err("email")}</p>}
729
+ </div>
730
+ <div>
731
+ <label className={label} htmlFor="message">Message</label>
732
+ <textarea id="message" name="message" rows={5} defaultValue={val("message")} className={field} />
733
+ {err("message") && <p className={errCls}>{err("message")}</p>}
734
+ </div>
735
+
736
+ {/* spam protection \u2014 one line each */}
737
+ <input {...honeypotProps("company")} />
738
+ <input type="hidden" name="_ts" defaultValue={timestampValue()} />
739
+
740
+ {err("_form") && <p className={errCls}>{err("_form")}</p>}
741
+ <button
742
+ disabled={pending}
743
+ className="gradient-bg rounded-full px-8 py-3 font-semibold text-black disabled:opacity-60"
744
+ >
745
+ {pending ? "Sending\u2026" : "Send message"}
746
+ </button>
747
+ </form>
748
+ );
749
+ }
750
+ `;
751
+ var contactPage = (ctx) => `import { site } from "@/lib/site";
752
+ import { ContactForm } from "@/components/contact-form";
753
+
754
+ // \u2728 Per-page SEO in one line \u2014 title, canonical, Open Graph & Twitter, all set.
755
+ export const metadata = site.meta({
756
+ title: "Contact",
757
+ path: "/contact",
758
+ description: "Get in touch with ${ctx.template.siteName}.",
759
+ });
760
+
761
+ export default function ContactPage() {
762
+ return (
763
+ <main className="mx-auto max-w-2xl px-6 py-24">
764
+ <h1 className="text-4xl font-black gradient-text sm:text-5xl">Get in touch</h1>
765
+ <p className="mt-4 text-white/60">Have a question or a project in mind? Drop a message below.</p>
766
+ <div className="mt-10">
767
+ <ContactForm />
768
+ </div>
769
+ </main>
770
+ );
771
+ }
772
+ `;
773
+ var seoWorkflow = () => `name: SEO
774
+
775
+ on:
776
+ push:
777
+ branches: [main]
778
+ pull_request:
779
+
780
+ jobs:
781
+ audit:
782
+ runs-on: ubuntu-latest
783
+ steps:
784
+ - uses: actions/checkout@v4
785
+ - uses: actions/setup-node@v4
786
+ with:
787
+ node-version: 20
788
+ - run: npm ci
789
+ - run: npm run build
790
+ env:
791
+ NEXT_PUBLIC_SITE_URL: http://localhost:3000
792
+ - name: Start the app
793
+ run: npm run start &
794
+ - name: Wait for it
795
+ run: npx wait-on http://localhost:3000 -t 60000
796
+ - name: Audit every page (fails below grade A)
797
+ run: npx @lacspace/seo crawl http://localhost:3000 --min-grade A
798
+ `;
569
799
  function buildFiles(ctx) {
570
800
  return {
571
801
  "package.json": pkgJson(ctx),
@@ -578,11 +808,19 @@ function buildFiles(ctx) {
578
808
  "app/layout.tsx": layout(ctx),
579
809
  "app/globals.css": globalsCss(ctx),
580
810
  "app/page.tsx": homePage(ctx),
811
+ "app/about/page.tsx": aboutPage(ctx),
581
812
  "app/not-found.tsx": notFound(),
582
813
  "app/og/route.tsx": ogRoute(ctx),
814
+ "app/icon.tsx": iconTsx(ctx),
815
+ "app/apple-icon.tsx": appleIconTsx(ctx),
583
816
  "app/manifest.ts": manifestTs(ctx),
584
817
  "app/robots.txt/route.ts": robotsTs(),
585
818
  "app/sitemap.xml/route.ts": sitemapTs(),
819
+ "app/contact/page.tsx": contactPage(ctx),
820
+ "app/actions.ts": actionsTs(),
821
+ "components/command-menu.tsx": commandMenu(ctx),
822
+ "components/contact-form.tsx": contactForm(),
823
+ ".github/workflows/seo.yml": seoWorkflow(),
586
824
  ".env.example": envExample(),
587
825
  "WELCOME.md": welcomeMd(ctx)
588
826
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-lacspace-app",
3
- "version": "1.2.0",
3
+ "version": "1.4.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": {