Implement repository-driven theme rendering
This commit is contained in:
@@ -33,27 +33,11 @@ async function startServer() {
|
||||
res.json(store.siteConfig);
|
||||
});
|
||||
|
||||
app.put("/api/site-config", (req, res) => {
|
||||
try {
|
||||
const { rawYaml } = req.body;
|
||||
if (rawYaml) {
|
||||
const parsed = YAML.parse(rawYaml);
|
||||
store.siteConfig.rawYaml = rawYaml;
|
||||
if (parsed.site) store.siteConfig.site = parsed.site;
|
||||
if (parsed.markdown) store.siteConfig.markdown = parsed.markdown;
|
||||
if (parsed.hosting) store.siteConfig.hosting = parsed.hosting;
|
||||
if (parsed.buildPolicy)
|
||||
store.siteConfig.buildPolicy = parsed.buildPolicy;
|
||||
store.addAudit(
|
||||
"Update Site Configuration",
|
||||
"config",
|
||||
"Updated site.yaml configuration from admin UI",
|
||||
);
|
||||
}
|
||||
res.json({ success: true, siteConfig: store.siteConfig });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: `YAML parse error: ${err.message}` });
|
||||
}
|
||||
app.put("/api/site-config", (_req, res) => {
|
||||
res.status(405).json({
|
||||
code: "E_CONFIG_READ_ONLY",
|
||||
message: "Site configuration is Git-owned and read-only in v1.",
|
||||
});
|
||||
});
|
||||
|
||||
// --- GITEA DISCOVERY ---
|
||||
@@ -449,19 +433,12 @@ async function startServer() {
|
||||
res.json(store.themeConfig);
|
||||
});
|
||||
|
||||
app.post("/api/theme/validate", (req, res) => {
|
||||
store.themeConfig.isValidated = true;
|
||||
store.themeConfig.validationErrors = [];
|
||||
store.addAudit(
|
||||
"Validate Theme",
|
||||
"config",
|
||||
"Validated theme manifest /.theme/theme.yaml",
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
theme: store.themeConfig,
|
||||
message:
|
||||
"Theme /.theme loaded successfully. All required templates and packages supported.",
|
||||
app.post("/api/theme/validate", (_req, res) => {
|
||||
const theme = store.validateTheme();
|
||||
res.status(theme.status === "valid" ? 200 : 422).json({
|
||||
success: theme.status === "valid",
|
||||
theme,
|
||||
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -559,191 +536,37 @@ async function startServer() {
|
||||
res.json(store.auditLogs);
|
||||
});
|
||||
|
||||
// --- LIVE SITE HTML PREVIEW ENDPOINT ---
|
||||
// --- RELEASE-ONLY SITE PREVIEW ENDPOINT ---
|
||||
app.get("/api/live-site/html", (req, res) => {
|
||||
const activeBuild =
|
||||
store.builds.find((b) => b.isActiveLocal) || store.builds[0];
|
||||
let pageRoute = (req.query.route as string) || "/";
|
||||
|
||||
const rootDir = store.getCurrentBuildDirectory();
|
||||
|
||||
if (fs.existsSync(rootDir)) {
|
||||
const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/");
|
||||
const relativeRoute = routeWithoutQuery.replace(/^\/+/, "");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const targetPath = path.resolve(rootDir, relativeRoute, "index.html");
|
||||
if (!targetPath.startsWith(rootPath)) {
|
||||
return res.status(400).json({ error: "Invalid preview route" });
|
||||
}
|
||||
if (fs.existsSync(targetPath)) {
|
||||
return res.type("html").send(fs.readFileSync(targetPath, "utf-8"));
|
||||
}
|
||||
const rootDir = store.getPreviewDirectory();
|
||||
if (!rootDir) {
|
||||
return res.status(503).json({
|
||||
code: "E_RELEASE_UNAVAILABLE",
|
||||
message: "No verified staging or live release is selected.",
|
||||
});
|
||||
}
|
||||
|
||||
// Simple HTML renderer simulating compiled static site output (fallback)
|
||||
let pageTitle = store.siteConfig.site.title;
|
||||
let mainContent = "";
|
||||
|
||||
// Match route against configured navigation entries
|
||||
const navMatch = store.siteConfig.navigation.find(
|
||||
(nav) => nav.route === pageRoute,
|
||||
);
|
||||
|
||||
if (navMatch) {
|
||||
pageTitle = `${navMatch.label} | ${store.siteConfig.site.title}`;
|
||||
|
||||
const relatedContent = store.contentItems.filter(
|
||||
(item) => item.artifactType === navMatch.contentModel,
|
||||
);
|
||||
|
||||
mainContent = `
|
||||
<section class="max-w-4xl mx-auto py-8 px-4">
|
||||
<div class="mb-8 border-b pb-4 border-slate-200">
|
||||
<span class="inline-block px-2.5 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 bg-indigo-50 rounded-full mb-2">Section: ${navMatch.label}</span>
|
||||
<h1 class="text-3xl font-bold text-slate-900">${navMatch.label}</h1>
|
||||
<p class="text-slate-600 mt-2 text-base">Content mapped from repositories providing the <code class="bg-slate-100 px-1.5 py-0.5 rounded text-xs font-mono text-indigo-600">${navMatch.contentModel}</code> model.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
${
|
||||
relatedContent.length === 0
|
||||
? '<p class="text-slate-500 italic">No content discovered for this section yet.</p>'
|
||||
: relatedContent
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="bg-white border border-slate-200 rounded-xl p-6 shadow-xs hover:border-indigo-300 transition-colors">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500 mb-2">
|
||||
<span class="font-mono text-indigo-600 font-medium">Repository: ${item.sourceRepo}</span>
|
||||
<time>${item.published}</time>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 hover:text-indigo-600 cursor-pointer mb-2">
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${item.route}'}, '*')">${item.title}</a>
|
||||
</h2>
|
||||
<p class="text-slate-600 text-sm mb-4 line-clamp-2">${item.summary}</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
${item.tags.map((t) => `<span class="bg-slate-100 text-slate-700 px-2 py-0.5 rounded-md font-mono">#${t}</span>`).join("")}
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
} else {
|
||||
const match = store.contentItems.find(
|
||||
(i) => i.route === pageRoute || pageRoute.includes(i.slug),
|
||||
);
|
||||
if (match) {
|
||||
pageTitle = `${match.title} | ${store.siteConfig.site.title}`;
|
||||
mainContent = `
|
||||
<article class="max-w-3xl mx-auto py-10 px-4">
|
||||
<div class="mb-6">
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="text-xs font-medium text-indigo-600 hover:underline">← Back Home</a>
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-slate-500">
|
||||
<span class="bg-indigo-100 text-indigo-800 font-medium px-2.5 py-0.5 rounded-full font-mono">${match.sourceRepo}</span>
|
||||
<span>Published on ${match.published}</span>
|
||||
<span class="text-emerald-600 font-medium">✓ Verified Schema</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-slate-900 mt-3">${match.title}</h1>
|
||||
<p class="text-slate-600 text-lg mt-2 font-serif italic">${match.summary}</p>
|
||||
</div>
|
||||
|
||||
${match.featuredImage ? `<img src="${match.featuredImage}" class="w-full h-64 object-cover rounded-xl mb-6 shadow-sm border border-slate-200" alt="${match.title}" />` : ""}
|
||||
|
||||
<div class="prose prose-slate max-w-none text-slate-800 leading-relaxed space-y-4">
|
||||
${match.contentMarkdown
|
||||
.replace(/^---[\s\S]*?---/, "")
|
||||
.replace(
|
||||
/# (.*)/g,
|
||||
'<h1 class="text-2xl font-bold text-slate-900 mt-6 mb-3">$1</h1>',
|
||||
)
|
||||
.replace(
|
||||
/## (.*)/g,
|
||||
'<h2 class="text-xl font-bold text-slate-900 mt-5 mb-2">$1</h2>',
|
||||
)
|
||||
.replace(
|
||||
/### (.*)/g,
|
||||
'<h3 class="text-lg font-semibold text-slate-800 mt-4 mb-2">$1</h3>',
|
||||
)
|
||||
.replace(
|
||||
/::youtube\[(.*?)\]\{id="(.*?)"\}/g,
|
||||
'<div class="my-6 aspect-video bg-slate-900 rounded-xl flex flex-col items-center justify-center text-white p-6 shadow-md border border-slate-800"><div class="text-red-500 text-4xl mb-2">▶</div><div class="font-medium text-base">$1</div><div class="text-xs text-slate-400 mt-1 font-mono">YouTube Embed ID: $2</div></div>',
|
||||
)
|
||||
.replace(
|
||||
/> \[\!(NOTE|IMPORTANT)\]\n> (.*)/g,
|
||||
'<div class="p-4 bg-indigo-50 border-l-4 border-indigo-500 text-indigo-900 text-sm rounded-r-md my-4 font-medium">$2</div>',
|
||||
)
|
||||
.replace(
|
||||
/```typescript([\s\S]*?)```/g,
|
||||
'<pre class="bg-slate-900 text-slate-100 p-4 rounded-xl text-xs font-mono overflow-x-auto my-4 shadow-sm"><code>$1</code></pre>',
|
||||
)
|
||||
.replace(
|
||||
/\n\n/g,
|
||||
'</p><p class="text-slate-700 font-sans leading-7">',
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
} else {
|
||||
mainContent = `
|
||||
<div class="max-w-md mx-auto py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold text-slate-900 mb-2">404</h1>
|
||||
<p class="text-slate-600 mb-6">Page non-existent in build ${activeBuild?.id}</p>
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700">Return Home</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
let requested: string;
|
||||
try {
|
||||
requested = decodeURIComponent(String(req.query.route ?? "/").split(/[?#]/, 1)[0]);
|
||||
} catch {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
if (!requested.startsWith("/") || requested.includes("\\") || requested.split("/").includes("..")) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${pageTitle}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #f8fafc; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- SITE HEADER -->
|
||||
<header class="bg-slate-900 text-white border-b border-slate-800 sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 cursor-pointer" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-500 flex items-center justify-center font-bold text-white text-lg">L</div>
|
||||
<span class="font-bold text-lg tracking-tight">${store.siteConfig.site.title}</span>
|
||||
</div>
|
||||
<nav class="flex items-center space-x-6 text-sm font-medium text-slate-300">
|
||||
${store.siteConfig.navigation.map((nav) => `<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${nav.route}'}, '*')" class="hover:text-white transition-colors text-indigo-400">${nav.label}</a>`).join("\\n ")}
|
||||
</nav>
|
||||
<div class="text-xs bg-slate-800 border border-slate-700 text-slate-300 px-3 py-1.5 rounded-full font-mono flex items-center space-x-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Hosted via Nginx (${activeBuild.id})</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const relative = requested.replace(/^\/+/, "");
|
||||
const outputFile = relative && path.posix.extname(relative) ? relative : path.posix.join(relative, "index.html");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const target = path.resolve(rootDir, ...outputFile.split("/"));
|
||||
if (!target.startsWith(rootPath)) return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
|
||||
<!-- MAIN BODY -->
|
||||
<main class="flex-grow">
|
||||
${mainContent}
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="bg-slate-900 text-slate-400 border-t border-slate-800 py-8 text-xs text-center mt-12">
|
||||
<div class="max-w-4xl mx-auto px-4 space-y-2">
|
||||
<p>Labyricorn Deterministic Static Site Builder — Active Release: <span class="font-mono text-indigo-400">${activeBuild.id}</span> (${activeBuild.artifactChecksum.substring(0, 16)}...)</p>
|
||||
<p class="text-slate-500">Public media served under <code class="text-slate-400">/media</code> • Pure CommonMark Markdown Dialect</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'");
|
||||
if (fs.existsSync(target) && fs.statSync(target).isFile()) return res.type(path.extname(target)).send(fs.readFileSync(target));
|
||||
const notFound = path.join(rootDir, "404.html");
|
||||
if (fs.existsSync(notFound)) return res.status(404).type("html").send(fs.readFileSync(notFound));
|
||||
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
// --- VITE / PRODUCTION SERVING ---
|
||||
|
||||
Reference in New Issue
Block a user