{post.summary == null || post.summary.trim() === "" ? (
-
+
) : (
)}
@@ -342,6 +354,7 @@ interface PostContentProps {
| null;
};
readonly featured?: boolean;
+ readonly canonical?: boolean;
readonly baseUrl: URL | string;
}
@@ -360,7 +373,7 @@ function getEffectiveQuoteState(
return state;
}
-function PostContent({ post, featured, baseUrl }: PostContentProps) {
+function PostContent({ post, featured, canonical, baseUrl }: PostContentProps) {
const quoteState = getEffectiveQuoteState(post);
const quoteDisplayable =
quoteState === "accepted" && post.quoteTarget != null;
@@ -368,7 +381,9 @@ function PostContent({ post, featured, baseUrl }: PostContentProps) {
? stripQuoteInlineFallbacks(post.contentHtml)
: post.contentHtml;
const contentHtml = renderCustomEmojis(
- displayContentHtml,
+ canonical || displayContentHtml == null
+ ? displayContentHtml
+ : stripFootnoteIds(displayContentHtml),
post.emojis,
baseUrl,
);
diff --git a/src/custom-emoji.test.ts b/src/custom-emoji.test.ts
new file mode 100644
index 00000000..761c8d09
--- /dev/null
+++ b/src/custom-emoji.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import { renderCustomEmojis } from "./custom-emoji";
+
+const emojis = { smile: "https://example.com/smile.png" };
+const baseUrl = "https://hollo.example/";
+
+describe("renderCustomEmojis", () => {
+ it("preserves MathML presentation and TeX annotations", () => {
+ const math =
+ '
';
+ const result = renderCustomEmojis(math + " :smile:", emojis, baseUrl);
+ expect(result).toContain(math);
+ expect(result.match(/
![]()
{
+ const fallback = `<${tag} class="hollo-math hollo-markdown-preserve">$<${tag}>:smile:${tag}> :smile:$${tag}>`;
+ const result = renderCustomEmojis(fallback + " :smile:", emojis, baseUrl);
+ expect(result).toContain(fallback);
+ expect(result.match(/
![]()
{
+ const math =
+ "
:smile:";
+ expect(renderCustomEmojis(math, emojis, baseUrl)).toBe(math);
+ });
+
+ it("continues rendering ordinary text and code while preserving attributes", () => {
+ const result = renderCustomEmojis(
+ '
:smile::smile:',
+ emojis,
+ baseUrl,
+ );
+ expect(result).toContain('title=":smile: >"');
+ expect(result.match(/
![]()
{
+ expect(
+ renderCustomEmojis(
+ '',
+ emojis,
+ baseUrl,
+ ),
+ ).toContain("
![class=hollo-math]()
:smile:',
+ emojis,
+ baseUrl,
+ ),
+ ).toContain("
![]()
{
+ expect(renderCustomEmojis(":missing:", emojis, baseUrl)).toBe(":missing:");
+ expect(renderCustomEmojis(null, emojis, baseUrl)).toBeNull();
+ });
+});
diff --git a/src/custom-emoji.ts b/src/custom-emoji.ts
index 8bd737bb..2c4d6ef3 100644
--- a/src/custom-emoji.ts
+++ b/src/custom-emoji.ts
@@ -1,7 +1,9 @@
+import { MATH_CLASS } from "./markdown-math";
import { proxyUrl } from "./media-proxy";
const CUSTOM_EMOJI_REGEXP = /:([a-z0-9_-]+):/gi;
-const HTML_ELEMENT_REGEXP = /<\/?[^>]+>/g;
+const HTML_ELEMENT_REGEXP =
+ /|<\/?[a-z][a-z0-9:-]*(?:"[^"]*"|'[^']*'|[^'">])*>/gi;
export { CUSTOM_EMOJI_REGEXP };
@@ -29,12 +31,49 @@ export function renderCustomEmojis(
if (html == null) return null;
let result = "";
let index = 0;
+ let protectedTag: string | undefined;
+ let protectedDepth = 0;
for (const match of html.matchAll(HTML_ELEMENT_REGEXP)) {
- result += replaceEmojis(html.substring(index, match.index));
+ const text = html.substring(index, match.index);
+ result += protectedDepth > 0 ? text : replaceEmojis(text);
result += match[0];
index = match.index + match[0].length;
+ const tag = /^<(\/)?([a-z][a-z0-9:-]*)\b/i.exec(match[0]);
+ if (tag == null) continue;
+ const name = tag[2].toLowerCase();
+ if (protectedDepth > 0) {
+ if (name === protectedTag) {
+ if (tag[1]) protectedDepth--;
+ else if (!/\/\s*>$/.test(match[0])) protectedDepth++;
+ }
+ } else if (!tag[1] && !/\/\s*>$/.test(match[0])) {
+ // Consume whole attribute values so a class-looking string inside a
+ // title or other quoted value cannot suppress ordinary emoji rendering.
+ const attributes = match[0]
+ .substring(tag[0].length)
+ .matchAll(
+ /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g,
+ );
+ let classes: string[] = [];
+ for (const attribute of attributes) {
+ if (attribute[1].toLowerCase() === "class") {
+ classes = (attribute[2] ?? attribute[3] ?? attribute[4] ?? "").split(
+ /\s+/,
+ );
+ break;
+ }
+ }
+ if (
+ name === "math" ||
+ ((name === "span" || name === "div") && classes.includes(MATH_CLASS))
+ ) {
+ protectedTag = name;
+ protectedDepth = 1;
+ }
+ }
}
- result += replaceEmojis(html.substring(index));
+ const tail = html.substring(index);
+ result += protectedDepth > 0 ? tail : replaceEmojis(tail);
return result;
function replaceEmojis(input: string): string {
diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts
index 0626aeda..6e82babe 100644
--- a/src/federation/post.test.ts
+++ b/src/federation/post.test.ts
@@ -1093,6 +1093,43 @@ describe("persistPost", () => {
]);
});
+ it.each(["id", "url", "link"] as const)(
+ "does not fetch footnote navigation as a preview through the object %s",
+ async (kind) => {
+ const author = await seedRemoteAccount("author");
+ const id = new URL(`https://remote.test/@author/posts/footnote-${kind}`);
+ const display = new URL(`https://remote.test/display/footnote-${kind}`);
+ const target = kind === "id" ? id : display;
+ const preview = vi
+ .spyOn(await import("../previewcard"), "fetchPreviewCard")
+ .mockResolvedValue(null);
+ try {
+ const result = await persistPost(
+ db,
+ new Note({
+ id,
+ url:
+ kind === "id"
+ ? null
+ : kind === "url"
+ ? display
+ : new Link({ href: display }),
+ attribution: createPerson(author),
+ content: `
Text[1]
`,
+ to: PUBLIC_COLLECTION,
+ }),
+ "https://hollo.test",
+ { account: author },
+ );
+ expect(result).not.toBeNull();
+ expect(result?.previewCard).toBeNull();
+ expect(preview).not.toHaveBeenCalled();
+ } finally {
+ preview.mockRestore();
+ }
+ },
+ );
+
it("stores the language when content is accompanied by contentMap", async () => {
expect.assertions(2);
const author = await seedRemoteAccount("author");
diff --git a/src/federation/post.ts b/src/federation/post.ts
index 2700f443..a1585321 100644
--- a/src/federation/post.ts
+++ b/src/federation/post.ts
@@ -434,6 +434,12 @@ export async function persistPost(
: await extractPreviewLink(
object.content.toString(),
previewLinkExclusions,
+ [
+ object.id?.href,
+ object.url instanceof Link
+ ? object.url.href?.href
+ : object.url?.href,
+ ].filter((value): value is string => value != null),
);
const previewCard =
previewLink == null ? null : await fetchPreviewCard(previewLink);
diff --git a/src/gukhanmun.test.ts b/src/gukhanmun.test.ts
index 9b3ab66e..044b137f 100644
--- a/src/gukhanmun.test.ts
+++ b/src/gukhanmun.test.ts
@@ -97,4 +97,19 @@ describe("transformPostHtmlWithGukhanmun", () => {
transformPostHtmlWithGukhanmun("
漢字
", "ja"),
).resolves.toBe("
漢字
");
});
+
+ it("preserves authored ruby, math, and literal math fallbacks", async () => {
+ vi.stubEnv("GUKHANMUN", "ko");
+ const preserved =
+ '
漢字' +
+ '
' +
+ '
$漢字$' +
+ '
$$漢字$$
';
+
+ await expect(
+ transformPostHtmlWithGukhanmun(preserved + "
漢字
", "ko"),
+ ).resolves.toBe(
+ preserved + "
漢字
",
+ );
+ });
});
diff --git a/src/gukhanmun.ts b/src/gukhanmun.ts
index 2430c96f..1b7002cf 100644
--- a/src/gukhanmun.ts
+++ b/src/gukhanmun.ts
@@ -1,8 +1,11 @@
import type { Gukhanmun, Preset } from "@gukhanmun/napi";
+import { MARKDOWN_PRESERVE_CLASS } from "./markdown-math";
+
const converterPromises = new Map
>();
const htmlOptions = {
preserveAttributes: [{ name: "translate", value: "no" }],
+ preserveClasses: [MARKDOWN_PRESERVE_CLASS],
} as const;
function matchesWildcard(value: string, pattern: string): boolean {
diff --git a/src/html.test.ts b/src/html.test.ts
new file mode 100644
index 00000000..60fc2bb0
--- /dev/null
+++ b/src/html.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+
+import { extractPreviewLink, stripFootnoteIds } from "./html";
+
+describe("footnote navigation", () => {
+ it("ignores relative and known-document fragments, preserving external anchors", async () => {
+ const html =
+ 'externalnoteabsolute';
+ expect(
+ await extractPreviewLink(html, [], ["https://local.test/post"]),
+ ).toBe("https://other.test/post#part");
+ });
+ it("removes only generated IDs from repeated cards", () => {
+ expect(
+ stripFootnoteIds(
+ 'backtext
',
+ ),
+ ).toBe(
+ 'backtext
',
+ );
+ });
+});
diff --git a/src/html.ts b/src/html.ts
index 041bae42..42952ebe 100644
--- a/src/html.ts
+++ b/src/html.ts
@@ -14,14 +14,22 @@ async function getCheerio() {
export async function extractPreviewLink(
html: string,
ignoredLinks: Iterable = [],
+ documentUrls: Iterable = [],
): Promise {
const cheerio = await getCheerio();
const $ = cheerio.load(html);
const ignored = new Set([...ignoredLinks].map(normalizeLink));
+ const documents = new Set([...documentUrls].map(withoutFragment));
const links = $("a[href]:not([rel=tag]):not(.mention)").toArray().reverse();
for (const link of links) {
const href = $(link).attr("href");
- if (href == null || ignored.has(normalizeLink(href))) continue;
+ if (
+ href == null ||
+ href.startsWith("#") ||
+ ignored.has(normalizeLink(href))
+ )
+ continue;
+ if (href.includes("#") && documents.has(withoutFragment(href))) continue;
return href;
}
return null;
@@ -96,3 +104,21 @@ function decodeHtmlEntities(value: string): string {
},
);
}
+
+function withoutFragment(value: string): string {
+ try {
+ const url = new URL(value);
+ url.hash = "";
+ return url.href;
+ } catch {
+ return value.split("#")[0];
+ }
+}
+
+/** Repeated cards link to the canonical document without duplicating its IDs. */
+export function stripFootnoteIds(html: string): string {
+ return html.replaceAll(
+ /(<(?:a|li)\b[^>]*?)\s+id="(?:fn|fnref)-hollo-[^"<>]+"/giu,
+ "$1",
+ );
+}
diff --git a/src/markdown-linkify.ts b/src/markdown-linkify.ts
new file mode 100644
index 00000000..e20c01ed
--- /dev/null
+++ b/src/markdown-linkify.ts
@@ -0,0 +1,119 @@
+import MarkdownIt, {
+ type Delimiter,
+ type MarkdownIt as Parser,
+ type StateInline,
+} from "markdown-it";
+
+// Obtain the built-in rule through the public Ruler API, without importing
+// markdown-it's private files or copying its URL parsing implementation.
+const reference = new MarkdownIt();
+reference.inline.ruler.enableOnly(["linkify"]);
+const [linkify] = reference.inline.ruler.getRules("");
+
+export const DELIMITER_SCAN_WORK = Symbol("delimiter scan work");
+
+export function installDelimiterLinkify(md: Parser): void {
+ const closerCache = new WeakMap>();
+ const balanceCache = new WeakMap<
+ Delimiter[],
+ { index: number; counts: Map }
+ >();
+ function openMarkers(state: StateInline): number[] {
+ const delimiters = state.delimiters;
+ let cached = balanceCache.get(delimiters);
+ if (cached == null || cached.index > delimiters.length) {
+ cached = { index: 0, counts: new Map() };
+ balanceCache.set(delimiters, cached);
+ }
+ while (cached.index < delimiters.length) {
+ const d = delimiters[cached.index++];
+ if (d.marker !== 61 && d.marker !== 43) continue;
+ const count = cached.counts.get(d.marker) ?? 0;
+ if (d.close && count > 0) cached.counts.set(d.marker, count - 1);
+ else if (d.open) cached.counts.set(d.marker, count + 1);
+ }
+ return [...cached.counts]
+ .filter(([, count]) => count > 0)
+ .map(([marker]) => marker);
+ }
+ function closers(state: StateInline): Map {
+ const existing = closerCache.get(state);
+ if (existing != null) return existing;
+ const result = new Map([
+ [61, new Int32Array(state.src.length + 1).fill(-1)],
+ [43, new Int32Array(state.src.length + 1).fill(-1)],
+ ]);
+ let slashes = 0;
+ let work = 0;
+ const originalMax = state.posMax;
+ state.posMax = state.src.length;
+ try {
+ for (let i = 0; i < state.src.length; i++) {
+ work++;
+ const marker = state.src.charCodeAt(i);
+ const escaped = slashes % 2 !== 0;
+ slashes = marker === 92 ? slashes + 1 : 0;
+ if (escaped || (marker !== 61 && marker !== 43)) continue;
+ const scanned = state.scanDelims(i, true);
+ if (scanned.length >= 2 && scanned.can_close)
+ result.get(marker)![i] = i;
+ i += scanned.length - 1;
+ work += scanned.length - 1;
+ }
+ } finally {
+ state.posMax = originalMax;
+ }
+ for (const list of result.values()) {
+ let next = -1;
+ for (let i = state.src.length - 1; i >= 0; i--) {
+ work++;
+ const current = list[i];
+ list[i] = next;
+ if (current >= 0) next = current;
+ }
+ }
+ state.env[DELIMITER_SCAN_WORK] =
+ Number(state.env[DELIMITER_SCAN_WORK] ?? 0) + work;
+ closerCache.set(state, result);
+ return result;
+ }
+ md.inline.ruler.at("linkify", (state, silent) => {
+ if (
+ state.src[state.pos] !== ":" ||
+ state.linkLevel > 0 ||
+ !md.options.linkify
+ )
+ return linkify(state, silent);
+ const markers = openMarkers(state);
+ if (markers.length === 0) return linkify(state, silent);
+ const scheme = /[a-z][a-z\d+.-]*$/iu.exec(state.pending.slice(-10))?.[0];
+ if (scheme == null) return linkify(state, silent);
+ const start = state.pos - scheme.length;
+ const match = md.linkify.matchAtStart(state.src.slice(start));
+ if (match == null) return linkify(state, silent);
+ let end = start + match.url.length;
+ for (const marker of markers) {
+ const close = closers(state).get(marker)![state.pos];
+ if (close >= state.pos && close < end && close < state.posMax)
+ end = close;
+ }
+ if (end === start + match.url.length) return linkify(state, silent);
+ const source = state.src;
+ const pos = state.pos;
+ const max = state.posMax;
+ state.src = source.slice(start, end);
+ state.pos = scheme.length;
+ state.posMax = state.src.length;
+ let matched = false;
+ let consumed = 0;
+ try {
+ matched = linkify(state, silent);
+ consumed = state.pos - scheme.length;
+ return matched;
+ } finally {
+ state.src = source;
+ state.pos = matched ? pos + consumed : pos;
+ state.posMax = max;
+ }
+ });
+}
diff --git a/src/markdown-math.ts b/src/markdown-math.ts
new file mode 100644
index 00000000..2c6ae383
--- /dev/null
+++ b/src/markdown-math.ts
@@ -0,0 +1,257 @@
+import type { KatexOptions } from "katex";
+import type {
+ Env,
+ MarkdownIt,
+ StateBlock,
+ StateInline,
+ Token,
+} from "markdown-it";
+
+export const MARKDOWN_PRESERVE_CLASS = "hollo-markdown-preserve";
+export const MATH_CLASS = "hollo-math";
+export const MAX_MATH_INPUT = 65_536;
+export const MATH_SCAN_WORK = Symbol("math scan work");
+const BUDGET = Symbol("math render budget");
+const UNSUPPORTED_COLOR = "hollo-untrusted";
+const MAX_EXPRESSION = 8_192;
+const MAX_EXPRESSIONS = 64;
+const MAX_EXPRESSION_OUTPUT = 256 * 1_024;
+const MAX_TOTAL_OUTPUT = 1_024 * 1_024;
+
+interface Delimiters {
+ single: Int32Array;
+ double: Int32Array;
+ escaped: Uint8Array;
+ visits: number;
+}
+
+/** Index each possible closer once, including failed and silent lookahead. */
+export function scanMathDelimiters(source: string): Delimiters {
+ const n = source.length;
+ const single = new Int32Array(n + 1).fill(-1);
+ const double = new Int32Array(n + 1).fill(-1);
+ const escaped = new Uint8Array(n);
+ const whitespace = new Uint8Array(n + 1);
+ whitespace[n] = 1;
+ let slashes = 0;
+ for (let i = 0; i < n; i++) {
+ escaped[i] = slashes % 2;
+ slashes = source[i] === "\\" ? slashes + 1 : 0;
+ }
+ for (let i = n - 1; i >= 0; i--) {
+ whitespace[i] =
+ source[i] === "\n" || (/\s/u.test(source[i]) && whitespace[i + 1])
+ ? 1
+ : 0;
+ }
+ let nextSingle = -1;
+ let nextDouble = -1;
+ for (let i = n - 1; i >= 0; i--) {
+ if (source[i] === "\n") nextSingle = -1;
+ single[i] = nextSingle;
+ double[i] = nextDouble;
+ if (source[i] !== "$" || escaped[i]) continue;
+ if (source[i + 1] === "$" && whitespace[i + 2]) {
+ nextDouble = i;
+ }
+ if (
+ source[i - 1] !== "$" &&
+ source[i + 1] !== "$" &&
+ i > 0 &&
+ !/\s/u.test(source[i - 1]) &&
+ !/\d/u.test(source[i + 1] ?? "")
+ ) {
+ nextSingle = i;
+ }
+ }
+ return { single, double, escaped, visits: n * 3 };
+}
+
+type MathEngine = (source: string, options: KatexOptions) => string;
+
+export function installMath(
+ md: MarkdownIt,
+ engine: MathEngine,
+ draft = false,
+): void {
+ const cache = new WeakMap