Merge branch 'feat-local-header-links' into dev
This commit is contained in:
@@ -57,20 +57,32 @@ export function normalizeObsidianMarkdown(content) {
|
|||||||
|
|
||||||
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
||||||
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
||||||
const slug = slugify(target);
|
return `[${label.trim()}](${wikilinkUrl(target)})`;
|
||||||
return `[${label.trim()}](/posts/${slug}/)`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
||||||
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
||||||
const trimmed = target.trim();
|
const trimmed = target.trim();
|
||||||
const slug = slugify(trimmed);
|
return `[${trimmed}](${wikilinkUrl(trimmed)})`;
|
||||||
return `[${trimmed}](/posts/${slug}/)`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return normalized;
|
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) {
|
function slugify(text) {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import markdownIt from "markdown-it";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Eleventy Filters for River Rooks Blog
|
* 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) => {
|
export const postDate = (dateObj) => {
|
||||||
if (!dateObj) return "";
|
if (!dateObj) return "";
|
||||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||||
@@ -92,6 +100,7 @@ export const removeTerminalPreview = (htmlContent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const filters = {
|
export const filters = {
|
||||||
|
renderExcerptMarkdown,
|
||||||
postDate,
|
postDate,
|
||||||
isoDate,
|
isoDate,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="featured-prose">
|
<p class="featured-prose">
|
||||||
{{ featured.data.excerpt }}
|
{{ featured.data.excerpt | renderExcerptMarkdown | safe }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if featured.data.terminal == true %}
|
{% if featured.data.terminal == true %}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{% if post.data.excerpt %}
|
{% if post.data.excerpt %}
|
||||||
<p class="feed-excerpt">{{ post.data.excerpt }}</p>
|
<p class="feed-excerpt">{{ post.data.excerpt | renderExcerptMarkdown | safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="feed-tags-row">
|
<div class="feed-tags-row">
|
||||||
|
|||||||
@@ -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,
|
filterTagList,
|
||||||
isoDate,
|
isoDate,
|
||||||
postDate,
|
postDate,
|
||||||
readingTime
|
readingTime,
|
||||||
|
renderExcerptMarkdown
|
||||||
} from "../src/_filters/index.js";
|
} from "../src/_filters/index.js";
|
||||||
|
|
||||||
const includesDir = path.resolve("src/_includes");
|
const includesDir = path.resolve("src/_includes");
|
||||||
@@ -19,6 +20,7 @@ function renderFeaturedCard(terminal) {
|
|||||||
env.addFilter("isoDate", isoDate);
|
env.addFilter("isoDate", isoDate);
|
||||||
env.addFilter("postDate", postDate);
|
env.addFilter("postDate", postDate);
|
||||||
env.addFilter("readingTime", readingTime);
|
env.addFilter("readingTime", readingTime);
|
||||||
|
env.addFilter("renderExcerptMarkdown", renderExcerptMarkdown);
|
||||||
|
|
||||||
const featured = {
|
const featured = {
|
||||||
url: "/dispatches/example/",
|
url: "/dispatches/example/",
|
||||||
|
|||||||
@@ -3,10 +3,27 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import http from "node:http";
|
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";
|
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 () => {
|
test("Ingestion API: rejects requests without proper Bearer token", async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
|||||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||||
assert.ok(gitService, "Gitea service exists");
|
assert.ok(gitService, "Gitea service exists");
|
||||||
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
assert.equal(gitService.tag, "git.testcluster.internal:443");
|
||||||
|
|
||||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
|
||||||
assert.ok(chefService, "CyberChef service exists");
|
|
||||||
assert.equal(chefService.url, "https://chef.testcluster.internal");
|
|
||||||
|
|
||||||
// Verify navigation links
|
// Verify navigation links
|
||||||
const navHome = site.nav.find((n) => n.label === "Home");
|
const navHome = site.nav.find((n) => n.label === "Home");
|
||||||
|
|||||||
Reference in New Issue
Block a user