Compare commits
4
Commits
98e46644c0
...
515e52b274
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
515e52b274 | ||
|
|
963521b3a9 | ||
|
|
efb0fbea94 | ||
|
|
6c1384b661 |
@@ -57,20 +57,32 @@ export function normalizeObsidianMarkdown(content) {
|
||||
|
||||
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
||||
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
||||
const slug = slugify(target);
|
||||
return `[${label.trim()}](/posts/${slug}/)`;
|
||||
return `[${label.trim()}](${wikilinkUrl(target)})`;
|
||||
});
|
||||
|
||||
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
||||
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
||||
const trimmed = target.trim();
|
||||
const slug = slugify(trimmed);
|
||||
return `[${trimmed}](/posts/${slug}/)`;
|
||||
return `[${trimmed}](${wikilinkUrl(trimmed)})`;
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function wikilinkUrl(target) {
|
||||
const trimmed = target.trim();
|
||||
const fragmentIndex = trimmed.indexOf("#");
|
||||
|
||||
if (fragmentIndex === -1) {
|
||||
return `/posts/${slugify(trimmed)}/`;
|
||||
}
|
||||
|
||||
const noteSlug = slugify(trimmed.slice(0, fragmentIndex));
|
||||
const headerSlug = slugify(trimmed.slice(fragmentIndex + 1));
|
||||
const fragment = headerSlug ? `#${headerSlug}` : "";
|
||||
return `/posts/${noteSlug}/${fragment}`;
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
layout: layouts/base.njk
|
||||
permalink: /404.html
|
||||
title: Page not found | River Rooks
|
||||
excerpt: The requested page could not be found.
|
||||
---
|
||||
<section class="not-found tile" aria-labelledby="not-found-title">
|
||||
<header class="tile-header">
|
||||
<span class="tile-title">// ROUTE STATUS</span>
|
||||
<span class="tile-meta">HTTP 404 • NOT FOUND</span>
|
||||
</header>
|
||||
|
||||
<div class="not-found-body">
|
||||
<h1 class="not-found-code" id="not-found-title">404<span class="not-found-cursor" aria-hidden="true">_</span></h1>
|
||||
<div class="not-found-message">
|
||||
<p class="not-found-label">REQUEST FAILED / NO MATCHING ROUTE</p>
|
||||
<h2>Page not found.</h2>
|
||||
<p>The address you requested doesn't point to a page here. Check the URL, or choose a known route below.</p>
|
||||
<nav class="not-found-links" aria-label="Recovery links">
|
||||
<a href="/">> Back to home</a>
|
||||
<a href="/dispatches/">> Browse dispatches</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,17 @@
|
||||
import { DateTime } from "luxon";
|
||||
import markdownIt from "markdown-it";
|
||||
|
||||
/**
|
||||
* Custom Eleventy Filters for River Rooks Blog
|
||||
*/
|
||||
|
||||
const excerptMarkdown = markdownIt({ html: false });
|
||||
|
||||
export const renderExcerptMarkdown = (excerpt) => {
|
||||
if (typeof excerpt !== "string") return "";
|
||||
return excerptMarkdown.renderInline(excerpt);
|
||||
};
|
||||
|
||||
export const postDate = (dateObj) => {
|
||||
if (!dateObj) return "";
|
||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||
@@ -92,6 +100,7 @@ export const removeTerminalPreview = (htmlContent) => {
|
||||
};
|
||||
|
||||
export const filters = {
|
||||
renderExcerptMarkdown,
|
||||
postDate,
|
||||
isoDate,
|
||||
limit,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</h2>
|
||||
|
||||
<p class="featured-prose">
|
||||
{{ featured.data.excerpt }}
|
||||
{{ featured.data.excerpt | renderExcerptMarkdown | safe }}
|
||||
</p>
|
||||
|
||||
{% if featured.data.terminal == true %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</h3>
|
||||
|
||||
{% if post.data.excerpt %}
|
||||
<p class="feed-excerpt">{{ post.data.excerpt }}</p>
|
||||
<p class="feed-excerpt">{{ post.data.excerpt | renderExcerptMarkdown | safe }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="feed-tags-row">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Missing route: a compact diagnostic panel in the site's tiling vocabulary. */
|
||||
.not-found {
|
||||
gap: 0;
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.not-found-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.not-found-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(5rem, 14vw, 10rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.09em;
|
||||
color: var(--accent-amber);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.not-found-cursor {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.not-found-message {
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding: 16px 0 16px 24px;
|
||||
max-width: 48ch;
|
||||
}
|
||||
|
||||
.not-found-label {
|
||||
font: 500 0.75rem var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-amber);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.not-found-message h2 {
|
||||
font: 700 clamp(1.3rem, 3vw, 2rem) / 1.25 var(--font-mono);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.not-found-message > p:not(.not-found-label) {
|
||||
color: var(--text-muted);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.not-found-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
margin-top: 24px;
|
||||
font: 500 0.85rem var(--font-mono);
|
||||
}
|
||||
|
||||
.not-found-links a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.not-found-links a:hover {
|
||||
color: var(--bg-canvas);
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.not-found {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.not-found-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 32px 0 8px;
|
||||
}
|
||||
|
||||
.not-found-message {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,4 @@
|
||||
@import "./components/code-box.css";
|
||||
@import "./components/article.css";
|
||||
@import "./components/footer.css";
|
||||
@import "./components/not-found.css";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import nunjucks from "nunjucks";
|
||||
import path from "node:path";
|
||||
import { filters } from "../src/_filters/index.js";
|
||||
|
||||
const includesDir = path.resolve("src/_includes");
|
||||
const markdownExcerpt = "Read the [deployment guide](/guides/deploy/) before continuing.";
|
||||
|
||||
function renderTemplate(template, context) {
|
||||
const env = new nunjucks.Environment(new nunjucks.FileSystemLoader(includesDir), { autoescape: false });
|
||||
for (const [name, filter] of Object.entries(filters)) {
|
||||
env.addFilter(name, filter);
|
||||
}
|
||||
|
||||
return env.render(template, context);
|
||||
}
|
||||
|
||||
test("Excerpt Markdown: renders Markdown links as safe inline HTML", () => {
|
||||
assert.equal(
|
||||
filters.renderExcerptMarkdown(markdownExcerpt),
|
||||
'Read the <a href="/guides/deploy/">deployment guide</a> before continuing.'
|
||||
);
|
||||
});
|
||||
|
||||
test("Excerpt Markdown: renders links in post-card previews", () => {
|
||||
const output = renderTemplate("partials/post-card.njk", {
|
||||
post: {
|
||||
fileSlug: "example-dispatch",
|
||||
url: "/dispatches/example/",
|
||||
date: "2026-10-06",
|
||||
data: {
|
||||
title: "Example dispatch",
|
||||
excerpt: markdownExcerpt,
|
||||
tags: ["systems"]
|
||||
},
|
||||
templateContent: "A short article."
|
||||
}
|
||||
});
|
||||
|
||||
assert.match(output, /<p class="feed-excerpt">\s*Read the <a href="\/guides\/deploy\/">deployment guide<\/a> before continuing\.\s*<\/p>/);
|
||||
});
|
||||
|
||||
test("Excerpt Markdown: renders links in featured previews and escapes raw HTML", () => {
|
||||
const output = renderTemplate("partials/featured-card.njk", {
|
||||
collections: {
|
||||
posts: [{
|
||||
url: "/dispatches/example/",
|
||||
date: "2026-10-06",
|
||||
data: {
|
||||
featured: true,
|
||||
tags: ["systems"],
|
||||
title: "Example dispatch",
|
||||
excerpt: `${markdownExcerpt} <script>alert('unsafe')</script>`
|
||||
},
|
||||
templateContent: ""
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
assert.match(output, /<a href="\/guides\/deploy\/">deployment guide<\/a>/);
|
||||
assert.match(output, /<script>alert\('unsafe'\)<\/script>/);
|
||||
assert.doesNotMatch(output, /<script>/);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
filterTagList,
|
||||
isoDate,
|
||||
postDate,
|
||||
readingTime
|
||||
readingTime,
|
||||
renderExcerptMarkdown
|
||||
} from "../src/_filters/index.js";
|
||||
|
||||
const includesDir = path.resolve("src/_includes");
|
||||
@@ -19,6 +20,7 @@ function renderFeaturedCard(terminal) {
|
||||
env.addFilter("isoDate", isoDate);
|
||||
env.addFilter("postDate", postDate);
|
||||
env.addFilter("readingTime", readingTime);
|
||||
env.addFilter("renderExcerptMarkdown", renderExcerptMarkdown);
|
||||
|
||||
const featured = {
|
||||
url: "/dispatches/example/",
|
||||
|
||||
@@ -3,10 +3,27 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { createServer, triggerEleventyBuild } from "../server.js";
|
||||
import { createServer, normalizeObsidianMarkdown, triggerEleventyBuild } from "../server.js";
|
||||
|
||||
const TEST_SECRET = "super-secret-test-token";
|
||||
|
||||
test("Obsidian normalization: preserves linked note header fragments", () => {
|
||||
const normalized = normalizeObsidianMarkdown(
|
||||
"See [[a note#header one]] and [[another note#Second Header|the other section]]."
|
||||
);
|
||||
|
||||
assert.match(
|
||||
normalized,
|
||||
/\[a note#header one\]\(\/posts\/a-note\/#header-one\)/,
|
||||
"A wikilink header must become a URL fragment on the linked note"
|
||||
);
|
||||
assert.match(
|
||||
normalized,
|
||||
/\[the other section\]\(\/posts\/another-note\/#second-header\)/,
|
||||
"Aliased wikilinks must retain their linked-note header fragment"
|
||||
);
|
||||
});
|
||||
|
||||
test("Ingestion API: rejects requests without proper Bearer token", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const siteDir = path.resolve("_site");
|
||||
|
||||
test("unknown routes render the themed 404 page with a real 404 status", async () => {
|
||||
execFileSync("npm", ["run", "build"], {
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, SITE_DOMAIN: process.env.SITE_DOMAIN || "riverrooks.dev" }
|
||||
});
|
||||
|
||||
const html = fs.readFileSync(path.join(siteDir, "404.html"), "utf8");
|
||||
assert.match(html, /<title>Page not found/);
|
||||
assert.match(html, /<h1[^>]*>[^<]*404/);
|
||||
assert.match(html, /href="\/"/);
|
||||
assert.match(html, /href="\/dispatches\/"/);
|
||||
assert.match(html, /href="\/assets\/css\/main\.css/);
|
||||
|
||||
const css = fs.readFileSync(path.join(siteDir, "assets/css/main.css"), "utf8");
|
||||
assert.match(css, /components\/not-found\.css/);
|
||||
const pageCss = fs.readFileSync(path.join(siteDir, "assets/css/components/not-found.css"), "utf8");
|
||||
assert.doesNotMatch(pageCss, /(?:backdrop-filter|box-shadow|text-shadow|linear-gradient|radial-gradient)\s*:/);
|
||||
|
||||
const server = createServer(siteDir, path.resolve("src/posts"), path.resolve("src/assets/images/posts"));
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
for (const route of ["/missing-page/", "/assets/missing.css"]) {
|
||||
const response = await fetch(`${origin}${route}`);
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(response.headers.get("content-type"), /text\/html/);
|
||||
assert.match(await response.text(), /Page not found/);
|
||||
}
|
||||
const home = await fetch(origin);
|
||||
assert.equal(home.status, 200);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -41,11 +41,7 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||
assert.ok(gitService, "Gitea service exists");
|
||||
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
||||
|
||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
||||
assert.ok(chefService, "CyberChef service exists");
|
||||
assert.equal(chefService.url, "https://chef.testcluster.internal");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:443");
|
||||
|
||||
// Verify navigation links
|
||||
const navHome = site.nav.find((n) => n.label === "Home");
|
||||
|
||||
Reference in New Issue
Block a user