create-lacspace-app 2.4.0 → 2.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/README.md +6 -0
- package/dist/index.js +317 -0
- package/dist/lib.cjs +317 -0
- package/dist/lib.js +317 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,12 @@ npx create-lacspace-app my-app --template saas --fullstack
|
|
|
20
20
|
|
|
21
21
|
You choose the *kind* of site you're building. It writes a **real Next.js 15 + React 19 + Tailwind v4 app** — not a hello-world, but a genuinely **polished, modern site**: a fluid `clamp()` type scale, tight display headings, a refined light **and** dark palette, glass chrome, soft layered shadows, a smooth logo marquee, animated counters, scroll reveals and a shimmering primary CTA — every page filled in, an SEO stack wired end-to-end, and a **26-component UI kit** you can drop in anywhere.
|
|
22
22
|
|
|
23
|
+
> **New in v2.5 — more prebuilt add-ons.** The `--with` catalog grows beyond AI:
|
|
24
|
+
> - **`content`** — a Markdown content section (`/updates`) for *any* template, with an auto-generated **RSS feed** and **`llms.txt`**. Drop `.md` files in, get pages.
|
|
25
|
+
> - **`search`** — **instant, keyless full-text search** (BM25) over your Markdown — a search box, a `/search` page and an API route. No API key, no service, no Ollama required.
|
|
26
|
+
>
|
|
27
|
+
> More add-ons (auth, payments, email, analytics…) are on the way — same rules: additive, keyless, free-first.
|
|
28
|
+
|
|
23
29
|
> **New in v2.4 — static *or* full-stack.** Now the interactive prompt (after you pick a template) asks what *kind* of app you want:
|
|
24
30
|
> - **Static / frontend only** *(default)* — today's single Next.js app, unchanged.
|
|
25
31
|
> - **Dynamic / full-stack** (`--fullstack`) — an npm-workspaces **monorepo**: a `frontend/` Next.js app **+** a `backend/` **Node · Express · MongoDB · Redis · TypeScript** API **+** a shared `types/` package the two both import (so the API contract can't drift), plus a root `docker-compose.yml` (Mongo + Redis) and **one** `npm install` / `npm run dev` for the whole thing.
|
package/dist/index.js
CHANGED
|
@@ -2117,6 +2117,51 @@ var FEATURES = [
|
|
|
2117
2117
|
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2118
2118
|
],
|
|
2119
2119
|
learn: "https://developer.lacspace.com/packages/rag"
|
|
2120
|
+
},
|
|
2121
|
+
{
|
|
2122
|
+
key: "content",
|
|
2123
|
+
label: "Content / blog",
|
|
2124
|
+
description: "A markdown content section (/updates) for any template \u2014 plus an RSS feed and an llms.txt. Drop in .md files, get pages.",
|
|
2125
|
+
deps: {
|
|
2126
|
+
"@lacspace/markdown": "^1.1.0",
|
|
2127
|
+
"@lacspace/rss": "^1.4.0",
|
|
2128
|
+
"@lacspace/llms-txt": "^1.4.0"
|
|
2129
|
+
},
|
|
2130
|
+
files: (ctx) => ({
|
|
2131
|
+
"content/updates/welcome.md": contentSampleWelcome(ctx),
|
|
2132
|
+
"content/updates/building-in-public.md": contentSampleSecond(),
|
|
2133
|
+
"lib/content.ts": contentLib(),
|
|
2134
|
+
"app/updates/page.tsx": updatesListPage(),
|
|
2135
|
+
"app/updates/[slug]/page.tsx": updatePostPage(),
|
|
2136
|
+
"app/feed.xml/route.ts": feedRoute(),
|
|
2137
|
+
"app/llms.txt/route.ts": llmsRoute()
|
|
2138
|
+
}),
|
|
2139
|
+
nextSteps: [
|
|
2140
|
+
"Add markdown files to content/updates/ \u2014 each becomes a page at /updates/<name>.",
|
|
2141
|
+
"Run `npm run dev` and open http://localhost:3000/updates, /feed.xml and /llms.txt.",
|
|
2142
|
+
"Set NEXT_PUBLIC_SITE_URL in .env so the feed and llms.txt links are absolute."
|
|
2143
|
+
],
|
|
2144
|
+
learn: "https://developer.lacspace.com/packages/markdown"
|
|
2145
|
+
},
|
|
2146
|
+
{
|
|
2147
|
+
key: "search",
|
|
2148
|
+
label: "Search",
|
|
2149
|
+
description: "Instant, keyless full-text search (BM25) over your markdown content \u2014 a search box + /search page + API route. No key, no service.",
|
|
2150
|
+
deps: {
|
|
2151
|
+
"@lacspace/rerank": "^1.0.0",
|
|
2152
|
+
"@lacspace/markdown": "^1.1.0"
|
|
2153
|
+
},
|
|
2154
|
+
files: () => ({
|
|
2155
|
+
"app/api/search/route.ts": searchRoute(),
|
|
2156
|
+
"components/search.tsx": searchBox(),
|
|
2157
|
+
"app/search/page.tsx": searchPage()
|
|
2158
|
+
}),
|
|
2159
|
+
nextSteps: [
|
|
2160
|
+
"Add markdown to content/ (e.g. the `content` add-on's content/updates/) \u2014 search indexes it automatically.",
|
|
2161
|
+
"Run `npm run dev` and open http://localhost:3000/search, or drop <Search /> into your header.",
|
|
2162
|
+
"Want semantic search? Upgrade to @lacspace/embeddings + @lacspace/vector (keyless-local via Ollama)."
|
|
2163
|
+
],
|
|
2164
|
+
learn: "https://developer.lacspace.com/packages/rerank"
|
|
2120
2165
|
}
|
|
2121
2166
|
];
|
|
2122
2167
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -5095,6 +5140,278 @@ var faqSection = (ctx) => {
|
|
|
5095
5140
|
</div>
|
|
5096
5141
|
</section>`;
|
|
5097
5142
|
};
|
|
5143
|
+
var contentLib = () => `import fs from "node:fs";
|
|
5144
|
+
import path from "node:path";
|
|
5145
|
+
import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
|
|
5146
|
+
|
|
5147
|
+
// how this works: every .md file in content/updates/ becomes a post. Frontmatter
|
|
5148
|
+
// (title/date/description) is read with @lacspace/markdown; the body is rendered to
|
|
5149
|
+
// safe HTML (raw HTML in the source is escaped, so this is XSS-safe by construction).
|
|
5150
|
+
const DIR = path.join(process.cwd(), "content/updates");
|
|
5151
|
+
|
|
5152
|
+
export interface PostMeta { slug: string; title: string; date: string; description: string; }
|
|
5153
|
+
export interface Post extends PostMeta { html: string; }
|
|
5154
|
+
|
|
5155
|
+
function read(slug: string): Post | null {
|
|
5156
|
+
const file = path.join(DIR, slug + ".md");
|
|
5157
|
+
if (!fs.existsSync(file)) return null;
|
|
5158
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
5159
|
+
return {
|
|
5160
|
+
slug,
|
|
5161
|
+
title: String(data.title ?? slug),
|
|
5162
|
+
date: String(data.date ?? ""),
|
|
5163
|
+
description: String(data.description ?? excerpt(content, { length: 160 })),
|
|
5164
|
+
html: markdownToHtml(content, { headingIds: true, openLinksInNewTab: true }),
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
export function getAllPosts(): PostMeta[] {
|
|
5169
|
+
if (!fs.existsSync(DIR)) return [];
|
|
5170
|
+
return fs.readdirSync(DIR)
|
|
5171
|
+
.filter((f) => f.endsWith(".md"))
|
|
5172
|
+
.map((f) => read(f.replace(/\\.md$/, ""))!)
|
|
5173
|
+
.filter(Boolean)
|
|
5174
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
export function getPost(slug: string): Post | null {
|
|
5178
|
+
return read(slug);
|
|
5179
|
+
}
|
|
5180
|
+
`;
|
|
5181
|
+
var updatesListPage = () => `import Link from "next/link";
|
|
5182
|
+
import { site } from "@/lib/site";
|
|
5183
|
+
import { getAllPosts } from "@/lib/content";
|
|
5184
|
+
|
|
5185
|
+
export const metadata = site.meta({ title: "Updates", path: "/updates" });
|
|
5186
|
+
|
|
5187
|
+
export default function UpdatesPage() {
|
|
5188
|
+
const posts = getAllPosts();
|
|
5189
|
+
return (
|
|
5190
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5191
|
+
<h1 className="text-3xl font-bold">Updates</h1>
|
|
5192
|
+
<p className="mt-2 text-muted">News, notes and posts. <a href="/feed.xml" className="underline">RSS</a></p>
|
|
5193
|
+
{posts.length === 0 && (
|
|
5194
|
+
<p className="mt-8 text-muted">No posts yet \u2014 add a markdown file in <code>content/updates/</code>.</p>
|
|
5195
|
+
)}
|
|
5196
|
+
<ul className="mt-8 space-y-6">
|
|
5197
|
+
{posts.map((p) => (
|
|
5198
|
+
<li key={p.slug} className="border-b border-hairline pb-6">
|
|
5199
|
+
<Link href={\`/updates/\${p.slug}\`} className="text-xl font-semibold hover:underline">{p.title}</Link>
|
|
5200
|
+
{p.date && <p className="mt-1 text-sm text-muted">{p.date}</p>}
|
|
5201
|
+
<p className="mt-2 text-muted">{p.description}</p>
|
|
5202
|
+
</li>
|
|
5203
|
+
))}
|
|
5204
|
+
</ul>
|
|
5205
|
+
</main>
|
|
5206
|
+
);
|
|
5207
|
+
}
|
|
5208
|
+
`;
|
|
5209
|
+
var updatePostPage = () => `import Link from "next/link";
|
|
5210
|
+
import { notFound } from "next/navigation";
|
|
5211
|
+
import { site } from "@/lib/site";
|
|
5212
|
+
import { getAllPosts, getPost } from "@/lib/content";
|
|
5213
|
+
|
|
5214
|
+
export function generateStaticParams() {
|
|
5215
|
+
return getAllPosts().map((p) => ({ slug: p.slug }));
|
|
5216
|
+
}
|
|
5217
|
+
|
|
5218
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
5219
|
+
const { slug } = await params;
|
|
5220
|
+
const post = getPost(slug);
|
|
5221
|
+
return site.meta({ title: post?.title ?? "Updates", path: \`/updates/\${slug}\` });
|
|
5222
|
+
}
|
|
5223
|
+
|
|
5224
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
5225
|
+
const { slug } = await params;
|
|
5226
|
+
const post = getPost(slug);
|
|
5227
|
+
if (!post) notFound();
|
|
5228
|
+
return (
|
|
5229
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5230
|
+
<Link href="/updates" className="text-sm text-muted hover:underline">\u2190 Updates</Link>
|
|
5231
|
+
<h1 className="mt-4 text-3xl font-bold">{post.title}</h1>
|
|
5232
|
+
{post.date && <p className="mt-1 text-sm text-muted">{post.date}</p>}
|
|
5233
|
+
<article
|
|
5234
|
+
className="mt-8 leading-7 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_p]:mt-4 [&_p]:text-muted [&_a]:underline [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:mt-1 [&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-surface [&_pre]:p-4 [&_pre]:text-sm [&_code]:text-sm"
|
|
5235
|
+
dangerouslySetInnerHTML={{ __html: post.html }}
|
|
5236
|
+
/>
|
|
5237
|
+
</main>
|
|
5238
|
+
);
|
|
5239
|
+
}
|
|
5240
|
+
`;
|
|
5241
|
+
var feedRoute = () => `import { rssResponse } from "@lacspace/rss";
|
|
5242
|
+
import { getAllPosts } from "@/lib/content";
|
|
5243
|
+
|
|
5244
|
+
export const dynamic = "force-static";
|
|
5245
|
+
|
|
5246
|
+
export function GET() {
|
|
5247
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5248
|
+
const items = getAllPosts().map((p) => ({
|
|
5249
|
+
title: p.title,
|
|
5250
|
+
link: base + "/updates/" + p.slug,
|
|
5251
|
+
description: p.description,
|
|
5252
|
+
// Only pass a date the feed can parse (rfc822Date throws on bad input).
|
|
5253
|
+
date: p.date && !Number.isNaN(Date.parse(p.date)) ? p.date : undefined,
|
|
5254
|
+
}));
|
|
5255
|
+
return rssResponse(
|
|
5256
|
+
{ title: "Updates", link: base, description: "The latest posts and updates.", feedUrl: base + "/feed.xml" },
|
|
5257
|
+
items,
|
|
5258
|
+
);
|
|
5259
|
+
}
|
|
5260
|
+
`;
|
|
5261
|
+
var llmsRoute = () => `import { llmsTxtFromPages } from "@lacspace/llms-txt";
|
|
5262
|
+
import { getAllPosts } from "@/lib/content";
|
|
5263
|
+
|
|
5264
|
+
export const dynamic = "force-static";
|
|
5265
|
+
|
|
5266
|
+
export function GET() {
|
|
5267
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5268
|
+
const txt = llmsTxtFromPages(
|
|
5269
|
+
getAllPosts().map((p) => ({ title: p.title, url: base + "/updates/" + p.slug, content: p.description, section: "Updates" })),
|
|
5270
|
+
{ title: "Updates", summary: "A content index for large language models." },
|
|
5271
|
+
);
|
|
5272
|
+
return new Response(txt, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5273
|
+
}
|
|
5274
|
+
`;
|
|
5275
|
+
var contentSampleWelcome = (ctx) => `---
|
|
5276
|
+
title: Welcome to ${ctx.template.siteName}'s content section
|
|
5277
|
+
date: 2026-01-15
|
|
5278
|
+
description: This markdown file became a page automatically \u2014 here's how it works.
|
|
5279
|
+
---
|
|
5280
|
+
|
|
5281
|
+
## It just works
|
|
5282
|
+
|
|
5283
|
+
Drop any \\\`.md\\\` file into \\\`content/updates/\\\` and it becomes a page at \\\`/updates/<filename>\\\`, listed on \\\`/updates\\\`, and included in \\\`/feed.xml\\\` and \\\`/llms.txt\\\`.
|
|
5284
|
+
|
|
5285
|
+
- Frontmatter \\\`title\\\`, \\\`date\\\` and \\\`description\\\` are read automatically.
|
|
5286
|
+
- The body is rendered safely with \\\`@lacspace/markdown\\\` \u2014 raw HTML is escaped, so it's XSS-safe.
|
|
5287
|
+
- Delete this file when you're ready to write your own.
|
|
5288
|
+
`;
|
|
5289
|
+
var contentSampleSecond = () => `---
|
|
5290
|
+
title: Building in public
|
|
5291
|
+
date: 2026-01-10
|
|
5292
|
+
description: A second example post so the list and feed have something to show.
|
|
5293
|
+
---
|
|
5294
|
+
|
|
5295
|
+
## Why a content section?
|
|
5296
|
+
|
|
5297
|
+
Every product needs a place for changelog posts, announcements and notes. This one is
|
|
5298
|
+
Markdown-powered, needs no database, and ships an RSS feed and an \\\`llms.txt\\\` for free.
|
|
5299
|
+
|
|
5300
|
+
### Add your own
|
|
5301
|
+
|
|
5302
|
+
1. Create \\\`content/updates/my-post.md\\\`.
|
|
5303
|
+
2. Add frontmatter (\\\`title\\\`, \\\`date\\\`, \\\`description\\\`).
|
|
5304
|
+
3. Write. That's it \u2014 it appears at \\\`/updates/my-post\\\`.
|
|
5305
|
+
`;
|
|
5306
|
+
var searchRoute = () => `import fs from "node:fs";
|
|
5307
|
+
import path from "node:path";
|
|
5308
|
+
import { parseFrontmatter, toPlainText, excerpt } from "@lacspace/markdown";
|
|
5309
|
+
import { rerank } from "@lacspace/rerank";
|
|
5310
|
+
|
|
5311
|
+
// how this works: full-text search with BM25 ranking \u2014 no external service, no API
|
|
5312
|
+
// key, no Ollama. It scans your markdown at request time and ranks matches. (For a
|
|
5313
|
+
// big site, precompute the corpus at build time and cache it instead.)
|
|
5314
|
+
export const runtime = "nodejs";
|
|
5315
|
+
export const dynamic = "force-dynamic";
|
|
5316
|
+
|
|
5317
|
+
const ROOT = path.join(process.cwd(), "content");
|
|
5318
|
+
// Map a content/<dir>/ to the route it renders under.
|
|
5319
|
+
const SECTION: Record<string, string> = { updates: "/updates", posts: "/blog", docs: "/docs" };
|
|
5320
|
+
|
|
5321
|
+
interface Entry { id: string; title: string; url: string; text: string; }
|
|
5322
|
+
|
|
5323
|
+
function corpus(): Entry[] {
|
|
5324
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
5325
|
+
const out: Entry[] = [];
|
|
5326
|
+
for (const dir of fs.readdirSync(ROOT)) {
|
|
5327
|
+
const full = path.join(ROOT, dir);
|
|
5328
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
5329
|
+
const base = SECTION[dir] ?? "/" + dir;
|
|
5330
|
+
for (const file of fs.readdirSync(full)) {
|
|
5331
|
+
if (!file.endsWith(".md")) continue;
|
|
5332
|
+
const slug = file.replace(/\\.md$/, "");
|
|
5333
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(path.join(full, file), "utf8"));
|
|
5334
|
+
out.push({ id: dir + "/" + slug, title: String(data.title ?? slug), url: base + "/" + slug, text: toPlainText(content) });
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
return out;
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
export async function GET(req: Request) {
|
|
5341
|
+
const q = new URL(req.url).searchParams.get("q")?.trim() ?? "";
|
|
5342
|
+
if (!q) return Response.json([]);
|
|
5343
|
+
const docs = corpus().map((e) => ({ id: e.id, text: e.title + "\\n" + e.text, metadata: { title: e.title, url: e.url } }));
|
|
5344
|
+
const hits = await rerank(q, docs, { method: "bm25", k: 8 });
|
|
5345
|
+
return Response.json(hits.map((h) => ({
|
|
5346
|
+
title: String(h.metadata?.title ?? ""),
|
|
5347
|
+
url: String(h.metadata?.url ?? "#"),
|
|
5348
|
+
snippet: excerpt(h.text.split("\\n").slice(1).join(" "), { length: 140 }),
|
|
5349
|
+
})));
|
|
5350
|
+
}
|
|
5351
|
+
`;
|
|
5352
|
+
var searchBox = () => `"use client";
|
|
5353
|
+
import { useState, useEffect, useRef } from "react";
|
|
5354
|
+
|
|
5355
|
+
interface Result { title: string; url: string; snippet: string; }
|
|
5356
|
+
|
|
5357
|
+
export function Search() {
|
|
5358
|
+
const [q, setQ] = useState("");
|
|
5359
|
+
const [results, setResults] = useState<Result[]>([]);
|
|
5360
|
+
const [open, setOpen] = useState(false);
|
|
5361
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
5362
|
+
|
|
5363
|
+
useEffect(() => {
|
|
5364
|
+
if (timer.current) clearTimeout(timer.current);
|
|
5365
|
+
if (!q.trim()) { setResults([]); return; }
|
|
5366
|
+
timer.current = setTimeout(async () => {
|
|
5367
|
+
const res = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
5368
|
+
setResults(await res.json());
|
|
5369
|
+
setOpen(true);
|
|
5370
|
+
}, 200);
|
|
5371
|
+
return () => { if (timer.current) clearTimeout(timer.current); };
|
|
5372
|
+
}, [q]);
|
|
5373
|
+
|
|
5374
|
+
return (
|
|
5375
|
+
<div className="relative w-full max-w-md">
|
|
5376
|
+
<input
|
|
5377
|
+
value={q}
|
|
5378
|
+
onChange={(e) => setQ(e.target.value)}
|
|
5379
|
+
onFocus={() => { if (results.length) setOpen(true); }}
|
|
5380
|
+
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
5381
|
+
placeholder="Search\u2026"
|
|
5382
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none"
|
|
5383
|
+
/>
|
|
5384
|
+
{open && results.length > 0 && (
|
|
5385
|
+
<ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-hairline bg-app shadow-lg">
|
|
5386
|
+
{results.map((r) => (
|
|
5387
|
+
<li key={r.url}>
|
|
5388
|
+
<a href={r.url} className="block px-4 py-3 transition hover:bg-surface">
|
|
5389
|
+
<p className="font-medium">{r.title}</p>
|
|
5390
|
+
<p className="mt-0.5 text-sm text-muted">{r.snippet}</p>
|
|
5391
|
+
</a>
|
|
5392
|
+
</li>
|
|
5393
|
+
))}
|
|
5394
|
+
</ul>
|
|
5395
|
+
)}
|
|
5396
|
+
</div>
|
|
5397
|
+
);
|
|
5398
|
+
}
|
|
5399
|
+
`;
|
|
5400
|
+
var searchPage = () => `import { site } from "@/lib/site";
|
|
5401
|
+
import { Search } from "@/components/search";
|
|
5402
|
+
|
|
5403
|
+
export const metadata = site.meta({ title: "Search", path: "/search" });
|
|
5404
|
+
|
|
5405
|
+
export default function SearchPage() {
|
|
5406
|
+
return (
|
|
5407
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5408
|
+
<h1 className="text-3xl font-bold">Search</h1>
|
|
5409
|
+
<p className="mt-2 text-muted">Instant, keyless full-text search over your content.</p>
|
|
5410
|
+
<div className="mt-8"><Search /></div>
|
|
5411
|
+
</main>
|
|
5412
|
+
);
|
|
5413
|
+
}
|
|
5414
|
+
`;
|
|
5098
5415
|
var scope = (ctx) => `@${ctx.name}`;
|
|
5099
5416
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5100
5417
|
name: `${scope(ctx)}/types`,
|
package/dist/lib.cjs
CHANGED
|
@@ -2121,6 +2121,51 @@ var FEATURES = [
|
|
|
2121
2121
|
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2122
2122
|
],
|
|
2123
2123
|
learn: "https://developer.lacspace.com/packages/rag"
|
|
2124
|
+
},
|
|
2125
|
+
{
|
|
2126
|
+
key: "content",
|
|
2127
|
+
label: "Content / blog",
|
|
2128
|
+
description: "A markdown content section (/updates) for any template \u2014 plus an RSS feed and an llms.txt. Drop in .md files, get pages.",
|
|
2129
|
+
deps: {
|
|
2130
|
+
"@lacspace/markdown": "^1.1.0",
|
|
2131
|
+
"@lacspace/rss": "^1.4.0",
|
|
2132
|
+
"@lacspace/llms-txt": "^1.4.0"
|
|
2133
|
+
},
|
|
2134
|
+
files: (ctx) => ({
|
|
2135
|
+
"content/updates/welcome.md": contentSampleWelcome(ctx),
|
|
2136
|
+
"content/updates/building-in-public.md": contentSampleSecond(),
|
|
2137
|
+
"lib/content.ts": contentLib(),
|
|
2138
|
+
"app/updates/page.tsx": updatesListPage(),
|
|
2139
|
+
"app/updates/[slug]/page.tsx": updatePostPage(),
|
|
2140
|
+
"app/feed.xml/route.ts": feedRoute(),
|
|
2141
|
+
"app/llms.txt/route.ts": llmsRoute()
|
|
2142
|
+
}),
|
|
2143
|
+
nextSteps: [
|
|
2144
|
+
"Add markdown files to content/updates/ \u2014 each becomes a page at /updates/<name>.",
|
|
2145
|
+
"Run `npm run dev` and open http://localhost:3000/updates, /feed.xml and /llms.txt.",
|
|
2146
|
+
"Set NEXT_PUBLIC_SITE_URL in .env so the feed and llms.txt links are absolute."
|
|
2147
|
+
],
|
|
2148
|
+
learn: "https://developer.lacspace.com/packages/markdown"
|
|
2149
|
+
},
|
|
2150
|
+
{
|
|
2151
|
+
key: "search",
|
|
2152
|
+
label: "Search",
|
|
2153
|
+
description: "Instant, keyless full-text search (BM25) over your markdown content \u2014 a search box + /search page + API route. No key, no service.",
|
|
2154
|
+
deps: {
|
|
2155
|
+
"@lacspace/rerank": "^1.0.0",
|
|
2156
|
+
"@lacspace/markdown": "^1.1.0"
|
|
2157
|
+
},
|
|
2158
|
+
files: () => ({
|
|
2159
|
+
"app/api/search/route.ts": searchRoute(),
|
|
2160
|
+
"components/search.tsx": searchBox(),
|
|
2161
|
+
"app/search/page.tsx": searchPage()
|
|
2162
|
+
}),
|
|
2163
|
+
nextSteps: [
|
|
2164
|
+
"Add markdown to content/ (e.g. the `content` add-on's content/updates/) \u2014 search indexes it automatically.",
|
|
2165
|
+
"Run `npm run dev` and open http://localhost:3000/search, or drop <Search /> into your header.",
|
|
2166
|
+
"Want semantic search? Upgrade to @lacspace/embeddings + @lacspace/vector (keyless-local via Ollama)."
|
|
2167
|
+
],
|
|
2168
|
+
learn: "https://developer.lacspace.com/packages/rerank"
|
|
2124
2169
|
}
|
|
2125
2170
|
];
|
|
2126
2171
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -5099,6 +5144,278 @@ var faqSection = (ctx) => {
|
|
|
5099
5144
|
</div>
|
|
5100
5145
|
</section>`;
|
|
5101
5146
|
};
|
|
5147
|
+
var contentLib = () => `import fs from "node:fs";
|
|
5148
|
+
import path from "node:path";
|
|
5149
|
+
import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
|
|
5150
|
+
|
|
5151
|
+
// how this works: every .md file in content/updates/ becomes a post. Frontmatter
|
|
5152
|
+
// (title/date/description) is read with @lacspace/markdown; the body is rendered to
|
|
5153
|
+
// safe HTML (raw HTML in the source is escaped, so this is XSS-safe by construction).
|
|
5154
|
+
const DIR = path.join(process.cwd(), "content/updates");
|
|
5155
|
+
|
|
5156
|
+
export interface PostMeta { slug: string; title: string; date: string; description: string; }
|
|
5157
|
+
export interface Post extends PostMeta { html: string; }
|
|
5158
|
+
|
|
5159
|
+
function read(slug: string): Post | null {
|
|
5160
|
+
const file = path.join(DIR, slug + ".md");
|
|
5161
|
+
if (!fs.existsSync(file)) return null;
|
|
5162
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
5163
|
+
return {
|
|
5164
|
+
slug,
|
|
5165
|
+
title: String(data.title ?? slug),
|
|
5166
|
+
date: String(data.date ?? ""),
|
|
5167
|
+
description: String(data.description ?? excerpt(content, { length: 160 })),
|
|
5168
|
+
html: markdownToHtml(content, { headingIds: true, openLinksInNewTab: true }),
|
|
5169
|
+
};
|
|
5170
|
+
}
|
|
5171
|
+
|
|
5172
|
+
export function getAllPosts(): PostMeta[] {
|
|
5173
|
+
if (!fs.existsSync(DIR)) return [];
|
|
5174
|
+
return fs.readdirSync(DIR)
|
|
5175
|
+
.filter((f) => f.endsWith(".md"))
|
|
5176
|
+
.map((f) => read(f.replace(/\\.md$/, ""))!)
|
|
5177
|
+
.filter(Boolean)
|
|
5178
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
export function getPost(slug: string): Post | null {
|
|
5182
|
+
return read(slug);
|
|
5183
|
+
}
|
|
5184
|
+
`;
|
|
5185
|
+
var updatesListPage = () => `import Link from "next/link";
|
|
5186
|
+
import { site } from "@/lib/site";
|
|
5187
|
+
import { getAllPosts } from "@/lib/content";
|
|
5188
|
+
|
|
5189
|
+
export const metadata = site.meta({ title: "Updates", path: "/updates" });
|
|
5190
|
+
|
|
5191
|
+
export default function UpdatesPage() {
|
|
5192
|
+
const posts = getAllPosts();
|
|
5193
|
+
return (
|
|
5194
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5195
|
+
<h1 className="text-3xl font-bold">Updates</h1>
|
|
5196
|
+
<p className="mt-2 text-muted">News, notes and posts. <a href="/feed.xml" className="underline">RSS</a></p>
|
|
5197
|
+
{posts.length === 0 && (
|
|
5198
|
+
<p className="mt-8 text-muted">No posts yet \u2014 add a markdown file in <code>content/updates/</code>.</p>
|
|
5199
|
+
)}
|
|
5200
|
+
<ul className="mt-8 space-y-6">
|
|
5201
|
+
{posts.map((p) => (
|
|
5202
|
+
<li key={p.slug} className="border-b border-hairline pb-6">
|
|
5203
|
+
<Link href={\`/updates/\${p.slug}\`} className="text-xl font-semibold hover:underline">{p.title}</Link>
|
|
5204
|
+
{p.date && <p className="mt-1 text-sm text-muted">{p.date}</p>}
|
|
5205
|
+
<p className="mt-2 text-muted">{p.description}</p>
|
|
5206
|
+
</li>
|
|
5207
|
+
))}
|
|
5208
|
+
</ul>
|
|
5209
|
+
</main>
|
|
5210
|
+
);
|
|
5211
|
+
}
|
|
5212
|
+
`;
|
|
5213
|
+
var updatePostPage = () => `import Link from "next/link";
|
|
5214
|
+
import { notFound } from "next/navigation";
|
|
5215
|
+
import { site } from "@/lib/site";
|
|
5216
|
+
import { getAllPosts, getPost } from "@/lib/content";
|
|
5217
|
+
|
|
5218
|
+
export function generateStaticParams() {
|
|
5219
|
+
return getAllPosts().map((p) => ({ slug: p.slug }));
|
|
5220
|
+
}
|
|
5221
|
+
|
|
5222
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
5223
|
+
const { slug } = await params;
|
|
5224
|
+
const post = getPost(slug);
|
|
5225
|
+
return site.meta({ title: post?.title ?? "Updates", path: \`/updates/\${slug}\` });
|
|
5226
|
+
}
|
|
5227
|
+
|
|
5228
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
5229
|
+
const { slug } = await params;
|
|
5230
|
+
const post = getPost(slug);
|
|
5231
|
+
if (!post) notFound();
|
|
5232
|
+
return (
|
|
5233
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5234
|
+
<Link href="/updates" className="text-sm text-muted hover:underline">\u2190 Updates</Link>
|
|
5235
|
+
<h1 className="mt-4 text-3xl font-bold">{post.title}</h1>
|
|
5236
|
+
{post.date && <p className="mt-1 text-sm text-muted">{post.date}</p>}
|
|
5237
|
+
<article
|
|
5238
|
+
className="mt-8 leading-7 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_p]:mt-4 [&_p]:text-muted [&_a]:underline [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:mt-1 [&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-surface [&_pre]:p-4 [&_pre]:text-sm [&_code]:text-sm"
|
|
5239
|
+
dangerouslySetInnerHTML={{ __html: post.html }}
|
|
5240
|
+
/>
|
|
5241
|
+
</main>
|
|
5242
|
+
);
|
|
5243
|
+
}
|
|
5244
|
+
`;
|
|
5245
|
+
var feedRoute = () => `import { rssResponse } from "@lacspace/rss";
|
|
5246
|
+
import { getAllPosts } from "@/lib/content";
|
|
5247
|
+
|
|
5248
|
+
export const dynamic = "force-static";
|
|
5249
|
+
|
|
5250
|
+
export function GET() {
|
|
5251
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5252
|
+
const items = getAllPosts().map((p) => ({
|
|
5253
|
+
title: p.title,
|
|
5254
|
+
link: base + "/updates/" + p.slug,
|
|
5255
|
+
description: p.description,
|
|
5256
|
+
// Only pass a date the feed can parse (rfc822Date throws on bad input).
|
|
5257
|
+
date: p.date && !Number.isNaN(Date.parse(p.date)) ? p.date : undefined,
|
|
5258
|
+
}));
|
|
5259
|
+
return rssResponse(
|
|
5260
|
+
{ title: "Updates", link: base, description: "The latest posts and updates.", feedUrl: base + "/feed.xml" },
|
|
5261
|
+
items,
|
|
5262
|
+
);
|
|
5263
|
+
}
|
|
5264
|
+
`;
|
|
5265
|
+
var llmsRoute = () => `import { llmsTxtFromPages } from "@lacspace/llms-txt";
|
|
5266
|
+
import { getAllPosts } from "@/lib/content";
|
|
5267
|
+
|
|
5268
|
+
export const dynamic = "force-static";
|
|
5269
|
+
|
|
5270
|
+
export function GET() {
|
|
5271
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5272
|
+
const txt = llmsTxtFromPages(
|
|
5273
|
+
getAllPosts().map((p) => ({ title: p.title, url: base + "/updates/" + p.slug, content: p.description, section: "Updates" })),
|
|
5274
|
+
{ title: "Updates", summary: "A content index for large language models." },
|
|
5275
|
+
);
|
|
5276
|
+
return new Response(txt, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5277
|
+
}
|
|
5278
|
+
`;
|
|
5279
|
+
var contentSampleWelcome = (ctx) => `---
|
|
5280
|
+
title: Welcome to ${ctx.template.siteName}'s content section
|
|
5281
|
+
date: 2026-01-15
|
|
5282
|
+
description: This markdown file became a page automatically \u2014 here's how it works.
|
|
5283
|
+
---
|
|
5284
|
+
|
|
5285
|
+
## It just works
|
|
5286
|
+
|
|
5287
|
+
Drop any \\\`.md\\\` file into \\\`content/updates/\\\` and it becomes a page at \\\`/updates/<filename>\\\`, listed on \\\`/updates\\\`, and included in \\\`/feed.xml\\\` and \\\`/llms.txt\\\`.
|
|
5288
|
+
|
|
5289
|
+
- Frontmatter \\\`title\\\`, \\\`date\\\` and \\\`description\\\` are read automatically.
|
|
5290
|
+
- The body is rendered safely with \\\`@lacspace/markdown\\\` \u2014 raw HTML is escaped, so it's XSS-safe.
|
|
5291
|
+
- Delete this file when you're ready to write your own.
|
|
5292
|
+
`;
|
|
5293
|
+
var contentSampleSecond = () => `---
|
|
5294
|
+
title: Building in public
|
|
5295
|
+
date: 2026-01-10
|
|
5296
|
+
description: A second example post so the list and feed have something to show.
|
|
5297
|
+
---
|
|
5298
|
+
|
|
5299
|
+
## Why a content section?
|
|
5300
|
+
|
|
5301
|
+
Every product needs a place for changelog posts, announcements and notes. This one is
|
|
5302
|
+
Markdown-powered, needs no database, and ships an RSS feed and an \\\`llms.txt\\\` for free.
|
|
5303
|
+
|
|
5304
|
+
### Add your own
|
|
5305
|
+
|
|
5306
|
+
1. Create \\\`content/updates/my-post.md\\\`.
|
|
5307
|
+
2. Add frontmatter (\\\`title\\\`, \\\`date\\\`, \\\`description\\\`).
|
|
5308
|
+
3. Write. That's it \u2014 it appears at \\\`/updates/my-post\\\`.
|
|
5309
|
+
`;
|
|
5310
|
+
var searchRoute = () => `import fs from "node:fs";
|
|
5311
|
+
import path from "node:path";
|
|
5312
|
+
import { parseFrontmatter, toPlainText, excerpt } from "@lacspace/markdown";
|
|
5313
|
+
import { rerank } from "@lacspace/rerank";
|
|
5314
|
+
|
|
5315
|
+
// how this works: full-text search with BM25 ranking \u2014 no external service, no API
|
|
5316
|
+
// key, no Ollama. It scans your markdown at request time and ranks matches. (For a
|
|
5317
|
+
// big site, precompute the corpus at build time and cache it instead.)
|
|
5318
|
+
export const runtime = "nodejs";
|
|
5319
|
+
export const dynamic = "force-dynamic";
|
|
5320
|
+
|
|
5321
|
+
const ROOT = path.join(process.cwd(), "content");
|
|
5322
|
+
// Map a content/<dir>/ to the route it renders under.
|
|
5323
|
+
const SECTION: Record<string, string> = { updates: "/updates", posts: "/blog", docs: "/docs" };
|
|
5324
|
+
|
|
5325
|
+
interface Entry { id: string; title: string; url: string; text: string; }
|
|
5326
|
+
|
|
5327
|
+
function corpus(): Entry[] {
|
|
5328
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
5329
|
+
const out: Entry[] = [];
|
|
5330
|
+
for (const dir of fs.readdirSync(ROOT)) {
|
|
5331
|
+
const full = path.join(ROOT, dir);
|
|
5332
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
5333
|
+
const base = SECTION[dir] ?? "/" + dir;
|
|
5334
|
+
for (const file of fs.readdirSync(full)) {
|
|
5335
|
+
if (!file.endsWith(".md")) continue;
|
|
5336
|
+
const slug = file.replace(/\\.md$/, "");
|
|
5337
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(path.join(full, file), "utf8"));
|
|
5338
|
+
out.push({ id: dir + "/" + slug, title: String(data.title ?? slug), url: base + "/" + slug, text: toPlainText(content) });
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
return out;
|
|
5342
|
+
}
|
|
5343
|
+
|
|
5344
|
+
export async function GET(req: Request) {
|
|
5345
|
+
const q = new URL(req.url).searchParams.get("q")?.trim() ?? "";
|
|
5346
|
+
if (!q) return Response.json([]);
|
|
5347
|
+
const docs = corpus().map((e) => ({ id: e.id, text: e.title + "\\n" + e.text, metadata: { title: e.title, url: e.url } }));
|
|
5348
|
+
const hits = await rerank(q, docs, { method: "bm25", k: 8 });
|
|
5349
|
+
return Response.json(hits.map((h) => ({
|
|
5350
|
+
title: String(h.metadata?.title ?? ""),
|
|
5351
|
+
url: String(h.metadata?.url ?? "#"),
|
|
5352
|
+
snippet: excerpt(h.text.split("\\n").slice(1).join(" "), { length: 140 }),
|
|
5353
|
+
})));
|
|
5354
|
+
}
|
|
5355
|
+
`;
|
|
5356
|
+
var searchBox = () => `"use client";
|
|
5357
|
+
import { useState, useEffect, useRef } from "react";
|
|
5358
|
+
|
|
5359
|
+
interface Result { title: string; url: string; snippet: string; }
|
|
5360
|
+
|
|
5361
|
+
export function Search() {
|
|
5362
|
+
const [q, setQ] = useState("");
|
|
5363
|
+
const [results, setResults] = useState<Result[]>([]);
|
|
5364
|
+
const [open, setOpen] = useState(false);
|
|
5365
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
5366
|
+
|
|
5367
|
+
useEffect(() => {
|
|
5368
|
+
if (timer.current) clearTimeout(timer.current);
|
|
5369
|
+
if (!q.trim()) { setResults([]); return; }
|
|
5370
|
+
timer.current = setTimeout(async () => {
|
|
5371
|
+
const res = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
5372
|
+
setResults(await res.json());
|
|
5373
|
+
setOpen(true);
|
|
5374
|
+
}, 200);
|
|
5375
|
+
return () => { if (timer.current) clearTimeout(timer.current); };
|
|
5376
|
+
}, [q]);
|
|
5377
|
+
|
|
5378
|
+
return (
|
|
5379
|
+
<div className="relative w-full max-w-md">
|
|
5380
|
+
<input
|
|
5381
|
+
value={q}
|
|
5382
|
+
onChange={(e) => setQ(e.target.value)}
|
|
5383
|
+
onFocus={() => { if (results.length) setOpen(true); }}
|
|
5384
|
+
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
5385
|
+
placeholder="Search\u2026"
|
|
5386
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none"
|
|
5387
|
+
/>
|
|
5388
|
+
{open && results.length > 0 && (
|
|
5389
|
+
<ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-hairline bg-app shadow-lg">
|
|
5390
|
+
{results.map((r) => (
|
|
5391
|
+
<li key={r.url}>
|
|
5392
|
+
<a href={r.url} className="block px-4 py-3 transition hover:bg-surface">
|
|
5393
|
+
<p className="font-medium">{r.title}</p>
|
|
5394
|
+
<p className="mt-0.5 text-sm text-muted">{r.snippet}</p>
|
|
5395
|
+
</a>
|
|
5396
|
+
</li>
|
|
5397
|
+
))}
|
|
5398
|
+
</ul>
|
|
5399
|
+
)}
|
|
5400
|
+
</div>
|
|
5401
|
+
);
|
|
5402
|
+
}
|
|
5403
|
+
`;
|
|
5404
|
+
var searchPage = () => `import { site } from "@/lib/site";
|
|
5405
|
+
import { Search } from "@/components/search";
|
|
5406
|
+
|
|
5407
|
+
export const metadata = site.meta({ title: "Search", path: "/search" });
|
|
5408
|
+
|
|
5409
|
+
export default function SearchPage() {
|
|
5410
|
+
return (
|
|
5411
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5412
|
+
<h1 className="text-3xl font-bold">Search</h1>
|
|
5413
|
+
<p className="mt-2 text-muted">Instant, keyless full-text search over your content.</p>
|
|
5414
|
+
<div className="mt-8"><Search /></div>
|
|
5415
|
+
</main>
|
|
5416
|
+
);
|
|
5417
|
+
}
|
|
5418
|
+
`;
|
|
5102
5419
|
var scope = (ctx) => `@${ctx.name}`;
|
|
5103
5420
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5104
5421
|
name: `${scope(ctx)}/types`,
|
package/dist/lib.js
CHANGED
|
@@ -2118,6 +2118,51 @@ var FEATURES = [
|
|
|
2118
2118
|
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2119
2119
|
],
|
|
2120
2120
|
learn: "https://developer.lacspace.com/packages/rag"
|
|
2121
|
+
},
|
|
2122
|
+
{
|
|
2123
|
+
key: "content",
|
|
2124
|
+
label: "Content / blog",
|
|
2125
|
+
description: "A markdown content section (/updates) for any template \u2014 plus an RSS feed and an llms.txt. Drop in .md files, get pages.",
|
|
2126
|
+
deps: {
|
|
2127
|
+
"@lacspace/markdown": "^1.1.0",
|
|
2128
|
+
"@lacspace/rss": "^1.4.0",
|
|
2129
|
+
"@lacspace/llms-txt": "^1.4.0"
|
|
2130
|
+
},
|
|
2131
|
+
files: (ctx) => ({
|
|
2132
|
+
"content/updates/welcome.md": contentSampleWelcome(ctx),
|
|
2133
|
+
"content/updates/building-in-public.md": contentSampleSecond(),
|
|
2134
|
+
"lib/content.ts": contentLib(),
|
|
2135
|
+
"app/updates/page.tsx": updatesListPage(),
|
|
2136
|
+
"app/updates/[slug]/page.tsx": updatePostPage(),
|
|
2137
|
+
"app/feed.xml/route.ts": feedRoute(),
|
|
2138
|
+
"app/llms.txt/route.ts": llmsRoute()
|
|
2139
|
+
}),
|
|
2140
|
+
nextSteps: [
|
|
2141
|
+
"Add markdown files to content/updates/ \u2014 each becomes a page at /updates/<name>.",
|
|
2142
|
+
"Run `npm run dev` and open http://localhost:3000/updates, /feed.xml and /llms.txt.",
|
|
2143
|
+
"Set NEXT_PUBLIC_SITE_URL in .env so the feed and llms.txt links are absolute."
|
|
2144
|
+
],
|
|
2145
|
+
learn: "https://developer.lacspace.com/packages/markdown"
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
key: "search",
|
|
2149
|
+
label: "Search",
|
|
2150
|
+
description: "Instant, keyless full-text search (BM25) over your markdown content \u2014 a search box + /search page + API route. No key, no service.",
|
|
2151
|
+
deps: {
|
|
2152
|
+
"@lacspace/rerank": "^1.0.0",
|
|
2153
|
+
"@lacspace/markdown": "^1.1.0"
|
|
2154
|
+
},
|
|
2155
|
+
files: () => ({
|
|
2156
|
+
"app/api/search/route.ts": searchRoute(),
|
|
2157
|
+
"components/search.tsx": searchBox(),
|
|
2158
|
+
"app/search/page.tsx": searchPage()
|
|
2159
|
+
}),
|
|
2160
|
+
nextSteps: [
|
|
2161
|
+
"Add markdown to content/ (e.g. the `content` add-on's content/updates/) \u2014 search indexes it automatically.",
|
|
2162
|
+
"Run `npm run dev` and open http://localhost:3000/search, or drop <Search /> into your header.",
|
|
2163
|
+
"Want semantic search? Upgrade to @lacspace/embeddings + @lacspace/vector (keyless-local via Ollama)."
|
|
2164
|
+
],
|
|
2165
|
+
learn: "https://developer.lacspace.com/packages/rerank"
|
|
2121
2166
|
}
|
|
2122
2167
|
];
|
|
2123
2168
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -5096,6 +5141,278 @@ var faqSection = (ctx) => {
|
|
|
5096
5141
|
</div>
|
|
5097
5142
|
</section>`;
|
|
5098
5143
|
};
|
|
5144
|
+
var contentLib = () => `import fs from "node:fs";
|
|
5145
|
+
import path from "node:path";
|
|
5146
|
+
import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
|
|
5147
|
+
|
|
5148
|
+
// how this works: every .md file in content/updates/ becomes a post. Frontmatter
|
|
5149
|
+
// (title/date/description) is read with @lacspace/markdown; the body is rendered to
|
|
5150
|
+
// safe HTML (raw HTML in the source is escaped, so this is XSS-safe by construction).
|
|
5151
|
+
const DIR = path.join(process.cwd(), "content/updates");
|
|
5152
|
+
|
|
5153
|
+
export interface PostMeta { slug: string; title: string; date: string; description: string; }
|
|
5154
|
+
export interface Post extends PostMeta { html: string; }
|
|
5155
|
+
|
|
5156
|
+
function read(slug: string): Post | null {
|
|
5157
|
+
const file = path.join(DIR, slug + ".md");
|
|
5158
|
+
if (!fs.existsSync(file)) return null;
|
|
5159
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
5160
|
+
return {
|
|
5161
|
+
slug,
|
|
5162
|
+
title: String(data.title ?? slug),
|
|
5163
|
+
date: String(data.date ?? ""),
|
|
5164
|
+
description: String(data.description ?? excerpt(content, { length: 160 })),
|
|
5165
|
+
html: markdownToHtml(content, { headingIds: true, openLinksInNewTab: true }),
|
|
5166
|
+
};
|
|
5167
|
+
}
|
|
5168
|
+
|
|
5169
|
+
export function getAllPosts(): PostMeta[] {
|
|
5170
|
+
if (!fs.existsSync(DIR)) return [];
|
|
5171
|
+
return fs.readdirSync(DIR)
|
|
5172
|
+
.filter((f) => f.endsWith(".md"))
|
|
5173
|
+
.map((f) => read(f.replace(/\\.md$/, ""))!)
|
|
5174
|
+
.filter(Boolean)
|
|
5175
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
5176
|
+
}
|
|
5177
|
+
|
|
5178
|
+
export function getPost(slug: string): Post | null {
|
|
5179
|
+
return read(slug);
|
|
5180
|
+
}
|
|
5181
|
+
`;
|
|
5182
|
+
var updatesListPage = () => `import Link from "next/link";
|
|
5183
|
+
import { site } from "@/lib/site";
|
|
5184
|
+
import { getAllPosts } from "@/lib/content";
|
|
5185
|
+
|
|
5186
|
+
export const metadata = site.meta({ title: "Updates", path: "/updates" });
|
|
5187
|
+
|
|
5188
|
+
export default function UpdatesPage() {
|
|
5189
|
+
const posts = getAllPosts();
|
|
5190
|
+
return (
|
|
5191
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5192
|
+
<h1 className="text-3xl font-bold">Updates</h1>
|
|
5193
|
+
<p className="mt-2 text-muted">News, notes and posts. <a href="/feed.xml" className="underline">RSS</a></p>
|
|
5194
|
+
{posts.length === 0 && (
|
|
5195
|
+
<p className="mt-8 text-muted">No posts yet \u2014 add a markdown file in <code>content/updates/</code>.</p>
|
|
5196
|
+
)}
|
|
5197
|
+
<ul className="mt-8 space-y-6">
|
|
5198
|
+
{posts.map((p) => (
|
|
5199
|
+
<li key={p.slug} className="border-b border-hairline pb-6">
|
|
5200
|
+
<Link href={\`/updates/\${p.slug}\`} className="text-xl font-semibold hover:underline">{p.title}</Link>
|
|
5201
|
+
{p.date && <p className="mt-1 text-sm text-muted">{p.date}</p>}
|
|
5202
|
+
<p className="mt-2 text-muted">{p.description}</p>
|
|
5203
|
+
</li>
|
|
5204
|
+
))}
|
|
5205
|
+
</ul>
|
|
5206
|
+
</main>
|
|
5207
|
+
);
|
|
5208
|
+
}
|
|
5209
|
+
`;
|
|
5210
|
+
var updatePostPage = () => `import Link from "next/link";
|
|
5211
|
+
import { notFound } from "next/navigation";
|
|
5212
|
+
import { site } from "@/lib/site";
|
|
5213
|
+
import { getAllPosts, getPost } from "@/lib/content";
|
|
5214
|
+
|
|
5215
|
+
export function generateStaticParams() {
|
|
5216
|
+
return getAllPosts().map((p) => ({ slug: p.slug }));
|
|
5217
|
+
}
|
|
5218
|
+
|
|
5219
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
5220
|
+
const { slug } = await params;
|
|
5221
|
+
const post = getPost(slug);
|
|
5222
|
+
return site.meta({ title: post?.title ?? "Updates", path: \`/updates/\${slug}\` });
|
|
5223
|
+
}
|
|
5224
|
+
|
|
5225
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
5226
|
+
const { slug } = await params;
|
|
5227
|
+
const post = getPost(slug);
|
|
5228
|
+
if (!post) notFound();
|
|
5229
|
+
return (
|
|
5230
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5231
|
+
<Link href="/updates" className="text-sm text-muted hover:underline">\u2190 Updates</Link>
|
|
5232
|
+
<h1 className="mt-4 text-3xl font-bold">{post.title}</h1>
|
|
5233
|
+
{post.date && <p className="mt-1 text-sm text-muted">{post.date}</p>}
|
|
5234
|
+
<article
|
|
5235
|
+
className="mt-8 leading-7 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_p]:mt-4 [&_p]:text-muted [&_a]:underline [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:mt-1 [&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-surface [&_pre]:p-4 [&_pre]:text-sm [&_code]:text-sm"
|
|
5236
|
+
dangerouslySetInnerHTML={{ __html: post.html }}
|
|
5237
|
+
/>
|
|
5238
|
+
</main>
|
|
5239
|
+
);
|
|
5240
|
+
}
|
|
5241
|
+
`;
|
|
5242
|
+
var feedRoute = () => `import { rssResponse } from "@lacspace/rss";
|
|
5243
|
+
import { getAllPosts } from "@/lib/content";
|
|
5244
|
+
|
|
5245
|
+
export const dynamic = "force-static";
|
|
5246
|
+
|
|
5247
|
+
export function GET() {
|
|
5248
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5249
|
+
const items = getAllPosts().map((p) => ({
|
|
5250
|
+
title: p.title,
|
|
5251
|
+
link: base + "/updates/" + p.slug,
|
|
5252
|
+
description: p.description,
|
|
5253
|
+
// Only pass a date the feed can parse (rfc822Date throws on bad input).
|
|
5254
|
+
date: p.date && !Number.isNaN(Date.parse(p.date)) ? p.date : undefined,
|
|
5255
|
+
}));
|
|
5256
|
+
return rssResponse(
|
|
5257
|
+
{ title: "Updates", link: base, description: "The latest posts and updates.", feedUrl: base + "/feed.xml" },
|
|
5258
|
+
items,
|
|
5259
|
+
);
|
|
5260
|
+
}
|
|
5261
|
+
`;
|
|
5262
|
+
var llmsRoute = () => `import { llmsTxtFromPages } from "@lacspace/llms-txt";
|
|
5263
|
+
import { getAllPosts } from "@/lib/content";
|
|
5264
|
+
|
|
5265
|
+
export const dynamic = "force-static";
|
|
5266
|
+
|
|
5267
|
+
export function GET() {
|
|
5268
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5269
|
+
const txt = llmsTxtFromPages(
|
|
5270
|
+
getAllPosts().map((p) => ({ title: p.title, url: base + "/updates/" + p.slug, content: p.description, section: "Updates" })),
|
|
5271
|
+
{ title: "Updates", summary: "A content index for large language models." },
|
|
5272
|
+
);
|
|
5273
|
+
return new Response(txt, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5274
|
+
}
|
|
5275
|
+
`;
|
|
5276
|
+
var contentSampleWelcome = (ctx) => `---
|
|
5277
|
+
title: Welcome to ${ctx.template.siteName}'s content section
|
|
5278
|
+
date: 2026-01-15
|
|
5279
|
+
description: This markdown file became a page automatically \u2014 here's how it works.
|
|
5280
|
+
---
|
|
5281
|
+
|
|
5282
|
+
## It just works
|
|
5283
|
+
|
|
5284
|
+
Drop any \\\`.md\\\` file into \\\`content/updates/\\\` and it becomes a page at \\\`/updates/<filename>\\\`, listed on \\\`/updates\\\`, and included in \\\`/feed.xml\\\` and \\\`/llms.txt\\\`.
|
|
5285
|
+
|
|
5286
|
+
- Frontmatter \\\`title\\\`, \\\`date\\\` and \\\`description\\\` are read automatically.
|
|
5287
|
+
- The body is rendered safely with \\\`@lacspace/markdown\\\` \u2014 raw HTML is escaped, so it's XSS-safe.
|
|
5288
|
+
- Delete this file when you're ready to write your own.
|
|
5289
|
+
`;
|
|
5290
|
+
var contentSampleSecond = () => `---
|
|
5291
|
+
title: Building in public
|
|
5292
|
+
date: 2026-01-10
|
|
5293
|
+
description: A second example post so the list and feed have something to show.
|
|
5294
|
+
---
|
|
5295
|
+
|
|
5296
|
+
## Why a content section?
|
|
5297
|
+
|
|
5298
|
+
Every product needs a place for changelog posts, announcements and notes. This one is
|
|
5299
|
+
Markdown-powered, needs no database, and ships an RSS feed and an \\\`llms.txt\\\` for free.
|
|
5300
|
+
|
|
5301
|
+
### Add your own
|
|
5302
|
+
|
|
5303
|
+
1. Create \\\`content/updates/my-post.md\\\`.
|
|
5304
|
+
2. Add frontmatter (\\\`title\\\`, \\\`date\\\`, \\\`description\\\`).
|
|
5305
|
+
3. Write. That's it \u2014 it appears at \\\`/updates/my-post\\\`.
|
|
5306
|
+
`;
|
|
5307
|
+
var searchRoute = () => `import fs from "node:fs";
|
|
5308
|
+
import path from "node:path";
|
|
5309
|
+
import { parseFrontmatter, toPlainText, excerpt } from "@lacspace/markdown";
|
|
5310
|
+
import { rerank } from "@lacspace/rerank";
|
|
5311
|
+
|
|
5312
|
+
// how this works: full-text search with BM25 ranking \u2014 no external service, no API
|
|
5313
|
+
// key, no Ollama. It scans your markdown at request time and ranks matches. (For a
|
|
5314
|
+
// big site, precompute the corpus at build time and cache it instead.)
|
|
5315
|
+
export const runtime = "nodejs";
|
|
5316
|
+
export const dynamic = "force-dynamic";
|
|
5317
|
+
|
|
5318
|
+
const ROOT = path.join(process.cwd(), "content");
|
|
5319
|
+
// Map a content/<dir>/ to the route it renders under.
|
|
5320
|
+
const SECTION: Record<string, string> = { updates: "/updates", posts: "/blog", docs: "/docs" };
|
|
5321
|
+
|
|
5322
|
+
interface Entry { id: string; title: string; url: string; text: string; }
|
|
5323
|
+
|
|
5324
|
+
function corpus(): Entry[] {
|
|
5325
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
5326
|
+
const out: Entry[] = [];
|
|
5327
|
+
for (const dir of fs.readdirSync(ROOT)) {
|
|
5328
|
+
const full = path.join(ROOT, dir);
|
|
5329
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
5330
|
+
const base = SECTION[dir] ?? "/" + dir;
|
|
5331
|
+
for (const file of fs.readdirSync(full)) {
|
|
5332
|
+
if (!file.endsWith(".md")) continue;
|
|
5333
|
+
const slug = file.replace(/\\.md$/, "");
|
|
5334
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(path.join(full, file), "utf8"));
|
|
5335
|
+
out.push({ id: dir + "/" + slug, title: String(data.title ?? slug), url: base + "/" + slug, text: toPlainText(content) });
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
return out;
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
export async function GET(req: Request) {
|
|
5342
|
+
const q = new URL(req.url).searchParams.get("q")?.trim() ?? "";
|
|
5343
|
+
if (!q) return Response.json([]);
|
|
5344
|
+
const docs = corpus().map((e) => ({ id: e.id, text: e.title + "\\n" + e.text, metadata: { title: e.title, url: e.url } }));
|
|
5345
|
+
const hits = await rerank(q, docs, { method: "bm25", k: 8 });
|
|
5346
|
+
return Response.json(hits.map((h) => ({
|
|
5347
|
+
title: String(h.metadata?.title ?? ""),
|
|
5348
|
+
url: String(h.metadata?.url ?? "#"),
|
|
5349
|
+
snippet: excerpt(h.text.split("\\n").slice(1).join(" "), { length: 140 }),
|
|
5350
|
+
})));
|
|
5351
|
+
}
|
|
5352
|
+
`;
|
|
5353
|
+
var searchBox = () => `"use client";
|
|
5354
|
+
import { useState, useEffect, useRef } from "react";
|
|
5355
|
+
|
|
5356
|
+
interface Result { title: string; url: string; snippet: string; }
|
|
5357
|
+
|
|
5358
|
+
export function Search() {
|
|
5359
|
+
const [q, setQ] = useState("");
|
|
5360
|
+
const [results, setResults] = useState<Result[]>([]);
|
|
5361
|
+
const [open, setOpen] = useState(false);
|
|
5362
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
5363
|
+
|
|
5364
|
+
useEffect(() => {
|
|
5365
|
+
if (timer.current) clearTimeout(timer.current);
|
|
5366
|
+
if (!q.trim()) { setResults([]); return; }
|
|
5367
|
+
timer.current = setTimeout(async () => {
|
|
5368
|
+
const res = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
5369
|
+
setResults(await res.json());
|
|
5370
|
+
setOpen(true);
|
|
5371
|
+
}, 200);
|
|
5372
|
+
return () => { if (timer.current) clearTimeout(timer.current); };
|
|
5373
|
+
}, [q]);
|
|
5374
|
+
|
|
5375
|
+
return (
|
|
5376
|
+
<div className="relative w-full max-w-md">
|
|
5377
|
+
<input
|
|
5378
|
+
value={q}
|
|
5379
|
+
onChange={(e) => setQ(e.target.value)}
|
|
5380
|
+
onFocus={() => { if (results.length) setOpen(true); }}
|
|
5381
|
+
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
5382
|
+
placeholder="Search\u2026"
|
|
5383
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none"
|
|
5384
|
+
/>
|
|
5385
|
+
{open && results.length > 0 && (
|
|
5386
|
+
<ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-hairline bg-app shadow-lg">
|
|
5387
|
+
{results.map((r) => (
|
|
5388
|
+
<li key={r.url}>
|
|
5389
|
+
<a href={r.url} className="block px-4 py-3 transition hover:bg-surface">
|
|
5390
|
+
<p className="font-medium">{r.title}</p>
|
|
5391
|
+
<p className="mt-0.5 text-sm text-muted">{r.snippet}</p>
|
|
5392
|
+
</a>
|
|
5393
|
+
</li>
|
|
5394
|
+
))}
|
|
5395
|
+
</ul>
|
|
5396
|
+
)}
|
|
5397
|
+
</div>
|
|
5398
|
+
);
|
|
5399
|
+
}
|
|
5400
|
+
`;
|
|
5401
|
+
var searchPage = () => `import { site } from "@/lib/site";
|
|
5402
|
+
import { Search } from "@/components/search";
|
|
5403
|
+
|
|
5404
|
+
export const metadata = site.meta({ title: "Search", path: "/search" });
|
|
5405
|
+
|
|
5406
|
+
export default function SearchPage() {
|
|
5407
|
+
return (
|
|
5408
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5409
|
+
<h1 className="text-3xl font-bold">Search</h1>
|
|
5410
|
+
<p className="mt-2 text-muted">Instant, keyless full-text search over your content.</p>
|
|
5411
|
+
<div className="mt-8"><Search /></div>
|
|
5412
|
+
</main>
|
|
5413
|
+
);
|
|
5414
|
+
}
|
|
5415
|
+
`;
|
|
5099
5416
|
var scope = (ctx) => `@${ctx.name}`;
|
|
5100
5417
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5101
5418
|
name: `${scope(ctx)}/types`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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, restaurant or marketplace — pre-wired with SEO, security headers, sitemap and robots. Use it as a CLI (like create-next-app, but you start gorgeous) or import it as a library to generate projects programmatically.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|