create-lacspace-app 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/index.js +825 -49
- package/dist/lib.cjs +825 -49
- package/dist/lib.d.cts +34 -1
- package/dist/lib.d.ts +34 -1
- package/dist/lib.js +825 -49
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,7 +36,8 @@ function resolveContext(options = {}) {
|
|
|
36
36
|
const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
|
|
37
37
|
const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
|
|
38
38
|
const features = normalizeFeatures(options.features);
|
|
39
|
-
|
|
39
|
+
let mode = options.mode === "dynamic" ? "dynamic" : "static";
|
|
40
|
+
if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
|
|
40
41
|
return { name, template, features, mode };
|
|
41
42
|
}
|
|
42
43
|
function normalizeFeatures(requested) {
|
|
@@ -2117,6 +2118,97 @@ var FEATURES = [
|
|
|
2117
2118
|
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2118
2119
|
],
|
|
2119
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"
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
key: "auth-pages",
|
|
2169
|
+
label: "Auth & account",
|
|
2170
|
+
description: "Account management on top of the built-in login/register: edit profile, change password, and TOTP two-factor auth (2FA) with backup codes. Full-stack.",
|
|
2171
|
+
requiresBackend: true,
|
|
2172
|
+
deps: {},
|
|
2173
|
+
files: () => ({ "app/account/settings/page.tsx": authSettingsPage() }),
|
|
2174
|
+
backend: (ctx) => ({
|
|
2175
|
+
deps: { "@lacspace/otp": "^1.2.0" },
|
|
2176
|
+
files: {
|
|
2177
|
+
"src/models/two-factor.ts": authTwoFactorModel(),
|
|
2178
|
+
"src/routes/account.ts": authAccountRoutes(ctx)
|
|
2179
|
+
},
|
|
2180
|
+
routes: [{ path: "/account", handler: "accountRoutes", auth: true, importLine: 'import accountRoutes from "./account.js";' }]
|
|
2181
|
+
}),
|
|
2182
|
+
nextSteps: [
|
|
2183
|
+
"Sign in, then open http://localhost:3000/account/settings.",
|
|
2184
|
+
"Enable 2FA: add the shown secret to an authenticator app (Google Authenticator, Authy\u2026), then verify.",
|
|
2185
|
+
"Backup codes are shown once on enable \u2014 store them somewhere safe."
|
|
2186
|
+
],
|
|
2187
|
+
learn: "https://developer.lacspace.com/packages/otp"
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
key: "analytics",
|
|
2191
|
+
label: "Analytics",
|
|
2192
|
+
description: "Privacy-first, cookieless web analytics: a tracker (@lacspace/analytics-lite), a collector that stores events in MongoDB, and a dashboard. Full-stack.",
|
|
2193
|
+
requiresBackend: true,
|
|
2194
|
+
deps: { "@lacspace/analytics-lite": "^1.1.0" },
|
|
2195
|
+
files: (ctx) => ({
|
|
2196
|
+
"components/analytics.tsx": analyticsComponent(ctx),
|
|
2197
|
+
"app/analytics/page.tsx": analyticsDashboard()
|
|
2198
|
+
}),
|
|
2199
|
+
backend: () => ({
|
|
2200
|
+
files: {
|
|
2201
|
+
"src/models/event.ts": analyticsEventModel(),
|
|
2202
|
+
"src/routes/events.ts": analyticsEventsRoutes()
|
|
2203
|
+
},
|
|
2204
|
+
routes: [{ path: "/events", handler: "eventRoutes", auth: false, importLine: 'import eventRoutes from "./events.js";' }]
|
|
2205
|
+
}),
|
|
2206
|
+
nextSteps: [
|
|
2207
|
+
"Add <Analytics /> to app/layout.tsx (inside <body>) to start tracking page views.",
|
|
2208
|
+
"Browse your site, then open http://localhost:3000/analytics (sign in) to see the dashboard.",
|
|
2209
|
+
"It's cookieless and stores no personal data \u2014 privacy-first by default."
|
|
2210
|
+
],
|
|
2211
|
+
learn: "https://developer.lacspace.com/packages/analytics-lite"
|
|
2120
2212
|
}
|
|
2121
2213
|
];
|
|
2122
2214
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -5095,6 +5187,646 @@ var faqSection = (ctx) => {
|
|
|
5095
5187
|
</div>
|
|
5096
5188
|
</section>`;
|
|
5097
5189
|
};
|
|
5190
|
+
var contentLib = () => `import fs from "node:fs";
|
|
5191
|
+
import path from "node:path";
|
|
5192
|
+
import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
|
|
5193
|
+
|
|
5194
|
+
// how this works: every .md file in content/updates/ becomes a post. Frontmatter
|
|
5195
|
+
// (title/date/description) is read with @lacspace/markdown; the body is rendered to
|
|
5196
|
+
// safe HTML (raw HTML in the source is escaped, so this is XSS-safe by construction).
|
|
5197
|
+
const DIR = path.join(process.cwd(), "content/updates");
|
|
5198
|
+
|
|
5199
|
+
export interface PostMeta { slug: string; title: string; date: string; description: string; }
|
|
5200
|
+
export interface Post extends PostMeta { html: string; }
|
|
5201
|
+
|
|
5202
|
+
function read(slug: string): Post | null {
|
|
5203
|
+
const file = path.join(DIR, slug + ".md");
|
|
5204
|
+
if (!fs.existsSync(file)) return null;
|
|
5205
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(file, "utf8"));
|
|
5206
|
+
return {
|
|
5207
|
+
slug,
|
|
5208
|
+
title: String(data.title ?? slug),
|
|
5209
|
+
date: String(data.date ?? ""),
|
|
5210
|
+
description: String(data.description ?? excerpt(content, { length: 160 })),
|
|
5211
|
+
html: markdownToHtml(content, { headingIds: true, openLinksInNewTab: true }),
|
|
5212
|
+
};
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
export function getAllPosts(): PostMeta[] {
|
|
5216
|
+
if (!fs.existsSync(DIR)) return [];
|
|
5217
|
+
return fs.readdirSync(DIR)
|
|
5218
|
+
.filter((f) => f.endsWith(".md"))
|
|
5219
|
+
.map((f) => read(f.replace(/\\.md$/, ""))!)
|
|
5220
|
+
.filter(Boolean)
|
|
5221
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
5222
|
+
}
|
|
5223
|
+
|
|
5224
|
+
export function getPost(slug: string): Post | null {
|
|
5225
|
+
return read(slug);
|
|
5226
|
+
}
|
|
5227
|
+
`;
|
|
5228
|
+
var updatesListPage = () => `import Link from "next/link";
|
|
5229
|
+
import { site } from "@/lib/site";
|
|
5230
|
+
import { getAllPosts } from "@/lib/content";
|
|
5231
|
+
|
|
5232
|
+
export const metadata = site.meta({ title: "Updates", path: "/updates" });
|
|
5233
|
+
|
|
5234
|
+
export default function UpdatesPage() {
|
|
5235
|
+
const posts = getAllPosts();
|
|
5236
|
+
return (
|
|
5237
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5238
|
+
<h1 className="text-3xl font-bold">Updates</h1>
|
|
5239
|
+
<p className="mt-2 text-muted">News, notes and posts. <a href="/feed.xml" className="underline">RSS</a></p>
|
|
5240
|
+
{posts.length === 0 && (
|
|
5241
|
+
<p className="mt-8 text-muted">No posts yet \u2014 add a markdown file in <code>content/updates/</code>.</p>
|
|
5242
|
+
)}
|
|
5243
|
+
<ul className="mt-8 space-y-6">
|
|
5244
|
+
{posts.map((p) => (
|
|
5245
|
+
<li key={p.slug} className="border-b border-hairline pb-6">
|
|
5246
|
+
<Link href={\`/updates/\${p.slug}\`} className="text-xl font-semibold hover:underline">{p.title}</Link>
|
|
5247
|
+
{p.date && <p className="mt-1 text-sm text-muted">{p.date}</p>}
|
|
5248
|
+
<p className="mt-2 text-muted">{p.description}</p>
|
|
5249
|
+
</li>
|
|
5250
|
+
))}
|
|
5251
|
+
</ul>
|
|
5252
|
+
</main>
|
|
5253
|
+
);
|
|
5254
|
+
}
|
|
5255
|
+
`;
|
|
5256
|
+
var updatePostPage = () => `import Link from "next/link";
|
|
5257
|
+
import { notFound } from "next/navigation";
|
|
5258
|
+
import { site } from "@/lib/site";
|
|
5259
|
+
import { getAllPosts, getPost } from "@/lib/content";
|
|
5260
|
+
|
|
5261
|
+
export function generateStaticParams() {
|
|
5262
|
+
return getAllPosts().map((p) => ({ slug: p.slug }));
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
5266
|
+
const { slug } = await params;
|
|
5267
|
+
const post = getPost(slug);
|
|
5268
|
+
return site.meta({ title: post?.title ?? "Updates", path: \`/updates/\${slug}\` });
|
|
5269
|
+
}
|
|
5270
|
+
|
|
5271
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
5272
|
+
const { slug } = await params;
|
|
5273
|
+
const post = getPost(slug);
|
|
5274
|
+
if (!post) notFound();
|
|
5275
|
+
return (
|
|
5276
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5277
|
+
<Link href="/updates" className="text-sm text-muted hover:underline">\u2190 Updates</Link>
|
|
5278
|
+
<h1 className="mt-4 text-3xl font-bold">{post.title}</h1>
|
|
5279
|
+
{post.date && <p className="mt-1 text-sm text-muted">{post.date}</p>}
|
|
5280
|
+
<article
|
|
5281
|
+
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"
|
|
5282
|
+
dangerouslySetInnerHTML={{ __html: post.html }}
|
|
5283
|
+
/>
|
|
5284
|
+
</main>
|
|
5285
|
+
);
|
|
5286
|
+
}
|
|
5287
|
+
`;
|
|
5288
|
+
var feedRoute = () => `import { rssResponse } from "@lacspace/rss";
|
|
5289
|
+
import { getAllPosts } from "@/lib/content";
|
|
5290
|
+
|
|
5291
|
+
export const dynamic = "force-static";
|
|
5292
|
+
|
|
5293
|
+
export function GET() {
|
|
5294
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5295
|
+
const items = getAllPosts().map((p) => ({
|
|
5296
|
+
title: p.title,
|
|
5297
|
+
link: base + "/updates/" + p.slug,
|
|
5298
|
+
description: p.description,
|
|
5299
|
+
// Only pass a date the feed can parse (rfc822Date throws on bad input).
|
|
5300
|
+
date: p.date && !Number.isNaN(Date.parse(p.date)) ? p.date : undefined,
|
|
5301
|
+
}));
|
|
5302
|
+
return rssResponse(
|
|
5303
|
+
{ title: "Updates", link: base, description: "The latest posts and updates.", feedUrl: base + "/feed.xml" },
|
|
5304
|
+
items,
|
|
5305
|
+
);
|
|
5306
|
+
}
|
|
5307
|
+
`;
|
|
5308
|
+
var llmsRoute = () => `import { llmsTxtFromPages } from "@lacspace/llms-txt";
|
|
5309
|
+
import { getAllPosts } from "@/lib/content";
|
|
5310
|
+
|
|
5311
|
+
export const dynamic = "force-static";
|
|
5312
|
+
|
|
5313
|
+
export function GET() {
|
|
5314
|
+
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
|
5315
|
+
const txt = llmsTxtFromPages(
|
|
5316
|
+
getAllPosts().map((p) => ({ title: p.title, url: base + "/updates/" + p.slug, content: p.description, section: "Updates" })),
|
|
5317
|
+
{ title: "Updates", summary: "A content index for large language models." },
|
|
5318
|
+
);
|
|
5319
|
+
return new Response(txt, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5320
|
+
}
|
|
5321
|
+
`;
|
|
5322
|
+
var contentSampleWelcome = (ctx) => `---
|
|
5323
|
+
title: Welcome to ${ctx.template.siteName}'s content section
|
|
5324
|
+
date: 2026-01-15
|
|
5325
|
+
description: This markdown file became a page automatically \u2014 here's how it works.
|
|
5326
|
+
---
|
|
5327
|
+
|
|
5328
|
+
## It just works
|
|
5329
|
+
|
|
5330
|
+
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\\\`.
|
|
5331
|
+
|
|
5332
|
+
- Frontmatter \\\`title\\\`, \\\`date\\\` and \\\`description\\\` are read automatically.
|
|
5333
|
+
- The body is rendered safely with \\\`@lacspace/markdown\\\` \u2014 raw HTML is escaped, so it's XSS-safe.
|
|
5334
|
+
- Delete this file when you're ready to write your own.
|
|
5335
|
+
`;
|
|
5336
|
+
var contentSampleSecond = () => `---
|
|
5337
|
+
title: Building in public
|
|
5338
|
+
date: 2026-01-10
|
|
5339
|
+
description: A second example post so the list and feed have something to show.
|
|
5340
|
+
---
|
|
5341
|
+
|
|
5342
|
+
## Why a content section?
|
|
5343
|
+
|
|
5344
|
+
Every product needs a place for changelog posts, announcements and notes. This one is
|
|
5345
|
+
Markdown-powered, needs no database, and ships an RSS feed and an \\\`llms.txt\\\` for free.
|
|
5346
|
+
|
|
5347
|
+
### Add your own
|
|
5348
|
+
|
|
5349
|
+
1. Create \\\`content/updates/my-post.md\\\`.
|
|
5350
|
+
2. Add frontmatter (\\\`title\\\`, \\\`date\\\`, \\\`description\\\`).
|
|
5351
|
+
3. Write. That's it \u2014 it appears at \\\`/updates/my-post\\\`.
|
|
5352
|
+
`;
|
|
5353
|
+
var searchRoute = () => `import fs from "node:fs";
|
|
5354
|
+
import path from "node:path";
|
|
5355
|
+
import { parseFrontmatter, toPlainText, excerpt } from "@lacspace/markdown";
|
|
5356
|
+
import { rerank } from "@lacspace/rerank";
|
|
5357
|
+
|
|
5358
|
+
// how this works: full-text search with BM25 ranking \u2014 no external service, no API
|
|
5359
|
+
// key, no Ollama. It scans your markdown at request time and ranks matches. (For a
|
|
5360
|
+
// big site, precompute the corpus at build time and cache it instead.)
|
|
5361
|
+
export const runtime = "nodejs";
|
|
5362
|
+
export const dynamic = "force-dynamic";
|
|
5363
|
+
|
|
5364
|
+
const ROOT = path.join(process.cwd(), "content");
|
|
5365
|
+
// Map a content/<dir>/ to the route it renders under.
|
|
5366
|
+
const SECTION: Record<string, string> = { updates: "/updates", posts: "/blog", docs: "/docs" };
|
|
5367
|
+
|
|
5368
|
+
interface Entry { id: string; title: string; url: string; text: string; }
|
|
5369
|
+
|
|
5370
|
+
function corpus(): Entry[] {
|
|
5371
|
+
if (!fs.existsSync(ROOT)) return [];
|
|
5372
|
+
const out: Entry[] = [];
|
|
5373
|
+
for (const dir of fs.readdirSync(ROOT)) {
|
|
5374
|
+
const full = path.join(ROOT, dir);
|
|
5375
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
5376
|
+
const base = SECTION[dir] ?? "/" + dir;
|
|
5377
|
+
for (const file of fs.readdirSync(full)) {
|
|
5378
|
+
if (!file.endsWith(".md")) continue;
|
|
5379
|
+
const slug = file.replace(/\\.md$/, "");
|
|
5380
|
+
const { data, content } = parseFrontmatter(fs.readFileSync(path.join(full, file), "utf8"));
|
|
5381
|
+
out.push({ id: dir + "/" + slug, title: String(data.title ?? slug), url: base + "/" + slug, text: toPlainText(content) });
|
|
5382
|
+
}
|
|
5383
|
+
}
|
|
5384
|
+
return out;
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5387
|
+
export async function GET(req: Request) {
|
|
5388
|
+
const q = new URL(req.url).searchParams.get("q")?.trim() ?? "";
|
|
5389
|
+
if (!q) return Response.json([]);
|
|
5390
|
+
const docs = corpus().map((e) => ({ id: e.id, text: e.title + "\\n" + e.text, metadata: { title: e.title, url: e.url } }));
|
|
5391
|
+
const hits = await rerank(q, docs, { method: "bm25", k: 8 });
|
|
5392
|
+
return Response.json(hits.map((h) => ({
|
|
5393
|
+
title: String(h.metadata?.title ?? ""),
|
|
5394
|
+
url: String(h.metadata?.url ?? "#"),
|
|
5395
|
+
snippet: excerpt(h.text.split("\\n").slice(1).join(" "), { length: 140 }),
|
|
5396
|
+
})));
|
|
5397
|
+
}
|
|
5398
|
+
`;
|
|
5399
|
+
var searchBox = () => `"use client";
|
|
5400
|
+
import { useState, useEffect, useRef } from "react";
|
|
5401
|
+
|
|
5402
|
+
interface Result { title: string; url: string; snippet: string; }
|
|
5403
|
+
|
|
5404
|
+
export function Search() {
|
|
5405
|
+
const [q, setQ] = useState("");
|
|
5406
|
+
const [results, setResults] = useState<Result[]>([]);
|
|
5407
|
+
const [open, setOpen] = useState(false);
|
|
5408
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
5409
|
+
|
|
5410
|
+
useEffect(() => {
|
|
5411
|
+
if (timer.current) clearTimeout(timer.current);
|
|
5412
|
+
if (!q.trim()) { setResults([]); return; }
|
|
5413
|
+
timer.current = setTimeout(async () => {
|
|
5414
|
+
const res = await fetch("/api/search?q=" + encodeURIComponent(q));
|
|
5415
|
+
setResults(await res.json());
|
|
5416
|
+
setOpen(true);
|
|
5417
|
+
}, 200);
|
|
5418
|
+
return () => { if (timer.current) clearTimeout(timer.current); };
|
|
5419
|
+
}, [q]);
|
|
5420
|
+
|
|
5421
|
+
return (
|
|
5422
|
+
<div className="relative w-full max-w-md">
|
|
5423
|
+
<input
|
|
5424
|
+
value={q}
|
|
5425
|
+
onChange={(e) => setQ(e.target.value)}
|
|
5426
|
+
onFocus={() => { if (results.length) setOpen(true); }}
|
|
5427
|
+
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
5428
|
+
placeholder="Search\u2026"
|
|
5429
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none"
|
|
5430
|
+
/>
|
|
5431
|
+
{open && results.length > 0 && (
|
|
5432
|
+
<ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-hairline bg-app shadow-lg">
|
|
5433
|
+
{results.map((r) => (
|
|
5434
|
+
<li key={r.url}>
|
|
5435
|
+
<a href={r.url} className="block px-4 py-3 transition hover:bg-surface">
|
|
5436
|
+
<p className="font-medium">{r.title}</p>
|
|
5437
|
+
<p className="mt-0.5 text-sm text-muted">{r.snippet}</p>
|
|
5438
|
+
</a>
|
|
5439
|
+
</li>
|
|
5440
|
+
))}
|
|
5441
|
+
</ul>
|
|
5442
|
+
)}
|
|
5443
|
+
</div>
|
|
5444
|
+
);
|
|
5445
|
+
}
|
|
5446
|
+
`;
|
|
5447
|
+
var searchPage = () => `import { site } from "@/lib/site";
|
|
5448
|
+
import { Search } from "@/components/search";
|
|
5449
|
+
|
|
5450
|
+
export const metadata = site.meta({ title: "Search", path: "/search" });
|
|
5451
|
+
|
|
5452
|
+
export default function SearchPage() {
|
|
5453
|
+
return (
|
|
5454
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
5455
|
+
<h1 className="text-3xl font-bold">Search</h1>
|
|
5456
|
+
<p className="mt-2 text-muted">Instant, keyless full-text search over your content.</p>
|
|
5457
|
+
<div className="mt-8"><Search /></div>
|
|
5458
|
+
</main>
|
|
5459
|
+
);
|
|
5460
|
+
}
|
|
5461
|
+
`;
|
|
5462
|
+
var authTwoFactorModel = () => `import mongoose from "mongoose";
|
|
5463
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5464
|
+
|
|
5465
|
+
// how this works: a separate collection for 2FA so the base User model stays simple.
|
|
5466
|
+
// NOTE: the TOTP secret is stored as-is here \u2014 in production, encrypt it at rest with
|
|
5467
|
+
// @lacspace/crypto (encrypt/decrypt) using a key from your env.
|
|
5468
|
+
export interface TwoFactorDoc {
|
|
5469
|
+
_id: string;
|
|
5470
|
+
userId: string;
|
|
5471
|
+
secret: string;
|
|
5472
|
+
enabled: boolean;
|
|
5473
|
+
backupHashes: string[];
|
|
5474
|
+
createdAt: Date;
|
|
5475
|
+
updatedAt: Date;
|
|
5476
|
+
}
|
|
5477
|
+
|
|
5478
|
+
const schema = new mongoose.Schema<TwoFactorDoc>(
|
|
5479
|
+
{
|
|
5480
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5481
|
+
userId: { type: String, required: true, unique: true, index: true },
|
|
5482
|
+
secret: { type: String, required: true },
|
|
5483
|
+
enabled: { type: Boolean, default: false },
|
|
5484
|
+
backupHashes: { type: [String], default: [] },
|
|
5485
|
+
},
|
|
5486
|
+
{ timestamps: true },
|
|
5487
|
+
);
|
|
5488
|
+
|
|
5489
|
+
export const TwoFactor =
|
|
5490
|
+
(mongoose.models.TwoFactor as mongoose.Model<TwoFactorDoc>) ?? mongoose.model<TwoFactorDoc>("TwoFactor", schema);
|
|
5491
|
+
`;
|
|
5492
|
+
var authAccountRoutes = (ctx) => `import express from "express";
|
|
5493
|
+
import { hash, verify as verifyPassword } from "@lacspace/password";
|
|
5494
|
+
import { setupTotp, verifyTotp, generateBackupCodes, verifyBackupCode } from "@lacspace/otp";
|
|
5495
|
+
import { v } from "@lacspace/validate";
|
|
5496
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
5497
|
+
import { User } from "../models/user.js";
|
|
5498
|
+
import { TwoFactor } from "../models/two-factor.js";
|
|
5499
|
+
|
|
5500
|
+
// This whole group is mounted behind requireAuth (see routes/index.ts), so
|
|
5501
|
+
// req.user is always set here.
|
|
5502
|
+
const router = express.Router();
|
|
5503
|
+
const ISSUER = "${ctx.name}";
|
|
5504
|
+
|
|
5505
|
+
const ProfileInput = v.object({ name: v.string().min(2).max(80) });
|
|
5506
|
+
const PasswordInput = v.object({ currentPassword: v.string().min(1), newPassword: v.string().min(8).max(200) });
|
|
5507
|
+
const CodeInput = v.object({ code: v.string().min(6).max(12) });
|
|
5508
|
+
|
|
5509
|
+
// GET /account/2fa \u2014 is two-factor enabled?
|
|
5510
|
+
router.get("/2fa", asyncHandler(async (req, res) => {
|
|
5511
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5512
|
+
res.json({ enabled: Boolean(tf?.enabled) });
|
|
5513
|
+
}));
|
|
5514
|
+
|
|
5515
|
+
// PATCH /account/profile \u2014 update your display name.
|
|
5516
|
+
router.patch("/profile", asyncHandler(async (req, res) => {
|
|
5517
|
+
const { name } = ProfileInput.parse(req.body);
|
|
5518
|
+
const user = await User.findByIdAndUpdate(req.user!.sub, { $set: { name } }, { new: true });
|
|
5519
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
5520
|
+
res.json({ id: String(user._id), name: user.name, email: user.email });
|
|
5521
|
+
}));
|
|
5522
|
+
|
|
5523
|
+
// POST /account/password \u2014 change your password (verifies the current one).
|
|
5524
|
+
router.post("/password", asyncHandler(async (req, res) => {
|
|
5525
|
+
const { currentPassword, newPassword } = PasswordInput.parse(req.body);
|
|
5526
|
+
const user = await User.findById(req.user!.sub);
|
|
5527
|
+
if (!user || !(await verifyPassword(currentPassword, user.passwordHash))) {
|
|
5528
|
+
throw new HttpError(401, "Current password is incorrect");
|
|
5529
|
+
}
|
|
5530
|
+
user.passwordHash = await hash(newPassword);
|
|
5531
|
+
await user.save();
|
|
5532
|
+
res.json({ ok: true });
|
|
5533
|
+
}));
|
|
5534
|
+
|
|
5535
|
+
// POST /account/2fa/setup \u2014 create a TOTP secret; show \`uri\` as a QR / \`secret\` to type.
|
|
5536
|
+
router.post("/2fa/setup", asyncHandler(async (req, res) => {
|
|
5537
|
+
const user = await User.findById(req.user!.sub);
|
|
5538
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
5539
|
+
const { secret, uri } = setupTotp({ account: user.email, issuer: ISSUER });
|
|
5540
|
+
await TwoFactor.findOneAndUpdate(
|
|
5541
|
+
{ userId: String(user._id) },
|
|
5542
|
+
{ $set: { secret, enabled: false } },
|
|
5543
|
+
{ upsert: true, new: true },
|
|
5544
|
+
);
|
|
5545
|
+
res.json({ secret, uri });
|
|
5546
|
+
}));
|
|
5547
|
+
|
|
5548
|
+
// POST /account/2fa/enable \u2014 verify a code, turn 2FA on, return one-time backup codes.
|
|
5549
|
+
router.post("/2fa/enable", asyncHandler(async (req, res) => {
|
|
5550
|
+
const { code } = CodeInput.parse(req.body);
|
|
5551
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5552
|
+
if (!tf) throw new HttpError(400, "Run 2FA setup first");
|
|
5553
|
+
if ((await verifyTotp(code, tf.secret, { window: 1 })) === null) throw new HttpError(401, "Invalid code");
|
|
5554
|
+
const { codes, hashes } = await generateBackupCodes(10);
|
|
5555
|
+
tf.enabled = true;
|
|
5556
|
+
tf.backupHashes = hashes;
|
|
5557
|
+
await tf.save();
|
|
5558
|
+
res.json({ enabled: true, backupCodes: codes });
|
|
5559
|
+
}));
|
|
5560
|
+
|
|
5561
|
+
// POST /account/2fa/disable \u2014 verify a TOTP or backup code, then turn 2FA off.
|
|
5562
|
+
router.post("/2fa/disable", asyncHandler(async (req, res) => {
|
|
5563
|
+
const { code } = CodeInput.parse(req.body);
|
|
5564
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5565
|
+
if (!tf || !tf.enabled) { res.json({ enabled: false }); return; }
|
|
5566
|
+
const ok = (await verifyTotp(code, tf.secret, { window: 1 })) !== null || (await verifyBackupCode(code, tf.backupHashes)) >= 0;
|
|
5567
|
+
if (!ok) throw new HttpError(401, "Invalid code");
|
|
5568
|
+
await TwoFactor.deleteOne({ userId: req.user!.sub });
|
|
5569
|
+
res.json({ enabled: false });
|
|
5570
|
+
}));
|
|
5571
|
+
|
|
5572
|
+
export default router;
|
|
5573
|
+
`;
|
|
5574
|
+
var authSettingsPage = () => `"use client";
|
|
5575
|
+
import { useEffect, useState } from "react";
|
|
5576
|
+
import { useRouter } from "next/navigation";
|
|
5577
|
+
import { getToken } from "@/lib/api";
|
|
5578
|
+
|
|
5579
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5580
|
+
|
|
5581
|
+
async function call<T>(path: string, body?: unknown, method = "POST"): Promise<T> {
|
|
5582
|
+
const token = getToken();
|
|
5583
|
+
const res = await fetch(API + path, {
|
|
5584
|
+
method,
|
|
5585
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
5586
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
5587
|
+
});
|
|
5588
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
5589
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
|
|
5590
|
+
return data as T;
|
|
5591
|
+
}
|
|
5592
|
+
const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
|
|
5593
|
+
const field = "w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none";
|
|
5594
|
+
const card = "rounded-2xl border border-hairline p-5 space-y-3";
|
|
5595
|
+
const btn = "rounded-full gradient-bg px-4 py-2 text-sm font-semibold on-accent";
|
|
5596
|
+
|
|
5597
|
+
export default function SettingsPage() {
|
|
5598
|
+
const router = useRouter();
|
|
5599
|
+
const [name, setName] = useState("");
|
|
5600
|
+
const [current, setCurrent] = useState("");
|
|
5601
|
+
const [next, setNext] = useState("");
|
|
5602
|
+
const [enabled, setEnabled] = useState(false);
|
|
5603
|
+
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
|
5604
|
+
const [code, setCode] = useState("");
|
|
5605
|
+
const [backup, setBackup] = useState<string[] | null>(null);
|
|
5606
|
+
const [msg, setMsg] = useState<string | null>(null);
|
|
5607
|
+
|
|
5608
|
+
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(null), 3000); };
|
|
5609
|
+
|
|
5610
|
+
useEffect(() => {
|
|
5611
|
+
if (!getToken()) { router.push("/login"); return; }
|
|
5612
|
+
call<{ enabled: boolean }>("/account/2fa", undefined, "GET").then((r) => setEnabled(r.enabled)).catch(() => {});
|
|
5613
|
+
}, [router]);
|
|
5614
|
+
|
|
5615
|
+
async function saveName(e: React.FormEvent) {
|
|
5616
|
+
e.preventDefault();
|
|
5617
|
+
try { await call("/account/profile", { name }, "PATCH"); flash("Profile updated"); } catch (err) { flash(msgOf(err)); }
|
|
5618
|
+
}
|
|
5619
|
+
async function savePassword(e: React.FormEvent) {
|
|
5620
|
+
e.preventDefault();
|
|
5621
|
+
try { await call("/account/password", { currentPassword: current, newPassword: next }); setCurrent(""); setNext(""); flash("Password changed"); } catch (err) { flash(msgOf(err)); }
|
|
5622
|
+
}
|
|
5623
|
+
async function begin2fa() {
|
|
5624
|
+
try { setSetup(await call<{ secret: string; uri: string }>("/account/2fa/setup")); } catch (err) { flash(msgOf(err)); }
|
|
5625
|
+
}
|
|
5626
|
+
async function enable2fa() {
|
|
5627
|
+
try { const r = await call<{ backupCodes: string[] }>("/account/2fa/enable", { code }); setBackup(r.backupCodes); setEnabled(true); setSetup(null); setCode(""); } catch (err) { flash(msgOf(err)); }
|
|
5628
|
+
}
|
|
5629
|
+
async function disable2fa() {
|
|
5630
|
+
const c = window.prompt("Enter a current 2FA code (or a backup code) to disable:");
|
|
5631
|
+
if (!c) return;
|
|
5632
|
+
try { await call("/account/2fa/disable", { code: c }); setEnabled(false); flash("2FA disabled"); } catch (err) { flash(msgOf(err)); }
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
return (
|
|
5636
|
+
<main className="mx-auto max-w-lg space-y-6 px-6 py-16">
|
|
5637
|
+
<h1 className="text-2xl font-bold">Account settings</h1>
|
|
5638
|
+
{msg && <p className="rounded-xl border border-hairline bg-surface px-4 py-2 text-sm">{msg}</p>}
|
|
5639
|
+
|
|
5640
|
+
<form onSubmit={saveName} className={card}>
|
|
5641
|
+
<h2 className="font-semibold">Profile</h2>
|
|
5642
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="New display name" className={field} />
|
|
5643
|
+
<button className={btn}>Save name</button>
|
|
5644
|
+
</form>
|
|
5645
|
+
|
|
5646
|
+
<form onSubmit={savePassword} className={card}>
|
|
5647
|
+
<h2 className="font-semibold">Change password</h2>
|
|
5648
|
+
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} placeholder="Current password" className={field} />
|
|
5649
|
+
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} placeholder="New password (min 8)" className={field} />
|
|
5650
|
+
<button className={btn}>Change password</button>
|
|
5651
|
+
</form>
|
|
5652
|
+
|
|
5653
|
+
<div className={card}>
|
|
5654
|
+
<h2 className="font-semibold">Two-factor authentication {enabled && <span className="text-green-500">\xB7 on</span>}</h2>
|
|
5655
|
+
{!enabled && !setup && <button onClick={begin2fa} className={btn}>Enable 2FA</button>}
|
|
5656
|
+
{!enabled && setup && (
|
|
5657
|
+
<div className="space-y-3">
|
|
5658
|
+
<p className="text-sm text-muted">Add this secret to your authenticator app (Google Authenticator, Authy\u2026):</p>
|
|
5659
|
+
<code className="block break-all rounded-lg bg-surface p-3 text-sm">{setup.secret}</code>
|
|
5660
|
+
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" className={field} />
|
|
5661
|
+
<button onClick={enable2fa} className={btn}>Verify & enable</button>
|
|
5662
|
+
</div>
|
|
5663
|
+
)}
|
|
5664
|
+
{enabled && <button onClick={disable2fa} className="rounded-full border border-hairline px-4 py-2 text-sm">Disable 2FA</button>}
|
|
5665
|
+
{backup && (
|
|
5666
|
+
<div className="mt-2 space-y-2">
|
|
5667
|
+
<p className="text-sm font-medium">Save these backup codes (shown once):</p>
|
|
5668
|
+
<ul className="grid grid-cols-2 gap-1 rounded-lg bg-surface p-3 font-mono text-sm">
|
|
5669
|
+
{backup.map((b) => <li key={b}>{b}</li>)}
|
|
5670
|
+
</ul>
|
|
5671
|
+
</div>
|
|
5672
|
+
)}
|
|
5673
|
+
</div>
|
|
5674
|
+
</main>
|
|
5675
|
+
);
|
|
5676
|
+
}
|
|
5677
|
+
`;
|
|
5678
|
+
var analyticsEventModel = () => `import mongoose from "mongoose";
|
|
5679
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5680
|
+
|
|
5681
|
+
export interface EventDoc {
|
|
5682
|
+
_id: string;
|
|
5683
|
+
type: string;
|
|
5684
|
+
path: string;
|
|
5685
|
+
referrer: string;
|
|
5686
|
+
screen: string;
|
|
5687
|
+
sid: string;
|
|
5688
|
+
props: Record<string, unknown>;
|
|
5689
|
+
ts: Date;
|
|
5690
|
+
}
|
|
5691
|
+
|
|
5692
|
+
const schema = new mongoose.Schema<EventDoc>({
|
|
5693
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5694
|
+
type: { type: String, default: "pageview", index: true },
|
|
5695
|
+
path: { type: String, default: "/" },
|
|
5696
|
+
referrer: { type: String, default: "" },
|
|
5697
|
+
screen: { type: String, default: "" },
|
|
5698
|
+
sid: { type: String, default: "" },
|
|
5699
|
+
props: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
5700
|
+
ts: { type: Date, default: Date.now, index: true },
|
|
5701
|
+
});
|
|
5702
|
+
|
|
5703
|
+
export const Event =
|
|
5704
|
+
(mongoose.models.Event as mongoose.Model<EventDoc>) ?? mongoose.model<EventDoc>("Event", schema);
|
|
5705
|
+
`;
|
|
5706
|
+
var analyticsEventsRoutes = () => `import express from "express";
|
|
5707
|
+
import { asyncHandler } from "../http.js";
|
|
5708
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
5709
|
+
import { Event } from "../models/event.js";
|
|
5710
|
+
|
|
5711
|
+
const router = express.Router();
|
|
5712
|
+
|
|
5713
|
+
// POST /events \u2014 the tracker beacon posts here. Public + cookieless (no PII stored).
|
|
5714
|
+
router.post("/", asyncHandler(async (req, res) => {
|
|
5715
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
5716
|
+
await Event.create({
|
|
5717
|
+
type: String(b.type ?? "pageview"),
|
|
5718
|
+
path: String(b.path ?? "/"),
|
|
5719
|
+
referrer: String(b.referrer ?? ""),
|
|
5720
|
+
screen: String(b.screen ?? ""),
|
|
5721
|
+
sid: String(b.sid ?? ""),
|
|
5722
|
+
props: b.props && typeof b.props === "object" ? (b.props as Record<string, unknown>) : {},
|
|
5723
|
+
});
|
|
5724
|
+
res.status(204).end();
|
|
5725
|
+
}));
|
|
5726
|
+
|
|
5727
|
+
// GET /events/summary \u2014 dashboard aggregates (protected: only signed-in owners).
|
|
5728
|
+
router.get("/summary", requireAuth, asyncHandler(async (_req, res) => {
|
|
5729
|
+
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
5730
|
+
const [total, pageviews, topPaths, byDay] = await Promise.all([
|
|
5731
|
+
Event.countDocuments({}),
|
|
5732
|
+
Event.countDocuments({ type: "pageview" }),
|
|
5733
|
+
Event.aggregate([
|
|
5734
|
+
{ $match: { type: "pageview" } },
|
|
5735
|
+
{ $group: { _id: "$path", count: { $sum: 1 } } },
|
|
5736
|
+
{ $sort: { count: -1 } },
|
|
5737
|
+
{ $limit: 8 },
|
|
5738
|
+
]),
|
|
5739
|
+
Event.aggregate([
|
|
5740
|
+
{ $match: { ts: { $gte: since } } },
|
|
5741
|
+
{ $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$ts" } }, count: { $sum: 1 } } },
|
|
5742
|
+
{ $sort: { _id: 1 } },
|
|
5743
|
+
]),
|
|
5744
|
+
]);
|
|
5745
|
+
res.json({
|
|
5746
|
+
total,
|
|
5747
|
+
pageviews,
|
|
5748
|
+
topPaths: topPaths.map((p: { _id: string; count: number }) => ({ path: p._id, count: p.count })),
|
|
5749
|
+
byDay: byDay.map((d: { _id: string; count: number }) => ({ day: d._id, count: d.count })),
|
|
5750
|
+
});
|
|
5751
|
+
}));
|
|
5752
|
+
|
|
5753
|
+
export default router;
|
|
5754
|
+
`;
|
|
5755
|
+
var analyticsComponent = (ctx) => `"use client";
|
|
5756
|
+
import { useEffect } from "react";
|
|
5757
|
+
import { createAnalytics } from "@lacspace/analytics-lite";
|
|
5758
|
+
|
|
5759
|
+
// how this works: cookieless, privacy-first tracking. It POSTs page views + events to
|
|
5760
|
+
// your backend /events collector (stored in MongoDB). No cookies, no localStorage, no
|
|
5761
|
+
// personal data. Respects the browser's Do-Not-Track.
|
|
5762
|
+
const ENDPOINT = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000") + "/events";
|
|
5763
|
+
|
|
5764
|
+
export function Analytics() {
|
|
5765
|
+
useEffect(() => {
|
|
5766
|
+
const a = createAnalytics({ endpoint: ENDPOINT, siteId: ${JSON.stringify(ctx.name)}, respectDNT: true });
|
|
5767
|
+
a.pageview();
|
|
5768
|
+
return a.autoTrack();
|
|
5769
|
+
}, []);
|
|
5770
|
+
return null;
|
|
5771
|
+
}
|
|
5772
|
+
`;
|
|
5773
|
+
var analyticsDashboard = () => `"use client";
|
|
5774
|
+
import { useEffect, useState } from "react";
|
|
5775
|
+
import { useRouter } from "next/navigation";
|
|
5776
|
+
import { getToken } from "@/lib/api";
|
|
5777
|
+
|
|
5778
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5779
|
+
|
|
5780
|
+
interface Summary {
|
|
5781
|
+
total: number;
|
|
5782
|
+
pageviews: number;
|
|
5783
|
+
topPaths: { path: string; count: number }[];
|
|
5784
|
+
byDay: { day: string; count: number }[];
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
export default function AnalyticsPage() {
|
|
5788
|
+
const router = useRouter();
|
|
5789
|
+
const [data, setData] = useState<Summary | null>(null);
|
|
5790
|
+
const [error, setError] = useState<string | null>(null);
|
|
5791
|
+
|
|
5792
|
+
useEffect(() => {
|
|
5793
|
+
const token = getToken();
|
|
5794
|
+
if (!token) { router.push("/login"); return; }
|
|
5795
|
+
fetch(API + "/events/summary", { headers: { Authorization: "Bearer " + token } })
|
|
5796
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("Please sign in"))))
|
|
5797
|
+
.then(setData)
|
|
5798
|
+
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load"));
|
|
5799
|
+
}, [router]);
|
|
5800
|
+
|
|
5801
|
+
if (error) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">{error}</main>;
|
|
5802
|
+
if (!data) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">Loading\u2026</main>;
|
|
5803
|
+
const max = Math.max(1, ...data.byDay.map((d) => d.count));
|
|
5804
|
+
|
|
5805
|
+
return (
|
|
5806
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5807
|
+
<h1 className="text-3xl font-bold">Analytics</h1>
|
|
5808
|
+
<p className="mt-1 text-muted">Cookieless, privacy-first \u2014 last 30 days.</p>
|
|
5809
|
+
<div className="mt-6 grid grid-cols-2 gap-4">
|
|
5810
|
+
<div className="rounded-2xl border border-hairline p-5"><p className="text-sm text-muted">Total events</p><p className="text-3xl font-bold">{data.total}</p></div>
|
|
5811
|
+
<div className="rounded-2xl border border-hairline p-5"><p className="text-sm text-muted">Page views</p><p className="text-3xl font-bold">{data.pageviews}</p></div>
|
|
5812
|
+
</div>
|
|
5813
|
+
<h2 className="mt-8 font-semibold">Top pages</h2>
|
|
5814
|
+
<ul className="mt-3 space-y-2">
|
|
5815
|
+
{data.topPaths.length === 0 && <li className="text-muted">No data yet \u2014 browse your site with <Analytics/> mounted.</li>}
|
|
5816
|
+
{data.topPaths.map((p) => (
|
|
5817
|
+
<li key={p.path} className="flex justify-between border-b border-hairline pb-2"><span className="truncate">{p.path}</span><span className="text-muted">{p.count}</span></li>
|
|
5818
|
+
))}
|
|
5819
|
+
</ul>
|
|
5820
|
+
<h2 className="mt-8 font-semibold">Events per day</h2>
|
|
5821
|
+
<div className="mt-3 flex items-end gap-1" style={{ height: 120 }}>
|
|
5822
|
+
{data.byDay.map((d) => (
|
|
5823
|
+
<div key={d.day} className="flex-1 rounded-t" style={{ height: (d.count / max) * 100 + "%", backgroundColor: "var(--accent, #6366f1)" }} title={d.day + ": " + d.count} />
|
|
5824
|
+
))}
|
|
5825
|
+
</div>
|
|
5826
|
+
</main>
|
|
5827
|
+
);
|
|
5828
|
+
}
|
|
5829
|
+
`;
|
|
5098
5830
|
var scope = (ctx) => `@${ctx.name}`;
|
|
5099
5831
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5100
5832
|
name: `${scope(ctx)}/types`,
|
|
@@ -5494,44 +6226,67 @@ function buildFullStack(ctx) {
|
|
|
5494
6226
|
out[".gitignore"] = rootGitignore();
|
|
5495
6227
|
out[".env.example"] = rootEnvExample(ctx);
|
|
5496
6228
|
out["README.md"] = rootReadme(ctx);
|
|
6229
|
+
const envBase = out[".env.example"];
|
|
6230
|
+
const seen = new Set(
|
|
6231
|
+
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|
|
6232
|
+
);
|
|
6233
|
+
const lines = [];
|
|
6234
|
+
for (const f of ctx.features) {
|
|
6235
|
+
const b = f.backend?.(ctx);
|
|
6236
|
+
if (!b?.env) continue;
|
|
6237
|
+
const fresh = Object.entries(b.env).filter(([name]) => !seen.has(name));
|
|
6238
|
+
if (!fresh.length) continue;
|
|
6239
|
+
lines.push("", `# --- ${f.label} (${f.key}) ---`);
|
|
6240
|
+
for (const [name, comment] of fresh) {
|
|
6241
|
+
if (comment) lines.push(`# ${comment}`);
|
|
6242
|
+
lines.push(`${name}=`);
|
|
6243
|
+
seen.add(name);
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
if (lines.length) out[".env.example"] = envBase.replace(/\n?$/, "\n") + lines.join("\n") + "\n";
|
|
5497
6247
|
return out;
|
|
5498
6248
|
}
|
|
5499
|
-
var backendPkgJson = (ctx) =>
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
6249
|
+
var backendPkgJson = (ctx) => {
|
|
6250
|
+
const featureDeps = {};
|
|
6251
|
+
for (const f of ctx.features) Object.assign(featureDeps, f.backend?.(ctx)?.deps ?? {});
|
|
6252
|
+
return JSON.stringify({
|
|
6253
|
+
name: `${scope(ctx)}/backend`,
|
|
6254
|
+
version: "0.1.0",
|
|
6255
|
+
private: true,
|
|
6256
|
+
type: "module",
|
|
6257
|
+
main: "dist/index.js",
|
|
6258
|
+
scripts: {
|
|
6259
|
+
dev: "tsx watch src/index.ts",
|
|
6260
|
+
build: "tsc -p tsconfig.json",
|
|
6261
|
+
start: "node dist/index.js",
|
|
6262
|
+
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
6263
|
+
},
|
|
6264
|
+
dependencies: {
|
|
6265
|
+
express: "^4.21.2",
|
|
6266
|
+
cors: "^2.8.5",
|
|
6267
|
+
mongoose: "^8.9.0",
|
|
6268
|
+
ioredis: "^5.4.2",
|
|
6269
|
+
dotenv: "^16.4.7",
|
|
6270
|
+
// ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
|
|
6271
|
+
"@lacspace/env": "^1.1.0",
|
|
6272
|
+
"@lacspace/jwt": "^1.4.0",
|
|
6273
|
+
"@lacspace/password": "^1.1.0",
|
|
6274
|
+
"@lacspace/validate": "^1.1.0",
|
|
6275
|
+
"@lacspace/id": "^1.1.0",
|
|
6276
|
+
"@lacspace/rate-limit": "^1.2.0",
|
|
6277
|
+
"@lacspace/cache": "^1.1.0",
|
|
6278
|
+
...featureDeps,
|
|
6279
|
+
[`${scope(ctx)}/types`]: "*"
|
|
6280
|
+
},
|
|
6281
|
+
devDependencies: {
|
|
6282
|
+
typescript: "^5.7.0",
|
|
6283
|
+
tsx: "^4.19.2",
|
|
6284
|
+
"@types/node": "^22.10.0",
|
|
6285
|
+
"@types/express": "^4.17.21",
|
|
6286
|
+
"@types/cors": "^2.8.17"
|
|
6287
|
+
}
|
|
6288
|
+
}, null, 2) + "\n";
|
|
6289
|
+
};
|
|
5535
6290
|
var backendTsconfig = (ctx) => JSON.stringify({
|
|
5536
6291
|
compilerOptions: {
|
|
5537
6292
|
target: "ES2022",
|
|
@@ -5957,35 +6712,49 @@ router.delete("/:id", asyncHandler(async (req, res) => {
|
|
|
5957
6712
|
export default router;
|
|
5958
6713
|
`;
|
|
5959
6714
|
var backendApp = (ctx) => `import express from "express";
|
|
5960
|
-
import type { RequestHandler } from "express";
|
|
5961
6715
|
import cors from "cors";
|
|
5962
|
-
import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
|
|
5963
6716
|
import { env } from "./env.js";
|
|
5964
6717
|
import { errorHandler } from "./middleware/error.js";
|
|
5965
|
-
import
|
|
5966
|
-
import noteRoutes from "./routes/notes.js";
|
|
6718
|
+
import { registerRoutes } from "./routes/index.js";
|
|
5967
6719
|
|
|
5968
6720
|
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
5969
|
-
// a health check,
|
|
6721
|
+
// a health check, all route groups (see routes/index.ts), then the error handler LAST.
|
|
5970
6722
|
export function createApp(): express.Express {
|
|
5971
6723
|
const app = express();
|
|
5972
6724
|
|
|
5973
6725
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
5974
6726
|
app.use(express.json());
|
|
5975
6727
|
|
|
5976
|
-
// Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
|
|
5977
|
-
const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
|
|
5978
|
-
|
|
5979
6728
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
5980
|
-
app
|
|
5981
|
-
app.use("/notes", noteRoutes);
|
|
6729
|
+
registerRoutes(app);
|
|
5982
6730
|
|
|
5983
6731
|
app.use(errorHandler);
|
|
5984
6732
|
return app;
|
|
5985
6733
|
}
|
|
5986
6734
|
`;
|
|
6735
|
+
var backendRoutesIndex = (ctx) => {
|
|
6736
|
+
const featureRoutes = ctx.features.flatMap((f) => f.backend?.(ctx)?.routes ?? []);
|
|
6737
|
+
const needsAuth = featureRoutes.some((r) => r.auth);
|
|
6738
|
+
const importLines = featureRoutes.map((r) => r.importLine).join("\n");
|
|
6739
|
+
const mountLines = featureRoutes.map((r) => ` app.use(${JSON.stringify(r.path)}, ${r.auth ? "requireAuth, " : ""}${r.handler});`).join("\n");
|
|
6740
|
+
return `import type { Express, RequestHandler } from "express";
|
|
6741
|
+
import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
|
|
6742
|
+
${needsAuth ? 'import { requireAuth } from "../middleware/auth.js";\n' : ""}import authRoutes from "./auth.js";
|
|
6743
|
+
import noteRoutes from "./notes.js";
|
|
6744
|
+
${importLines ? importLines + "\n" : ""}
|
|
6745
|
+
// how this works: every route group is mounted here. Add a new resource by creating
|
|
6746
|
+
// a router in routes/ and adding one app.use(...) line below.
|
|
6747
|
+
export function registerRoutes(app: Express): void {
|
|
6748
|
+
// Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
|
|
6749
|
+
const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
|
|
6750
|
+
app.use("/auth", authLimiter, authRoutes);
|
|
6751
|
+
app.use("/notes", noteRoutes);
|
|
6752
|
+
${mountLines}
|
|
6753
|
+
}
|
|
6754
|
+
`;
|
|
6755
|
+
};
|
|
5987
6756
|
function backendFiles(ctx) {
|
|
5988
|
-
|
|
6757
|
+
const files = {
|
|
5989
6758
|
"backend/package.json": backendPkgJson(ctx),
|
|
5990
6759
|
"backend/tsconfig.json": backendTsconfig(ctx),
|
|
5991
6760
|
"backend/.gitignore": backendGitignore(),
|
|
@@ -6004,8 +6773,15 @@ function backendFiles(ctx) {
|
|
|
6004
6773
|
"backend/src/models/user.ts": backendUserModel(),
|
|
6005
6774
|
"backend/src/models/note.ts": backendNoteModel(),
|
|
6006
6775
|
"backend/src/routes/auth.ts": backendAuthRoutes(ctx),
|
|
6007
|
-
"backend/src/routes/notes.ts": backendNoteRoutes(ctx)
|
|
6776
|
+
"backend/src/routes/notes.ts": backendNoteRoutes(ctx),
|
|
6777
|
+
"backend/src/routes/index.ts": backendRoutesIndex(ctx)
|
|
6008
6778
|
};
|
|
6779
|
+
for (const f of ctx.features) {
|
|
6780
|
+
const b = f.backend?.(ctx);
|
|
6781
|
+
if (!b?.files) continue;
|
|
6782
|
+
for (const [rel, content] of Object.entries(b.files)) files["backend/" + rel] = content;
|
|
6783
|
+
}
|
|
6784
|
+
return files;
|
|
6009
6785
|
}
|
|
6010
6786
|
var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
6011
6787
|
function parseArgs(list) {
|