f53f198f03
website/ holds Horizon's site: the horizon-theme tokens and bundled fonts, the launcher's sidebar, rows, buttons and segmented control, over Horizon's own frozen landscape. The parity table, harness count and roadmap are generated from the README at build time; releases, patch notes, the roadmap and links.json are refreshed in the browser from GitHub, with the build's copy as fallback. The download button picks the newest launcher-v* asset for the visitor's OS. Legal: the Minecraft disclaimer in the hero, footer and 404; no Minecraft logo or wordmark; no game files; the launcher's background (licence unconfirmed) and the screenshots showing it are left out; LEGAL.md lists every asset and its licence. No cookies, analytics or third-party assets. Railway: root directory website, Dockerfile (Node build, Caddy on $PORT with a strict CSP). Checked with Playwright at 360-1920 px and 1250x700 (docs/widths/); Lighthouse 96/100/96/92 on mobile, 100/100/96/92 on desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
5.3 KiB
JavaScript
147 lines
5.3 KiB
JavaScript
// A small Markdown renderer for release notes and the README: headings, paragraphs,
|
|
// lists (nested by indentation), fenced code, quotes, rules, and inline code, bold,
|
|
// italic and links. Everything is escaped first, so its output never carries raw HTML.
|
|
// Shared by build.mjs (Node) and main.js (browser).
|
|
|
|
const REPO_BLOB = "https://github.com/ludovic111/horizon/blob/main/";
|
|
|
|
export function escape(text) {
|
|
return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
}
|
|
|
|
function href(url) {
|
|
url = url.trim();
|
|
if (/^(https?:|mailto:)/i.test(url)) return url;
|
|
if (url.startsWith("#")) return null; // README anchors have no target here
|
|
if (/^[a-z]+:/i.test(url)) return null; // javascript: and the like
|
|
return REPO_BLOB + url.replace(/^\.?\//, "");
|
|
}
|
|
|
|
export function inline(text) {
|
|
const codes = [];
|
|
let out = text.replace(/`([^`]+)`/g, (_, code) => {
|
|
codes.push(code);
|
|
return `\u0000${codes.length - 1}\u0000`;
|
|
});
|
|
const links = [];
|
|
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|
links.push([label, url]);
|
|
return `\u0001${links.length - 1}\u0001`;
|
|
});
|
|
out = escape(out)
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>")
|
|
.replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1<em>$2</em>")
|
|
.replace(/<(https?:\/\/[^\s&]+)>/g, (_, u) => `<a href="${u}">${u}</a>`);
|
|
out = out.replace(/\u0001(\d+)\u0001/g, (_, i) => {
|
|
const [label, url] = links[i];
|
|
const target = href(url);
|
|
const body = inline(label);
|
|
return target ? `<a href="${escape(target)}">${body}</a>` : body;
|
|
});
|
|
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escape(codes[i])}</code>`);
|
|
}
|
|
|
|
const indentOf = (line) => line.match(/^ */)[0].length;
|
|
const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
|
|
|
function list(lines, start) {
|
|
// Collects the items at this list's indentation; deeper lines belong to the item above.
|
|
const base = indentOf(lines[start]);
|
|
const ordered = /\d/.test(lines[start].match(ITEM)[2]);
|
|
const items = [];
|
|
let i = start;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
if (!line.trim()) {
|
|
const next = lines.slice(i + 1).find((l) => l.trim());
|
|
if (next && ITEM.test(next) && indentOf(next) >= base) {
|
|
i++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
const m = line.match(ITEM);
|
|
if (m && indentOf(line) === base) {
|
|
items.push({ text: [m[3]], children: [] });
|
|
} else if (m && indentOf(line) > base) {
|
|
const [html, end] = list(lines, i);
|
|
items[items.length - 1].children.push(html);
|
|
i = end;
|
|
continue;
|
|
} else if (indentOf(line) > base && items.length) {
|
|
items[items.length - 1].text.push(line.trim());
|
|
} else {
|
|
break;
|
|
}
|
|
i++;
|
|
}
|
|
const tag = ordered ? "ol" : "ul";
|
|
const body = items.map((it) => `<li>${inline(it.text.join(" "))}${it.children.join("")}</li>`).join("");
|
|
return [`<${tag}>${body}</${tag}>`, i];
|
|
}
|
|
|
|
/** Renders Markdown; `shift` moves headings down (a `#` becomes `h{1 + shift}`). */
|
|
export function markdown(source, { shift = 0 } = {}) {
|
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
const out = [];
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
if (!line.trim() || /^\s*<\/?[a-z][^>]*>\s*$/i.test(line)) {
|
|
i++; // blank lines and lone HTML tags (the README's layout tables) are skipped
|
|
continue;
|
|
}
|
|
let m;
|
|
if ((m = line.match(/^(#{1,6})\s+(.*?)\s*#*$/))) {
|
|
const level = Math.min(6, m[1].length + shift);
|
|
out.push(`<h${level}>${inline(m[2])}</h${level}>`);
|
|
i++;
|
|
} else if ((m = line.match(/^\s*(```|~~~)/))) {
|
|
const fence = m[1];
|
|
const code = [];
|
|
i++;
|
|
while (i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i++]);
|
|
i++;
|
|
out.push(`<pre><code>${escape(code.join("\n"))}</code></pre>`);
|
|
} else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
out.push("<hr>");
|
|
i++;
|
|
} else if (ITEM.test(line)) {
|
|
const [html, end] = list(lines, i);
|
|
out.push(html);
|
|
i = end;
|
|
} else if (line.startsWith(">")) {
|
|
const quote = [];
|
|
while (i < lines.length && lines[i].startsWith(">")) quote.push(lines[i++].replace(/^>\s?/, ""));
|
|
out.push(`<blockquote>${markdown(quote.join("\n"), { shift })}</blockquote>`);
|
|
} else {
|
|
const para = [];
|
|
while (
|
|
i < lines.length &&
|
|
lines[i].trim() &&
|
|
!/^(#{1,6}\s|>|\s*```|\s*~~~)/.test(lines[i]) &&
|
|
!ITEM.test(lines[i])
|
|
) {
|
|
para.push(lines[i++].trim());
|
|
}
|
|
out.push(`<p>${inline(para.join(" "))}</p>`);
|
|
}
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** The body of the section titled `title` (any heading level), up to the next heading at its level or above. */
|
|
export function section(source, title) {
|
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
const level = (l) => (l.match(/^(#{1,6})\s/) || [, ""])[1].length;
|
|
const start = lines.findIndex((l) => level(l) > 0 && l.replace(/^#+/, "").trim() === title);
|
|
if (start < 0) return null;
|
|
const depth = level(lines[start]);
|
|
const end = lines.findIndex((l, i) => i > start && level(l) > 0 && level(l) <= depth);
|
|
return lines
|
|
.slice(start + 1, end < 0 ? undefined : end)
|
|
.join("\n")
|
|
.trim();
|
|
}
|