Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions test/viewer-ui-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ describe("viewer UI assets", () => {
const indexHtml = await readFile(join(process.cwd(), "viewer", "index.html"), "utf8");
const logo = await readFile(join(process.cwd(), "viewer", "almanac-logo.png"));
const appJs = await readFile(join(process.cwd(), "viewer", "app.js"), "utf8");
const markdownJs = await readFile(join(process.cwd(), "viewer", "markdown.js"), "utf8");
const markedEsm = await readFile(join(process.cwd(), "viewer", "vendor", "marked.esm.js"));
const routesJs = await readFile(join(process.cwd(), "viewer", "routes.js"), "utf8");
const jobsJs = await readFile(join(process.cwd(), "viewer", "jobs-view.js"), "utf8");
const jobsTranscriptJs = await readFile(join(process.cwd(), "viewer", "jobs-transcript.js"), "utf8");
Expand Down Expand Up @@ -52,12 +54,14 @@ describe("viewer UI assets", () => {
expect(appJs).toContain("event.state");
expect(appJs).toContain("Back");
expect(appJs).not.toContain("ca-page-header");
expect(appJs).toContain("renderHeading");
expect(appJs).toContain("renderMarkdown(page.body, { decorateTitle: true, summary: page.summary })");
expect(appJs).toContain("function renderMarkdown(source, options = {})");
expect(appJs).toContain("renderArticleSummary");
expect(appJs).toContain('const level = decorated ? "h1" : "h2";');
expect(appJs).toContain("ca-page-ornament");
expect(appJs).toContain('import { createMarkdown } from "./markdown.js"');
expect(appJs).toContain("createMarkdown({");
expect(appJs).not.toContain("function renderMarkdown(source");
expect(markdownJs).toContain("export function createMarkdown");
expect(markdownJs).toContain('from "./vendor/marked.esm.js"');
expect(markdownJs).toContain("ca-page-ornament");
expect(markedEsm.byteLength).toBeGreaterThan(0);
expect(appJs).toContain('wikiPath === "/jobs"');
expect(appJs).toContain('wikiPath.startsWith("/jobs/")');
expect(appJs).toContain('import { createJobsView } from "./jobs-view.js"');
Expand Down
69 changes: 69 additions & 0 deletions viewer/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,75 @@ a { color: var(--ca-accent); text-decoration: none; }
color: inherit;
}

.ca-prose ul,
.ca-prose ol {
margin: 0 0 16px;
padding-left: 26px;
}

.ca-prose li { margin: 0 0 6px; }

.ca-prose li > ul,
.ca-prose li > ol {
margin: 6px 0 0;
}

.ca-prose ul ul { list-style-type: circle; }

.ca-prose li:has(> input[type="checkbox"]) { list-style: none; }

.ca-prose li input[type="checkbox"] {
margin: 0 8px 0 -22px;
}

.ca-prose strong { font-weight: 600; }

.ca-prose em { font-style: italic; }

.ca-prose del { text-decoration: line-through; }

.ca-prose blockquote {
margin: 18px 0;
padding: 4px 18px;
border-left: 3px solid var(--ca-border);
color: var(--ca-muted);
}

.ca-prose blockquote p:last-child { margin-bottom: 0; }

.ca-prose hr {
margin: 32px 0;
border: 0;
border-top: 1px solid var(--ca-border-light);
}

.ca-prose table {
width: 100%;
margin: 18px 0;
border-collapse: collapse;
font-family: var(--ca-serif);
font-size: 15px;
}

.ca-prose th,
.ca-prose td {
padding: 8px 12px;
border: 1px solid var(--ca-border-light);
text-align: left;
line-height: 1.6;
}

.ca-prose thead th {
background: var(--ca-surface-deep);
font-weight: 600;
}

.ca-prose img {
max-width: 100%;
height: auto;
border-radius: var(--ca-radius);
}

/* ────────────────────────────────────────────────────────────
Right rail (page detail only)
──────────────────────────────────────────────────────────── */
Expand Down
96 changes: 21 additions & 75 deletions viewer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
wikiRoute as buildWikiRoute,
} from "./routes.js";
import { createSearchSuggestions } from "./search-suggestions.js";
import { createMarkdown } from "./markdown.js";

const state = {
wikis: [],
Expand All @@ -20,6 +21,26 @@ const state = {
historyIndex: 0,
};

const { renderMarkdown } = createMarkdown({
resolveWikilink: (target) => ({
href: routeForWikilink(target, state.currentWiki),
label: labelForWikilink(target, pageLabel),
}),
resolveLink: (href) => {
if (/^https?:\/\//i.test(href)) return { type: "external", href };
const slug = pageSlugFromMarkdownTarget(href);
if (slug !== null) {
const known = pageLabel(slug);
return {
type: "page",
route: wikiRoute(`/page/${encodeURIComponent(slug)}`),
label: known === slug ? null : known,
};
}
return { type: "dead" };
},
});

const els = {
shell: document.querySelector("#app"),
reader: document.querySelector("#reader"),
Expand Down Expand Up @@ -487,11 +508,6 @@ function renderPageArticle(page) {
`;
}

function renderArticleSummary(summary) {
const text = summary?.trim();
return text ? `<p class="ca-article-summary">${escapeHtml(text)}</p>` : "";
}

async function renderTopic(slug) {
const topic = await api(wikiApi(`/topic/${encodeURIComponent(slug)}`));
rememberPages(topic.pages);
Expand Down Expand Up @@ -635,76 +651,6 @@ function renderPageActions(fallbackRoute) {
`;
}

function renderMarkdown(source, options = {}) {
const blocks = [];
let inCode = false;
let code = [];
let decoratedHeading = false;
const decorateTitle = options.decorateTitle === true;

for (const line of source.split(/\r?\n/)) {
if (line.startsWith("```")) {
if (inCode) {
blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
code = [];
inCode = false;
} else {
inCode = true;
}
continue;
}
if (inCode) {
code.push(line);
continue;
}
if (line.trim().length === 0) {
blocks.push("");
continue;
}
if (line.startsWith("### ")) blocks.push(`<h3>${inline(line.slice(4))}</h3>`);
else if (line.startsWith("## ")) blocks.push(`<h2>${inline(line.slice(3))}</h2>`);
else if (line.startsWith("# ")) {
blocks.push(renderHeading(line.slice(2), decorateTitle && !decoratedHeading, options.summary));
decoratedHeading = true;
}
else if (line.startsWith("- ")) blocks.push(`<p>• ${inline(line.slice(2))}</p>`);
else blocks.push(`<p>${inline(line)}</p>`);
}
if (inCode) blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
return blocks.filter(Boolean).join("\n");
}

function renderHeading(text, decorated, summary = null) {
const level = decorated ? "h1" : "h2";
const heading = `<${level}>${inline(text)}</${level}>`;
if (!decorated) return heading;
return `${heading}\n${renderArticleSummary(summary)}\n<div class="ca-page-ornament" aria-hidden="true"><span>✥</span></div>`;
}

function inline(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\[\[([^\]]+)\]\]/g, (_, target) => {
const route = routeForWikilink(target, state.currentWiki);
const label = labelForWikilink(target, pageLabel);
return `<a href="${escapeAttr(route)}" data-route="${escapeAttr(route)}">${escapeHtml(label)}</a>`;
})
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, target) => renderMarkdownLink(label, target));
}

function renderMarkdownLink(label, target) {
if (/^https?:\/\//.test(target)) {
return `<a href="${escapeAttr(target)}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>`;
}
const pageSlug = pageSlugFromMarkdownTarget(target);
if (pageSlug !== null) {
const route = wikiRoute(`/page/${encodeURIComponent(pageSlug)}`);
const text = pageLabel(pageSlug) === pageSlug ? label.replace(/\.md$/, "") : pageLabel(pageSlug);
return `<a href="${escapeAttr(route)}" data-route="${escapeAttr(route)}">${escapeHtml(text)}</a>`;
}
return `<span>${escapeHtml(label)}</span>`;
}

function pageSlugFromMarkdownTarget(target) {
const normalized = String(target).replace(/\\/g, "/");
const match = normalized.match(/(?:^|\/)(?:\.almanac\/)?pages\/([^/]+)\.md$/)
Expand Down
117 changes: 117 additions & 0 deletions viewer/markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Marked } from "./vendor/marked.esm.js";

// Markdown rendering for the viewer, backed by `marked` (vendored).
//
// Four deliberate departures from stock `marked`:
// - `[[wikilinks]]` are a custom inline token resolved against the viewer's
// routing helpers (see routes.js).
// - Standard `[text](target)` links are resolved through `resolveLink` so a
// target pointing at a `pages/*.md` file becomes an in-app page route, an
// `http(s)` target opens in a new tab, and an unresolvable target renders
// as plain text. This mirrors the previous hand-rolled renderer.
// - Raw inline/block HTML is escaped, not passed through. Almanac pages are
// AI-authored markdown; rendering arbitrary HTML into innerHTML is not worth
// the XSS surface. This matches the previous hand-rolled renderer.
// - The first `# heading` of a page article is rendered as the decorated
// title (summary + ornament); other level-1 headings are demoted to h2 so
// they don't read as a second page title.
//
// `resolveWikilink(target)` returns `{ href, label }`.
// `resolveLink(href)` returns one of:
// { type: "external", href } — opens in a new tab
// { type: "page", route, label } — in-app route; label null falls back to
// the link text (with a trailing .md trimmed)
// { type: "dead" } — rendered as plain, non-clickable text

export function createMarkdown({ resolveWikilink, resolveLink }) {
const md = new Marked({ gfm: true, breaks: false });

md.use({
extensions: [
{
name: "wikilink",
level: "inline",
start(src) {
const index = src.indexOf("[[");
return index < 0 ? undefined : index;
},
tokenizer(src) {
const match = /^\[\[([^\]]+)\]\]/.exec(src);
if (match === null) return undefined;
return { type: "wikilink", raw: match[0], target: match[1].trim() };
},
renderer(token) {
const { href, label } = resolveWikilink(token.target);
return `<a href="${escapeAttr(href)}" data-route="${escapeAttr(href)}">${escapeHtml(label)}</a>`;
},
},
],
renderer: {
heading(token) {
const inner = this.parser.parseInline(token.tokens);
if (token.isArticleTitle === true) {
return `<h1>${inner}</h1>\n${articleSummary(token.articleSummary)}\n<div class="ca-page-ornament" aria-hidden="true"><span>✥</span></div>`;
}
const level = token.depth <= 2 ? 2 : token.depth;
return `<h${level}>${inner}</h${level}>`;
},
html(token) {
return escapeHtml(token.text);
},
link(token) {
const inner = this.parser.parseInline(token.tokens);
const resolved = resolveLink(token.href ?? "");
if (resolved.type === "external") {
return `<a href="${escapeAttr(resolved.href)}" target="_blank" rel="noreferrer">${inner}</a>`;
}
if (resolved.type === "page") {
const text = resolved.label != null ? escapeHtml(resolved.label) : inner.replace(/\.md$/, "");
return `<a href="${escapeAttr(resolved.route)}" data-route="${escapeAttr(resolved.route)}">${text}</a>`;
}
return `<span>${inner}</span>`;
},
},
});

function renderMarkdown(source, options = {}) {
if (typeof source !== "string" || source.trim().length === 0) return "";
const decorateTitle = options.decorateTitle === true;
const summary = options.summary ?? null;
let titleMarked = false;
return md.parse(source, {
walkTokens(token) {
if (
decorateTitle &&
!titleMarked &&
token.type === "heading" &&
token.depth === 1
) {
titleMarked = true;
token.isArticleTitle = true;
token.articleSummary = summary;
}
},
});
}

return { renderMarkdown };
}

function articleSummary(summary) {
const text = (summary ?? "").trim();
return text ? `<p class="ca-article-summary">${escapeHtml(text)}</p>` : "";
}

function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}

function escapeAttr(value) {
return escapeHtml(value);
}
Loading