From 2837c6e40e35afe16657ba4e4301efb2208e794f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 22 Oct 2026 20:22:14 +0900 Subject: [PATCH 1/5] Add configurable Markdown extensions Add ten opt-in extensions for local posts while preserving the existing profile syntax. Use bounded MathML rendering, readable HTML fallbacks, and stable footnote links without duplicate IDs in repeated post views. Preserve authored annotations through Gukhanmun and emoji processing. Document syntax, limits, and federation caveats in all five locales, with parser, API, ActivityPub, and rendering regression coverage. Codex implemented the change; Codex and Claude reviewed the design and code. OpenCode's initial review was blocked and prompted no changes. Closes https://github.com/fedify-dev/hollo/issues/679 Assisted-by: Codex:gpt-6.1-sol Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-opus-5-5 --- .env.sample | 4 + CHANGES.md | 9 + DESIGN.md | 12 + bin/server.ts | 3 + changes.d/markdown-extensions.md | 11 + compose-fs.yaml | 2 + compose.yaml | 2 + docs/astro.config.mjs | 10 + docs/src/content/docs/install/env.mdx | 20 + docs/src/content/docs/ja/install/env.mdx | 19 + docs/src/content/docs/ja/markdown.mdx | 139 ++++++ docs/src/content/docs/ko/install/env.mdx | 19 + docs/src/content/docs/ko/markdown.mdx | 140 ++++++ docs/src/content/docs/markdown.mdx | 139 ++++++ docs/src/content/docs/zh-cn/install/env.mdx | 16 + docs/src/content/docs/zh-cn/markdown.mdx | 136 ++++++ docs/src/content/docs/zh-tw/install/env.mdx | 16 + docs/src/content/docs/zh-tw/markdown.mdx | 136 ++++++ package.json | 11 +- pnpm-lock.yaml | 130 +++++- pnpm-workspace.yaml | 2 + src/api/v1/statuses.test.ts | 76 ++++ src/api/v1/statuses.ts | 24 +- src/components/Post.tsx | 25 +- src/custom-emoji.test.ts | 61 +++ src/custom-emoji.ts | 45 +- src/federation/post.test.ts | 37 ++ src/federation/post.ts | 6 + src/gukhanmun.test.ts | 15 + src/gukhanmun.ts | 3 + src/html.test.ts | 22 + src/html.ts | 28 +- src/markdown-linkify.ts | 119 +++++ src/markdown-math.ts | 257 +++++++++++ src/markdown-plugins.d.ts | 30 ++ src/markdown-settings.test.ts | 45 ++ src/markdown-settings.ts | 36 ++ src/markdown.test.ts | 474 ++++++++++++++++++++ src/markdown.ts | 346 ++++++++++++++ src/pages/profile/profilePost.tsx | 7 +- src/text.ts | 145 ++++-- src/xss.test.ts | 45 ++ src/xss.ts | 27 +- uno.config.ts | 9 + 44 files changed, 2772 insertions(+), 86 deletions(-) create mode 100644 changes.d/markdown-extensions.md create mode 100644 docs/src/content/docs/ja/markdown.mdx create mode 100644 docs/src/content/docs/ko/markdown.mdx create mode 100644 docs/src/content/docs/markdown.mdx create mode 100644 docs/src/content/docs/zh-cn/markdown.mdx create mode 100644 docs/src/content/docs/zh-tw/markdown.mdx create mode 100644 src/custom-emoji.test.ts create mode 100644 src/html.test.ts create mode 100644 src/markdown-linkify.ts create mode 100644 src/markdown-math.ts create mode 100644 src/markdown-plugins.d.ts create mode 100644 src/markdown-settings.test.ts create mode 100644 src/markdown-settings.ts create mode 100644 src/markdown.test.ts create mode 100644 src/markdown.ts create mode 100644 src/xss.test.ts diff --git a/.env.sample b/.env.sample index 49216426..d9b26948 100644 --- a/.env.sample +++ b/.env.sample @@ -4,6 +4,10 @@ SECRET_KEY=secret_key # generate a secret key with `openssl rand -base64 32` HOME_URL=https://example.com/ # optional; if present, the home page will redirect to this URL # Comma-separated locale patterns for Gukhanmun readings, e.g. ko,ko-* GUKHANMUN= +# Optional post Markdown extensions; empty disables all: +# https://docs.hollo.social/markdown/ +# footnote,deflist,sub,sup,mark,ruby,ins,abbr,tasklist,math +MARKDOWN_EXTENSIONS= LOG_LEVEL=info LOG_QUERY=false diff --git a/CHANGES.md b/CHANGES.md index 2f2d29ea..4fc0eef6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -146,6 +146,13 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. \[[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] + - Added opt-in Markdown extensions for local posts. Set + `MARKDOWN_EXTENSIONS` to select footnotes, definition lists, subscripts, + superscripts, highlights, ruby annotations, insertions, abbreviations, + task lists, or math. Existing posts retain their rendered content until + explicitly edited. Remote software may simplify the resulting HTML. + [[#679], [#680]] + - Changed the HTTP server to avoid buffering entire uploads and incoming activities before handling them behind a reverse proxy. Shutdown now waits for active HTTP requests alongside background workers within the existing @@ -194,6 +201,8 @@ To be released. [#664]: https://github.com/fedify-dev/hollo/pull/664 [#672]: https://github.com/fedify-dev/hollo/issues/672 [#673]: https://github.com/fedify-dev/hollo/pull/673 +[#679]: https://github.com/fedify-dev/hollo/issues/679 +[#680]: https://github.com/fedify-dev/hollo/pull/680 Version 0.9.24 diff --git a/DESIGN.md b/DESIGN.md index fe7903f6..e5357d3f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -397,3 +397,15 @@ public login page — prefer the passkey pattern over an inline blob: should be hidden behind a button the user has to click). - Wire to the DOM via stable `id`s on the form / button the page already renders. The server-rendered markup is the contract. + + +Markdown extensions +------------------- + +Post bodies use native semantic HTML for optional Markdown extensions. +Footnotes include visible numbers in their text; suppress duplicate list +numbering in Hollo. Retain their generated IDs only in the canonical post +body, so repeated cards link back to that document without duplicate IDs. +Task lists use static text markers. Highlight text uses the account theme +palette in both color schemes. Display math may scroll horizontally within +the post body; no client-side typesetting or KaTeX stylesheet is required. diff --git a/bin/server.ts b/bin/server.ts index 50e2e268..7b3d8379 100644 --- a/bin/server.ts +++ b/bin/server.ts @@ -2,6 +2,7 @@ import { isIP } from "node:net"; import "../src/logging"; import { checkHandleHostConsistency } from "../src/handle-host-check"; +import { getMarkdownExtensions } from "../src/markdown-settings"; import { configureSentry } from "../src/sentry"; import { createServerLifecycle } from "../src/server-lifecycle"; @@ -39,6 +40,8 @@ if (!["all", "web", "worker"].includes(NODE_TYPE)) { process.exit(1); } +getMarkdownExtensions(); + const lifecycle = createServerLifecycle([ async () => { // Warn if HANDLE_HOST disagrees with an existing account. diff --git a/changes.d/markdown-extensions.md b/changes.d/markdown-extensions.md new file mode 100644 index 00000000..e989b35f --- /dev/null +++ b/changes.d/markdown-extensions.md @@ -0,0 +1,11 @@ +--- +links: + '#679': https://github.com/fedify-dev/hollo/issues/679 + '#680': https://github.com/fedify-dev/hollo/pull/680 +--- + - Added opt-in Markdown extensions for local posts. Set + `MARKDOWN_EXTENSIONS` to select footnotes, definition lists, subscripts, + superscripts, highlights, ruby annotations, insertions, abbreviations, + task lists, or math. Existing posts retain their rendered content until + explicitly edited. Remote software may simplify the resulting HTML. + [[#679], [#680]] diff --git a/compose-fs.yaml b/compose-fs.yaml index 7b7ba862..3ccf0571 100644 --- a/compose-fs.yaml +++ b/compose-fs.yaml @@ -10,6 +10,8 @@ services: BEHIND_PROXY: "${BEHIND_PROXY}" # REMOTE_REPLIES_SCRAPE_MAX_REQUESTS: "200" # GUKHANMUN: "ko,ko-*" + # Optional post Markdown extensions; empty disables all: + # MARKDOWN_EXTENSIONS: "" # e.g. "footnote,ruby,math" DRIVE_DISK: fs STORAGE_URL_BASE: http://localhost:3000/assets/ FS_STORAGE_PATH: /var/lib/hollo diff --git a/compose.yaml b/compose.yaml index a3b6a9de..9a865557 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,8 @@ services: BEHIND_PROXY: "${BEHIND_PROXY}" # REMOTE_REPLIES_SCRAPE_MAX_REQUESTS: "200" # GUKHANMUN: "ko,ko-*" + # Optional post Markdown extensions; empty disables all: + # MARKDOWN_EXTENSIONS: "" # e.g. "footnote,ruby,math" DRIVE_DISK: s3 STORAGE_URL_BASE: http://localhost:9000/hollo/ S3_REGION: us-east-1 diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 7d7e3902..45c6f449 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -154,6 +154,16 @@ export default defineConfig({ "zh-TW": "已測試的客戶端", }, }, + { + slug: "markdown", + label: "Markdown", + translations: { + ko: "Markdown", + ja: "Markdown", + "zh-CN": "Markdown", + "zh-TW": "Markdown", + }, + }, { slug: "search", label: "Search", diff --git a/docs/src/content/docs/install/env.mdx b/docs/src/content/docs/install/env.mdx index 94d07fa9..2a38b69c 100644 --- a/docs/src/content/docs/install/env.mdx +++ b/docs/src/content/docs/install/env.mdx @@ -140,6 +140,26 @@ Additional features If present, the home page will redirect to this URL. If not set, the home page will show the list of accounts on the instance. +### `MARKDOWN_EXTENSIONS` + +A comma-separated list of optional Markdown extensions for local posts: +`footnote`, `deflist`, `sub`, `sup`, `mark`, `ruby`, `ins`, `abbr`, `tasklist`, +and `math`. For example, `MARKDOWN_EXTENSIONS=footnote,ruby,math` enables +footnotes, ruby annotations, and mathematics. Surrounding whitespace is +ignored, duplicate names are accepted, and unknown names prevent startup. +Unset or empty means all optional extensions are disabled. Tables and +strikethrough remain available without this setting. + +Extensions apply when local posts are created or explicitly edited, not to +profile biographies or fields. Changing this setting does not change stored +source or HTML, or send updates for existing posts. Editing a post renders it +with the current setting, so disabling an extension can make its syntax appear +literally after an edit. + +See the [Markdown guide] for syntax, escaping, and federation limitations. + +[Markdown guide]: /markdown/ + ### `GUKHANMUN` A comma-separated list of locale tag patterns for local posts that should have diff --git a/docs/src/content/docs/ja/install/env.mdx b/docs/src/content/docs/ja/install/env.mdx index ee4a2065..528c6b96 100644 --- a/docs/src/content/docs/ja/install/env.mdx +++ b/docs/src/content/docs/ja/install/env.mdx @@ -137,6 +137,25 @@ Holloは起動に失敗します。**最初のアカウントを作成する前 設定された場合、トップページへにアクセスした時にこのURLにリダイレクトされます。 設定されていない場合、トップページにはアカウント一覧が表示されます。 +### `MARKDOWN_EXTENSIONS` + +ローカル投稿で使う任意のMarkdown拡張をカンマで区切って指定します。 +指定できる名前は`footnote`、`deflist`、`sub`、`sup`、`mark`、`ruby`、 +`ins`、`abbr`、`tasklist`、`math`です。たとえば +`MARKDOWN_EXTENSIONS=footnote,ruby,math`で脚注、ルビ、数式を有効にします。 +名前の前後の空白は無視され、重複は許可されます。不明な名前があると起動に +失敗します。未設定または空の場合、任意の拡張はすべて無効です。 +表と取り消し線はこの設定なしでも使えます。 + +ローカル投稿の作成時と明示的な編集時に適用され、プロフィールの自己紹介や +フィールドには適用されません。設定を変えても既存の投稿の保存済みソースや +HTMLは変わらず、更新も送信しません。編集時は現在の設定で描画するため、 +拡張を無効にしてから編集すると、その構文が文字として表示される場合があります。 + +構文、エスケープ、連合時の制限は[Markdownガイド]を参照してください。 + +[Markdownガイド]: /ja/markdown/ + ### `GUKHANMUN` [Gukhanmun]で漢字にハングルの読みを付けるローカル投稿のロケールタグ diff --git a/docs/src/content/docs/ja/markdown.mdx b/docs/src/content/docs/ja/markdown.mdx new file mode 100644 index 00000000..b8f5d363 --- /dev/null +++ b/docs/src/content/docs/ja/markdown.mdx @@ -0,0 +1,139 @@ +--- +title: Markdown +description: 任意のMarkdown拡張を使った投稿方法とその制限を説明します。 +--- + +Holloは表や`~~取り消し線~~`を含むMarkdown投稿に対応しています。 +インスタンスの管理者は[`MARKDOWN_EXTENSIONS`][設定]で構文を追加できます。 +拡張はローカル投稿の作成時と明示的な編集時に適用されます。 +プロフィールの自己紹介やフィールドは従来のMarkdown構文を使います。 + +[設定]: /ja/install/env/#markdown_extensions- + + +拡張を有効にする +---------------- + +*.env*に使いたい拡張を設定し、Holloを再起動してください。 + +~~~~ bash +MARKDOWN_EXTENSIONS=footnote,ruby,math +~~~~ + +指定できる名前は`footnote`、`deflist`、`sub`、`sup`、`mark`、`ruby`、 +`ins`、`abbr`、`tasklist`、`math`です。未設定または空ならすべて無効です。 +名前の前後の空白と重複は無視され、不明な名前があると起動に失敗します。 + +設定を変えても保存済みのソースやHTMLは変わらず、既存の投稿の更新を送信しません。 +明示的に編集すると現在の設定で描画し直します。拡張を無効にしてから古い投稿を +編集すると、元の構文が文字として現れる場合があります。 + + +構文 +---- + +| 拡張 | ソース | 意味 | +| ---------- | ------------------------------------- | ---------------------------- | +| `footnote` | `本文[^1]`と`[^1]: 脚注` | 番号付き脚注 | +| `deflist` | 用語の次の行に`: 定義` | 定義リスト | +| `sub` | `H~2~O` | 下付き文字 | +| `sup` | `x^2^` | 上付き文字 | +| `mark` | `==強調箇所==` | ハイライト | +| `ruby` | `{漢字:かんじ}` | ルビ | +| `ins` | `++追加した内容++` | 挿入した内容 | +| `abbr` | `*[HTML]: Hyper Text Markup Language` | 略語の定義 | +| `tasklist` | `- [x] 完了`と`- [ ] 未完了` | 静的なタスク状態 | +| `math` | `$x^2$`と`$$x^2$$` | インライン数式と別行立て数式 | + +対応する拡張を有効にすると、次のように書けます。 + +~~~~ markdown +ある主張[^1]。HTML は要素を持ちます。 + +[^1]: 補足説明。 + +用語 +: 定義 + +*[HTML]: Hyper Text Markup Language + +- [x] 執筆 +- [ ] 確認 +~~~~ + +脚注には実際の番号と元の投稿への絶対リンクが付きます。 +定義リストには用語と説明を区切る句読点が含まれます。 +ルビにはタグが除去される場合に備えた括弧が付きます。 +Gukhanmunは明示的なルビを保護し、読みを重ねて追加しません。 + +略語は大文字・小文字と単語の境界を区別します。`HTML5`、`html`、`HTML은`は +`HTML`に一致しません。通常の文章で最初に一致した略語には、 +「HTML (Hyper Text Markup Language)」のように展開した説明が表示されます。 +リンクのラベル、URL、メンション、ハッシュタグ、コード、数式は対象外です。 +重複する定義は最初のものを使い、説明は通常のテキストとして扱います。 + +タスク状態はクリックできるチェックボックスではなく、実際の`[x]`または`[ ]`の +文字として残ります。`[X]`も完了として認識します。印の後には空白か行末が +必要です。状態は投稿を編集して変えます。 +`- [x]: note`のような行は、タスクではなくMarkdownのリンク定義になる場合があります。 + + +エスケープと紛らわしい表記 +-------------------------- + +構文をそのまま書くには、コードスパンを使うか区切り文字の前にバックスラッシュを +付けてください。`sup`や`sub`が有効な場合、顔文字や範囲に含まれる対の +キャレットやチルダが書式になることがあります。`\^_\^`や`1\~2,3\~4`と書くと +表記を保てます。添字内の空白は`x^a\ b^`のようにエスケープしてください。 +従来の`~~取り消し線~~`も引き続き使えます。 + +`footnote`が有効なら`^[note]`はインライン脚注で、`sup`が有効なら +`^[note]^`は上付き文字です。受信側で添字が通常の文字列になる場合があるため、 +重要な指数は「xのa+b乗」のように通常の文章でも説明してください。 + + +数式 +---- + +インラインの`$...$`は改行をまたげません。開始の`$`の直後と終了の`$`の直前には +空白を置けず、終了の`$`の直後には数字を置けません。 +そのため`$5`や`$20 and $30`は通常の通貨表記として残ります。 +ドル記号自体は`\$`でエスケープするか、コードスパン内に書いてください。 + +別行立て数式は行頭の`$$`で始めます。字下げは可能です。 +終了の`$$`の後には、その行では空白だけを置けます。 +`$$x^2$$`のように1行にも書けますが、文の途中の別行立て数式には対応していません。 + +~~~~ markdown +インライン数式は$x^2$です。 + +$$ +\frac{a}{b} +$$ +~~~~ + +数式は[FEP-dc88]で説明される方式に沿って、MathMLとTeX注釈を使います。 +不正な式、安全でないコマンド、描画上限に達した式は、エスケープしたソースを +表示します。式ごとに独立したマクロ状態を持ち、展開回数と大きさを制限します。 + +上限は1式あたり8,192 UTF-16コード単位、1投稿あたり64式、1式の出力256 KiB、 +数式出力の合計1 MiBです。投稿のソースが65,536 UTF-16コード単位を超えると、 +数式の解析だけが無効になります。通常のMarkdownやメンション、ハッシュタグ、 +リンクの処理は続きます。この場合、ドル記号で囲まれたテキストも保護された数式ではなく、 +通常のMarkdown規則で解釈されます。 + +[FEP-dc88]: https://w3id.org/fep/dc88 + + +他のサーバーで読む +------------------ + +受信側のサーバーやクライアントは書式を除去する場合があります。添字、ハイライト、 +挿入の印、略語のツールチップが失われる可能性があります。 +重要な訂正や定義は本文にも明記してください。 +MastodonやMisskeyのルビ対応は内容や受信側のバージョンによります。 + +Mastodonは数式の注釈をドル記号付きのTeX文字列として表示できる場合がありますが、 +数式の組版を保証するものではありません。Misskeyでは数式の表示テキストと +TeX注釈が連結される場合があります。表示はサーバーやクライアントによって +異なります。数式が重要な意味を持つ場合は、通常の言葉でも説明を添えてください。 diff --git a/docs/src/content/docs/ko/install/env.mdx b/docs/src/content/docs/ko/install/env.mdx index f20cc03d..44a40874 100644 --- a/docs/src/content/docs/ko/install/env.mdx +++ b/docs/src/content/docs/ko/install/env.mdx @@ -134,6 +134,25 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야 설정될 경우, 첫 페이지 접속 시 이 URL로 리다이렉트됩니다. 설정되지 않을 경우, 첫 페이지에서 인스턴스 내 계정 목록이 보입니다. +### `MARKDOWN_EXTENSIONS` + +로컬 게시물에 적용할 선택적 Markdown 확장의 이름을 쉼표로 구분합니다. +사용 가능한 이름은 `footnote`, `deflist`, `sub`, `sup`, `mark`, `ruby`, +`ins`, `abbr`, `tasklist`, `math`입니다. 예를 들어 +`MARKDOWN_EXTENSIONS=footnote,ruby,math`는 각주, 루비, 수식을 켭니다. +이름 앞뒤 공백은 무시하고 중복은 허용하며, 모르는 이름이 있으면 시작에 +실패합니다. 미설정 또는 빈 값이면 모든 선택적 확장이 꺼집니다. +표와 취소선은 이 설정 없이도 사용할 수 있습니다. + +로컬 게시물을 생성하거나 명시적으로 수정할 때만 적용되며, 프로필 소개나 +필드에는 적용되지 않습니다. 설정을 바꿔도 기존 게시물의 저장된 원문과 HTML은 +바뀌지 않으며 갱신을 전송하지 않습니다. 수정 시에는 현재 설정으로 렌더링하므로, +확장을 끈 뒤 수정하면 해당 문법이 그대로 보일 수 있습니다. + +문법, 이스케이프, 연합 시 제약은 [Markdown 안내]를 참고하세요. + +[Markdown 안내]: /ko/markdown/ + ### `GUKHANMUN` [Gukhanmun]으로 한자에 한글 독음을 붙일 로컬 게시물의 로캘 태그 패턴을 diff --git a/docs/src/content/docs/ko/markdown.mdx b/docs/src/content/docs/ko/markdown.mdx new file mode 100644 index 00000000..b59928c9 --- /dev/null +++ b/docs/src/content/docs/ko/markdown.mdx @@ -0,0 +1,140 @@ +--- +title: Markdown +description: 선택적 Markdown 확장으로 게시물을 작성하는 방법과 제약을 설명합니다. +--- + +Hollo는 표와 `~~취소선~~`을 포함한 Markdown 게시물을 지원합니다. +인스턴스 운영자는 [`MARKDOWN_EXTENSIONS`][설정]로 문법을 추가할 수 있습니다. +확장은 로컬 게시물의 생성과 명시적 수정에 적용됩니다. +프로필 소개와 필드는 기존 Markdown 문법을 유지합니다. + +[설정]: /ko/install/env/#markdown_extensions- + + +확장 켜기 +--------- + +*.env*에 사용할 확장을 설정한 뒤 Hollo를 재시작하세요. + +~~~~ bash +MARKDOWN_EXTENSIONS=footnote,ruby,math +~~~~ + +사용 가능한 이름은 `footnote`, `deflist`, `sub`, `sup`, `mark`, `ruby`, +`ins`, `abbr`, `tasklist`, `math`입니다. 미설정 또는 빈 값이면 모두 꺼집니다. +이름 앞뒤 공백과 중복은 무시하며, 모르는 이름이 있으면 시작에 실패합니다. + +설정을 바꿔도 저장된 원문과 HTML은 바뀌지 않으며 기존 게시물의 갱신을 전송하지 +않습니다. 명시적으로 수정하면 현재 설정으로 다시 렌더링합니다. 확장을 끈 뒤 +예전 게시물을 수정하면 원래 문법이 그대로 드러날 수 있습니다. + + +문법 +---- + +| 확장 | 원문 | 의미 | +| ---------- | ------------------------------------- | ----------------------- | +| `footnote` | `본문[^1]`과 `[^1]: 각주` | 번호가 있는 각주 | +| `deflist` | 용어 다음 줄에 `: 정의` | 정의 목록 | +| `sub` | `H~2~O` | 아래 첨자 | +| `sup` | `x^2^` | 위 첨자 | +| `mark` | `==하이라이트==` | 강조 표시한 구절 | +| `ruby` | `{漢字:한자}` | 루비 독음 주석 | +| `ins` | `++추가된 내용++` | 삽입한 내용 | +| `abbr` | `*[HTML]: Hyper Text Markup Language` | 약어 정의 | +| `tasklist` | `- [x] 완료`와 `- [ ] 미완료` | 정적인 작업 상태 | +| `math` | `$x^2$`와 `$$x^2$$` | 인라인 수식과 별도 수식 | + +해당 확장을 켠 상태에서 다음과 같이 작성할 수 있습니다. + +~~~~ markdown +어떤 주장[^1]. HTML 문서에는 요소가 있습니다. + +[^1]: 뒷받침하는 설명. + +용어 +: 정의 + +*[HTML]: Hyper Text Markup Language + +- [x] 작성 +- [ ] 검토 +~~~~ + +각주는 실제 번호와 원본 게시물을 가리키는 절대 링크를 포함합니다. +정의 목록은 용어와 설명을 구분하는 문장 부호를 포함합니다. +루비에는 태그가 제거될 때를 위한 괄호가 있으며, Gukhanmun은 명시적 루비에 +독음을 중첩해서 붙이지 않고 보존합니다. + +약어는 대소문자와 단어 경계를 구분합니다. `HTML5`, `html`, `HTML은`은 +`HTML`에 일치하지 않습니다. 일반 텍스트에서 처음 일치하는 약어에는 +“HTML (Hyper Text Markup Language)”처럼 풀이가 보이게 붙습니다. +링크 글자, URL, 멘션, 해시태그, 코드, 수식은 제외합니다. +중복 정의는 첫 정의를 쓰며, 풀이는 일반 텍스트입니다. + +작업 상태는 클릭할 수 있는 체크박스가 아니라 실제 `[x]` 또는 `[ ]` 글자로 +남습니다. `[X]`도 완료 상태로 인식합니다. 표시 뒤에는 공백이나 줄 끝이 +와야 합니다. 상태는 게시물을 수정해서 바꿉니다. +`- [x]: note` 같은 줄은 작업 대신 Markdown 링크 정의가 될 수 있습니다. + + +이스케이프와 모호한 표기 +------------------------ + +문법 자체를 쓰려면 코드 구간을 쓰거나 구분자 앞에 역슬래시를 붙이세요. +`sup`나 `sub`를 켜면 이모티콘이나 범위의 짝지어진 캐럿·물결표가 서식으로 +바뀔 수 있습니다. `\^_\^`, `1\~2,3\~4`처럼 쓰면 표기를 보존합니다. +첨자 안의 공백은 `x^a\ b^`처럼 이스케이프해야 합니다. +기존 `~~취소선~~` 문법도 계속 사용할 수 있습니다. + +`footnote`를 켜면 `^[note]`는 인라인 각주이며, `sup`를 켜면 `^[note]^`는 +위 첨자입니다. 수신 소프트웨어에서 첨자가 평문으로 바뀔 수 있으므로 중요한 +지수는 “x의 a+b제곱”처럼 일반 텍스트로도 설명하세요. + + +수식 +---- + +인라인 `$...$`는 줄을 넘을 수 없습니다. 여는 `$` 뒤와 닫는 `$` 앞에는 +공백이 올 수 없으며, 닫는 `$` 바로 뒤에는 숫자가 올 수 없습니다. +따라서 `$5`와 `$20 and $30`은 일반 통화 표기로 남습니다. +달러 기호 자체는 `\$`로 이스케이프하거나 코드 구간에 쓰세요. + +별도 수식은 줄 처음의 `$$`로 시작하며, 앞에 들여쓰기가 있어도 됩니다. +닫는 `$$` 뒤의 같은 줄에는 공백만 올 수 있습니다. +`$$x^2$$`처럼 한 줄로 쓸 수도 있지만, 문장 중간의 별도 수식은 지원하지 않습니다. + +~~~~ markdown +인라인 수식은 $x^2$입니다. + +$$ +\frac{a}{b} +$$ +~~~~ + +수식은 [FEP-dc88]에 설명된 접근에 따라 MathML과 TeX 주석을 사용합니다. +잘못된 수식이나 안전하지 않은 명령, 렌더링 상한에 걸린 수식은 이스케이프한 +원문으로 표시합니다. 수식마다 매크로 상태가 독립적이며, 전개와 크기를 +제한합니다. + +상한은 수식당 UTF-16 코드 단위 8,192개, 게시물당 수식 64개, +수식당 출력 256 KiB, 수식 출력 합계 1 MiB입니다. +게시물 원문이 UTF-16 코드 단위 65,536개를 넘으면 수식 해석만 꺼집니다. +일반 Markdown과 멘션·해시태그·링크 처리는 계속합니다. 이때 달러 구분자 안의 +텍스트도 보호된 수식이 아니라 일반 Markdown 규칙으로 해석합니다. + +[FEP-dc88]: https://w3id.org/fep/dc88 + + +다른 서버에서 읽기 +------------------ + +수신 서버와 클라이언트는 서식을 제거할 수 있습니다. 아래·위 첨자, +하이라이트, 삽입 표시, 약어 툴팁이 사라질 수 있으므로 중요한 정정과 정의는 +실제 본문에도 쓰세요. Mastodon과 Misskey의 루비 지원은 내용과 수신 버전에 +따라 달라집니다. + +Mastodon은 수식 주석을 달러로 감싼 TeX 텍스트로 표시할 수 있으며, 이는 수식 +조판을 보장하지 않습니다. Misskey에서는 수식 표시 텍스트와 TeX 주석이 +이어 붙을 수 있습니다. 서버와 클라이언트마다 표시가 다를 수 +있습니다. 수식이 핵심 의미를 전달한다면 일반 언어로 된 설명도 함께 쓰세요. diff --git a/docs/src/content/docs/markdown.mdx b/docs/src/content/docs/markdown.mdx new file mode 100644 index 00000000..8e27e62a --- /dev/null +++ b/docs/src/content/docs/markdown.mdx @@ -0,0 +1,139 @@ +--- +title: Markdown +description: Write posts with optional Markdown extensions and understand their limits. +--- + +Hollo supports Markdown posts, including tables and `~~strikethrough~~`. +Your instance operator can enable additional syntax with +[`MARKDOWN_EXTENSIONS`][configuration]. Extensions apply when creating or +editing local posts. Profile biographies and fields keep their existing syntax. + +[configuration]: /install/env/#markdown_extensions- + + +Enable extensions +----------------- + +Set the extensions you want in *.env*, then restart Hollo: + +~~~~ bash +MARKDOWN_EXTENSIONS=footnote,ruby,math +~~~~ + +The available names are `footnote`, `deflist`, `sub`, `sup`, `mark`, `ruby`, +`ins`, `abbr`, `tasklist`, and `math`. An unset or empty value disables them +all. Spaces around names and duplicate names are ignored; an unknown name +prevents startup. + +Configuration changes leave stored source and HTML unchanged and send no +updates. Explicit edits use the current setting; editing after disabling +an extension can reveal its original syntax. + + +Syntax +------ + +| Extension | Source | Meaning | +| ---------- | -------------------------------------------------- | ------------------------------ | +| `footnote` | `Text[^1]` and `[^1]: Note` | Numbered footnote | +| `deflist` | A term followed by `: Definition` on the next line | Definition list | +| `sub` | `H~2~O` | Subscript | +| `sup` | `x^2^` | Superscript | +| `mark` | `==highlight==` | Highlighted passage | +| `ruby` | `{漢字:한자}` | Ruby reading annotation | +| `ins` | `++inserted++` | Added text | +| `abbr` | `*[HTML]: Hyper Text Markup Language` | Abbreviation definition | +| `tasklist` | `- [x] Done` and `- [ ] Pending` | Static task states | +| `math` | `$x^2$` and `$$x^2$$` | Inline and display mathematics | + +For example, with the corresponding extensions enabled: + +~~~~ markdown +A statement[^1]. HTML has elements. + +[^1]: Supporting detail. + +Term +: Definition + +*[HTML]: Hyper Text Markup Language + +- [x] Written +- [ ] Reviewed +~~~~ + +Footnotes include visible numbers and absolute links to the original post. +Definition lists include visible punctuation to separate terms from their +definitions. Ruby includes fallback parentheses for readers that remove +tags; Gukhanmun preserves explicit ruby instead of adding nested readings. + +Abbreviations are case-sensitive and use word boundaries. `HTML5`, `html`, +and `HTML은` do not match `HTML`. The first matching use in ordinary text +includes the visible expansion, such as “HTML (Hyper Text Markup Language)”. +Link labels, URLs, mentions, hashtags, code, and math are excluded. Duplicate +definitions use the first definition; expansions are plain text. + +Tasks show static `[x]` or `[ ]` text; `[X]` also means completed. Follow +markers with whitespace or a line ending. Edit the post to change state. +`- [x]: note` can be a Markdown link definition instead of a task. + + +Escaping and ambiguous text +--------------------------- + +Use code spans or a backslash before a delimiter to write literal syntax. +With `sup` or `sub`, paired carets or tildes in emoticons and ranges can +become formatting: write `\^_\^` or `1\~2,3\~4` to preserve them. +Spaces inside subscripts and superscripts must be escaped, as in `x^a\ b^`. + +With `footnote`, `^[note]` is an inline footnote; with `sup`, `^[note]^` is +superscript. Explain essential exponents in words too, such as “x to the +power a+b”, since receiving software may flatten them. + + +Mathematics +----------- + +Inline `$...$` cannot cross a line. No whitespace may follow its opening +`$` or precede its closing `$`; the closing `$` cannot be followed by a digit. +Thus `$5` and `$20 and $30` remain ordinary currency text. Escape a literal +dollar as `\$`, or use a code span. + +Display math starts with `$$` at the beginning of a line, allowing indentation. +Only whitespace may follow the closing `$$` on its line. `$$x^2$$` can +occupy one line; display math in the middle of a sentence is unsupported. + +~~~~ markdown +The inline expression is $x^2$. + +$$ +\frac{a}{b} +$$ +~~~~ + +Math uses MathML with a TeX annotation, following the approach described in +[FEP-dc88]. Invalid expressions, unsafe commands, or rendering limits show +escaped literal source. Each expression has separate macros with limits on +expansion and size. + +Limits are 8,192 UTF-16 code units per expression, 64 expressions per post, +256 KiB output per expression, and 1 MiB total math output. When the post +exceeds 65,536 UTF-16 code units, only math parsing is disabled: ordinary +Markdown and mention, hashtag, and link processing continue. Dollar-delimited +text then follows ordinary Markdown rules, rather than being protected math. + +[FEP-dc88]: https://w3id.org/fep/dc88 + + +Reading on other servers +------------------------ + +Receiving software can remove subscripts, superscripts, highlights, +insertion marks, and abbreviation tooltips. Write important corrections +and definitions in visible text. Ruby +support in Mastodon and Misskey depends on the content and receiving version. + +Mastodon can expose math annotations as dollar-delimited TeX text; this is +not a guarantee of typeset mathematics. Misskey can concatenate presentation +text with the TeX annotation. Display varies between servers and +clients. Explain essential formulas in ordinary language too. diff --git a/docs/src/content/docs/zh-cn/install/env.mdx b/docs/src/content/docs/zh-cn/install/env.mdx index baac7a5b..42cf163a 100644 --- a/docs/src/content/docs/zh-cn/install/env.mdx +++ b/docs/src/content/docs/zh-cn/install/env.mdx @@ -124,6 +124,22 @@ openssl rand -hex 32 如果存在,主页将重定向到此URL。如果未设置,主页将显示实例上的账户列表。 +### `MARKDOWN_EXTENSIONS` + +以逗号分隔的本地帖子可选 Markdown 扩展列表。可用名称为`footnote`、 +`deflist`、`sub`、`sup`、`mark`、`ruby`、`ins`、`abbr`、`tasklist`和`math`。 +例如,`MARKDOWN_EXTENSIONS=footnote,ruby,math`启用脚注、注音和数学公式。 +名称前后的空白会被忽略,重复名称会合并;未知名称会导致启动失败。 +未设置或为空时,所有可选扩展均关闭。表格和删除线无需此设置即可使用。 + +扩展仅在创建或明确编辑本地帖子时应用,不用于个人简介或资料字段。 +更改设置不会改变现有帖子的已存源文本或 HTML,也不会发送更新。 +编辑帖子时会使用当前设置重新渲染,因此关闭扩展后再编辑,可能会直接显示其语法。 + +语法、转义和联邦互通限制请参阅 [Markdown 指南]。 + +[Markdown 指南]: /zh-cn/markdown/ + ### `GUKHANMUN` 以逗号分隔的语言区域标签模式列表,用于指定哪些本地帖子应通过 diff --git a/docs/src/content/docs/zh-cn/markdown.mdx b/docs/src/content/docs/zh-cn/markdown.mdx new file mode 100644 index 00000000..35a13f72 --- /dev/null +++ b/docs/src/content/docs/zh-cn/markdown.mdx @@ -0,0 +1,136 @@ +--- +title: Markdown +description: 使用可选的 Markdown 扩展撰写帖子,并了解其限制。 +--- + +Hollo 支持 Markdown 帖子,包括表格和`~~删除线~~`。 +实例管理员可以通过 [`MARKDOWN_EXTENSIONS`][配置]启用其他语法。 +扩展适用于创建和明确编辑本地帖子的操作。 +个人简介和资料字段继续使用原有的 Markdown 语法。 + +[配置]: /zh-cn/install/env/#markdown_extensions- + + +启用扩展 +-------- + +在 *.env* 中设置所需扩展,然后重启 Hollo: + +~~~~ bash +MARKDOWN_EXTENSIONS=footnote,ruby,math +~~~~ + +可用名称为`footnote`、`deflist`、`sub`、`sup`、`mark`、`ruby`、`ins`、 +`abbr`、`tasklist`和`math`。未设置或为空时,所有扩展均关闭。 +名称前后的空白和重复名称会被忽略;未知名称会导致启动失败。 + +更改设置不会改变已存的源文本或 HTML,也不会发送现有帖子的更新。 +明确编辑帖子时,会按当前设置重新渲染。因此,关闭扩展后编辑旧帖子, +可能会直接显示其原始语法。 + + +语法 +---- + +| 扩展 | 源文本 | 含义 | +| ---------- | ------------------------------------- | -------------------- | +| `footnote` | `正文[^1]`和`[^1]: 脚注` | 带编号的脚注 | +| `deflist` | 术语的下一行写`: 定义` | 定义列表 | +| `sub` | `H~2~O` | 下标 | +| `sup` | `x^2^` | 上标 | +| `mark` | `==高亮内容==` | 高亮片段 | +| `ruby` | `{汉字:hànzì}` | 注音 | +| `ins` | `++新增内容++` | 插入的内容 | +| `abbr` | `*[HTML]: Hyper Text Markup Language` | 缩写定义 | +| `tasklist` | `- [x] 已完成`和`- [ ] 待完成` | 静态任务状态 | +| `math` | `$x^2$`和`$$x^2$$` | 行内和独立显示的公式 | + +启用对应扩展后,可以这样写: + +~~~~ markdown +一个论点[^1]。HTML 具有元素。 + +[^1]: 补充说明。 + +术语 +: 定义 + +*[HTML]: Hyper Text Markup Language + +- [x] 撰写 +- [ ] 审阅 +~~~~ + +脚注包含可见的编号和指向原帖的绝对链接。 +定义列表包含用于区分术语和定义的可见标点。 +注音包含在标签被移除时使用的后备括号;Gukhanmun 会保留明确写出的注音, +不会再嵌套添加读音。 + +缩写区分大小写,并按单词边界匹配。`HTML5`、`html`和`HTML은`均不匹配`HTML`。 +在普通文本中首次匹配时,会显示完整解释,如“HTML (Hyper Text Markup Language)”。 +链接文字、URL、提及、话题标签、代码和公式不参与替换。 +重复定义使用第一项;解释内容作为纯文本处理。 + +任务状态保留为可见的`[x]`或`[ ]`文本,而非可点击的复选框。 +`[X]`也表示完成。标记之后需要空白或行尾。 +通过编辑帖子更改状态。`- [x]: note`这样的行可能被视为 Markdown 链接定义, +而非任务。 + + +转义和歧义 +---------- + +要直接显示语法,请使用行内代码,或在分隔符前加反斜杠。 +启用`sup`或`sub`后,表情符号和范围中的成对尖号或波浪号可能变为格式。 +写成`\^_\^`或`1\~2,3\~4`即可保留原样。 +上下标内的空格必须转义,例如`x^a\ b^`。 +原有的`~~删除线~~`语法仍然可用。 + +启用`footnote`时,`^[note]`是行内脚注;启用`sup`时,`^[note]^`是上标。 +接收软件可能将上下标变成普通文本,因此也应以普通文字说明重要指数, +例如“x 的 a+b 次方”。 + + +数学公式 +-------- + +行内`$...$`不能跨行。开始的`$`后和结束的`$`前不能有空白, +结束的`$`后不能紧跟数字。因此,`$5`和`$20 and $30`仍为普通的货币文本。 +要显示美元符号,请写成`\$`或放在行内代码中。 + +独立显示的公式以行首的`$$`开始,允许缩进。 +结束的`$$`之后,同一行只能有空白。也可以写成单行`$$x^2$$`; +不支持句子中间的独立显示公式。 + +~~~~ markdown +行内公式是 $x^2$。 + +$$ +\frac{a}{b} +$$ +~~~~ + +公式使用 MathML 和 TeX 注释,采用 [FEP-dc88] 所描述的方式。 +无效表达式、不安全命令或达到渲染上限的公式,会显示为经过转义的原始文本。 +各个表达式的宏状态相互独立,并限制展开次数和大小。 + +上限为每个表达式 8,192 个 UTF-16 代码单元、每帖 64 个表达式、 +每个表达式输出 256 KiB,以及公式总输出 1 MiB。 +当帖子源文本超过 65,536 个 UTF-16 代码单元时,仅关闭公式解析; +普通 Markdown、提及、话题标签和链接处理仍会继续。 +此时,美元符号内的文本也遵循普通 Markdown 规则,不再作为受保护的公式。 + +[FEP-dc88]: https://w3id.org/fep/dc88 + + +在其他服务器上阅读 +------------------ + +接收服务器和客户端可能会移除格式。上下标、高亮、插入标记和缩写提示可能丢失。 +重要的更正和定义应写在可见的正文中。 +Mastodon 和 Misskey 对注音的支持取决于内容和接收版本。 + +Mastodon 可能将公式注释显示为带美元符号的 TeX 文本,这并不保证公式排版。 +Misskey 可能将公式显示文本和 TeX 注释连接在一起。 +不同服务器和客户端的显示可能不同。 +当公式承载重要含义时,请附上普通语言的说明。 diff --git a/docs/src/content/docs/zh-tw/install/env.mdx b/docs/src/content/docs/zh-tw/install/env.mdx index 99f56083..3545268c 100644 --- a/docs/src/content/docs/zh-tw/install/env.mdx +++ b/docs/src/content/docs/zh-tw/install/env.mdx @@ -124,6 +124,22 @@ openssl rand -hex 32 如果存在,首頁將重新導向到此 URL。如果未設定,首頁將顯示實例上的帳號清單。 +### `MARKDOWN_EXTENSIONS` + +以逗號分隔的本機貼文選用 Markdown 擴充功能清單。可用名稱為`footnote`、 +`deflist`、`sub`、`sup`、`mark`、`ruby`、`ins`、`abbr`、`tasklist`和`math`。 +例如,`MARKDOWN_EXTENSIONS=footnote,ruby,math`啟用註腳、注音和數學公式。 +名稱前後的空白會被忽略,重複名稱會合併;未知名稱會導致啟動失敗。 +未設定或為空時,所有選用擴充功能均關閉。表格和刪除線無需此設定即可使用。 + +擴充功能僅在建立或明確編輯本機貼文時套用,不用於個人簡介或資料欄位。 +變更設定不會改變既有貼文儲存的原始文字或 HTML,也不會傳送更新。 +編輯貼文時會使用目前設定重新呈現,因此關閉擴充功能後再編輯,可能會直接顯示其語法。 + +語法、跳脫和聯邦互通限制請參閱 [Markdown 指南]。 + +[Markdown 指南]: /zh-tw/markdown/ + ### `GUKHANMUN` 以逗號分隔的語言區域標籤模式清單,用於指定哪些本地貼文應透過 diff --git a/docs/src/content/docs/zh-tw/markdown.mdx b/docs/src/content/docs/zh-tw/markdown.mdx new file mode 100644 index 00000000..ac7e01f2 --- /dev/null +++ b/docs/src/content/docs/zh-tw/markdown.mdx @@ -0,0 +1,136 @@ +--- +title: Markdown +description: 使用選用的 Markdown 擴充功能撰寫貼文,並了解其限制。 +--- + +Hollo 支援 Markdown 貼文,包括表格和`~~刪除線~~`。 +實例管理員可以透過 [`MARKDOWN_EXTENSIONS`][設定]啟用其他語法。 +擴充功能適用於建立和明確編輯本機貼文的操作。 +個人簡介和資料欄位繼續使用原有的 Markdown 語法。 + +[設定]: /zh-tw/install/env/#markdown_extensions- + + +啟用擴充功能 +------------ + +在 *.env* 中設定所需擴充功能,然後重新啟動 Hollo: + +~~~~ bash +MARKDOWN_EXTENSIONS=footnote,ruby,math +~~~~ + +可用名稱為`footnote`、`deflist`、`sub`、`sup`、`mark`、`ruby`、`ins`、 +`abbr`、`tasklist`和`math`。未設定或為空時,所有擴充功能均關閉。 +名稱前後的空白和重複名稱會被忽略;未知名稱會導致啟動失敗。 + +變更設定不會改變已儲存的原始文字或 HTML,也不會傳送既有貼文的更新。 +明確編輯貼文時,會依目前設定重新呈現。因此,關閉擴充功能後編輯舊貼文, +可能會直接顯示其原始語法。 + + +語法 +---- + +| 擴充功能 | 原始文字 | 含義 | +| ---------- | ------------------------------------- | -------------------- | +| `footnote` | `正文[^1]`和`[^1]: 註腳` | 帶編號的註腳 | +| `deflist` | 術語的下一行寫`: 定義` | 定義清單 | +| `sub` | `H~2~O` | 下標 | +| `sup` | `x^2^` | 上標 | +| `mark` | `==醒目內容==` | 醒目標示的片段 | +| `ruby` | `{漢字:ㄏㄢˋ ㄗˋ}` | 注音 | +| `ins` | `++新增內容++` | 插入的內容 | +| `abbr` | `*[HTML]: Hyper Text Markup Language` | 縮寫定義 | +| `tasklist` | `- [x] 已完成`和`- [ ] 待完成` | 靜態工作狀態 | +| `math` | `$x^2$`和`$$x^2$$` | 行內和獨立顯示的公式 | + +啟用對應擴充功能後,可以這樣寫: + +~~~~ markdown +一個論點[^1]。HTML 具有元素。 + +[^1]: 補充說明。 + +術語 +: 定義 + +*[HTML]: Hyper Text Markup Language + +- [x] 撰寫 +- [ ] 審閱 +~~~~ + +註腳包含可見的編號和指向原始貼文的絕對連結。 +定義清單包含用於區分術語和定義的可見標點。 +注音包含在標籤被移除時使用的備用括號;Gukhanmun 會保留明確寫出的注音, +不會再巢狀加入讀音。 + +縮寫區分大小寫,並按單字邊界比對。`HTML5`、`html`和`HTML은`均不符合`HTML`。 +在一般文字中首次符合時,會顯示完整解釋,如「HTML (Hyper Text Markup +Language)」。 連結文字、URL、提及、主題標籤、程式碼和公式不參與替換。 +重複定義使用第一項;解釋內容作為純文字處理。 + +工作狀態保留為可見的`[x]`或`[ ]`文字,而非可點擊的核取方塊。 +`[X]`也表示完成。標記之後需要空白或行尾。 +透過編輯貼文變更狀態。`- [x]: note`這樣的行可能被視為 Markdown 連結定義, +而非工作。 + + +跳脫和歧義 +---------- + +要直接顯示語法,請使用行內程式碼,或在分隔符號前加反斜線。 +啟用`sup`或`sub`後,表情符號和範圍中的成對插入號或波浪號可能變為格式。 +寫成`\^_\^`或`1\~2,3\~4`即可保留原樣。 +上下標內的空格必須跳脫,例如`x^a\ b^`。 +原有的`~~刪除線~~`語法仍然可用。 + +啟用`footnote`時,`^[note]`是行內註腳;啟用`sup`時,`^[note]^`是上標。 +接收軟體可能將上下標變成一般文字,因此也應以一般文字說明重要指數, +例如「x 的 a+b 次方」。 + + +數學公式 +-------- + +行內`$...$`不能跨行。開始的`$`後和結束的`$`前不能有空白, +結束的`$`後不能緊接數字。因此,`$5`和`$20 and $30`仍為一般的貨幣文字。 +要顯示美元符號,請寫成`\$`或放在行內程式碼中。 + +獨立顯示的公式以行首的`$$`開始,允許縮排。 +結束的`$$`之後,同一行只能有空白。也可以寫成單行`$$x^2$$`; +不支援句子中間的獨立顯示公式。 + +~~~~ markdown +行內公式是 $x^2$。 + +$$ +\frac{a}{b} +$$ +~~~~ + +公式使用 MathML 和 TeX 註解,採用 [FEP-dc88] 所描述的方式。 +無效運算式、不安全命令或達到呈現上限的公式,會顯示為經過跳脫的原始文字。 +各個運算式的巨集狀態相互獨立,並限制展開次數和大小。 + +上限為每個運算式 8,192 個 UTF-16 程式碼單元、每篇貼文 64 個運算式、 +每個運算式輸出 256 KiB,以及公式總輸出 1 MiB。 +當貼文原始文字超過 65,536 個 UTF-16 程式碼單元時,僅關閉公式解析; +一般 Markdown、提及、主題標籤和連結處理仍會繼續。 +此時,美元符號內的文字也遵循一般 Markdown 規則,不再作為受保護的公式。 + +[FEP-dc88]: https://w3id.org/fep/dc88 + + +在其他伺服器上閱讀 +------------------ + +接收伺服器和客戶端可能會移除格式。上下標、醒目標示、插入標記和縮寫提示可能遺失。 +重要的更正和定義應寫在可見的正文中。 +Mastodon 和 Misskey 對注音的支援取決於內容和接收版本。 + +Mastodon 可能將公式註解顯示為帶美元符號的 TeX 文字,這並不保證公式排版。 +Misskey 可能將公式顯示文字和 TeX 註解連接在一起。 +不同伺服器和客戶端的顯示可能不同。 +當公式承載重要含義時,請附上一般語言的說明。 diff --git a/package.json b/package.json index ec17d4ec..b0500067 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@logtape/file": "~2.3.10", "@logtape/logtape": "~2.3.10", "@logtape/sentry": "~2.3.10", + "@mdit/plugin-ruby": "^1.1.3", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.11.0", "@opentelemetry/core": "^2.12.0", @@ -73,8 +74,16 @@ "flydrive": "^2.1.0", "hono": "^4.13.12", "iso-639-1": "^3.1.5", - "markdown-it": "^15.0.2", + "katex": "^0.19.0", + "markdown-it": "^15.0.3", + "markdown-it-abbr": "^2.0.0", + "markdown-it-deflist": "^4.0.0", + "markdown-it-footnote": "^4.0.0", + "markdown-it-ins": "^4.0.0", + "markdown-it-mark": "^4.0.0", "markdown-it-replace-link": "^1.2.2", + "markdown-it-sub": "^2.0.0", + "markdown-it-sup": "^2.0.0", "mime": "^4.1.0", "neat-csv": "^7.0.0", "open-graph-scraper": "^6.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f08ced..c1ff9c16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: '@logtape/sentry': specifier: ~2.3.10 version: 2.3.10(@logtape/logtape@2.3.10)(@sentry/core@11.4.0) + '@mdit/plugin-ruby': + specifier: ^1.1.3 + version: 1.1.3(markdown-it@15.0.3) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -299,12 +302,36 @@ importers: iso-639-1: specifier: ^3.1.5 version: 3.1.6 + katex: + specifier: ^0.19.0 + version: 0.19.0 markdown-it: - specifier: ^15.0.2 - version: 15.0.2 + specifier: ^15.0.3 + version: 15.0.3 + markdown-it-abbr: + specifier: ^2.0.0 + version: 2.0.0 + markdown-it-deflist: + specifier: ^4.0.0 + version: 4.0.0 + markdown-it-footnote: + specifier: ^4.0.0 + version: 4.0.0 + markdown-it-ins: + specifier: ^4.0.0 + version: 4.0.0 + markdown-it-mark: + specifier: ^4.0.0 + version: 4.0.0 markdown-it-replace-link: specifier: ^1.2.2 - version: 1.2.2(@types/markdown-it@14.1.2)(markdown-it@15.0.2) + version: 1.2.2(@types/markdown-it@14.1.2)(markdown-it@15.0.3) + markdown-it-sub: + specifier: ^2.0.0 + version: 2.0.0 + markdown-it-sup: + specifier: ^2.0.0 + version: 2.0.0 mime: specifier: ^4.1.0 version: 4.1.0 @@ -1711,6 +1738,24 @@ packages: '@logtape/logtape': ^2.3.10 '@sentry/core': '>=8.0.0' + '@mdit/helper@1.1.3': + resolution: {integrity: sha512-ZJxIn+WQSHzJqI05hfMNmskhJoxGmixKMIUBMkbnjOL6CiamLDmiEubu6rwgyGVa6W0kE0Q1fmu/mMyNoU4SZw==} + engines: {node: '>=22'} + peerDependencies: + markdown-it: ^15.0.3 + peerDependenciesMeta: + markdown-it: + optional: true + + '@mdit/plugin-ruby@1.1.3': + resolution: {integrity: sha512-H6qSP/g4Q276wjMM8ql7XL3qDgOOcRRxFvcwt6biEv2YJJ1GkKxuO39G6RW9v8uiuRihR1Gmssg3NXwX38fZwQ==} + engines: {node: '>=22'} + peerDependencies: + markdown-it: ^15.0.3 + peerDependenciesMeta: + markdown-it: + optional: true + '@multiformats/base-x@4.0.1': resolution: {integrity: sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==} @@ -3888,8 +3933,8 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - argparse@3.0.2: - resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==} + argparse@3.0.3: + resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} @@ -4059,6 +4104,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4926,6 +4975,10 @@ packages: resolution: {integrity: sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==} engines: {node: '>=18'} + katex@0.19.0: + resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5072,18 +5125,39 @@ packages: magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + markdown-it-abbr@2.0.0: + resolution: {integrity: sha512-of7C8pXSjXjDojW4neNP+jD7inUYH/DO0Ca+K/4FUEccg0oHAEX/nfscw0jfz66PJbYWOAT9U8mjO21X5p6aAw==} + + markdown-it-deflist@4.0.0: + resolution: {integrity: sha512-06186ZWDwNLg638THD70MxBeFrDMF0+nL7IWU/qwCGHGOcUIm4MTYXbfjpv0ooWnVjkSnAjW8msKaOQmNRfY6Q==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-ins@4.0.0: + resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==} + + markdown-it-mark@4.0.0: + resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==} + markdown-it-replace-link@1.2.2: resolution: {integrity: sha512-JpeY+TMKqttF+tZQDmkFxXs/8PTohFvw4Z76h98O06ZEx66PNd1wMV0Odp094O/mvvf6iOnjdHpTKAfDUwhH1A==} peerDependencies: '@types/markdown-it': '*' markdown-it: '*' + markdown-it-sub@2.0.0: + resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + markdown-it@14.3.0: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true - markdown-it@15.0.2: - resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==} + markdown-it@15.0.3: + resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==} hasBin: true matcher@3.0.0: @@ -8137,6 +8211,16 @@ snapshots: '@logtape/logtape': 2.3.10 '@sentry/core': 11.4.0 + '@mdit/helper@1.1.3(markdown-it@15.0.3)': + optionalDependencies: + markdown-it: 15.0.3 + + '@mdit/plugin-ruby@1.1.3(markdown-it@15.0.3)': + dependencies: + '@mdit/helper': 1.1.3(markdown-it@15.0.3) + optionalDependencies: + markdown-it: 15.0.3 + '@multiformats/base-x@4.0.1': {} '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': @@ -9016,7 +9100,7 @@ snapshots: '@shikijs/markdown-it@4.5.0': dependencies: - markdown-it: 15.0.2 + markdown-it: 15.0.3 shiki: 4.5.0 '@shikijs/primitive@4.4.3': @@ -10106,7 +10190,7 @@ snapshots: argparse@2.0.1: {} - argparse@3.0.2: {} + argparse@3.0.3: {} aria-query@5.3.2: {} @@ -10346,6 +10430,8 @@ snapshots: commander@11.1.0: {} + commander@15.0.0: {} + commander@2.20.3: {} common-ancestor-path@2.0.0: {} @@ -11117,6 +11203,10 @@ snapshots: lru-cache: 6.0.0 rdf-canonize: 5.0.0 + katex@0.19.0: + dependencies: + commander: 15.0.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -11243,14 +11333,28 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.1 - markdown-it-replace-link@1.2.2(@types/markdown-it@14.1.2)(markdown-it@15.0.2): + markdown-it-abbr@2.0.0: {} + + markdown-it-deflist@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-ins@4.0.0: {} + + markdown-it-mark@4.0.0: {} + + markdown-it-replace-link@1.2.2(@types/markdown-it@14.1.2)(markdown-it@15.0.3): dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 15.0.2 + markdown-it: 15.0.3 optionalDependencies: dom-serializer: 2.0.0 htmlparser2: 8.0.2 + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + markdown-it@14.3.0: dependencies: argparse: 2.0.1 @@ -11260,9 +11364,9 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - markdown-it@15.0.2: + markdown-it@15.0.3: dependencies: - argparse: 3.0.2 + argparse: 3.0.3 entities: 8.1.0 linkify-it: 6.1.0 mdurl: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29044110..574d99c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,8 @@ overrides: minimumReleaseAgeExclude: - '@fedify/*' - srvx@1.0.6 +- '@mdit/helper@1.1.3' +- '@mdit/plugin-ruby@1.1.3' permissions: '@fedify/fedify': diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index f510cc94..12511dd8 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -57,6 +57,82 @@ describe("/api/v1/accounts/verify_credentials", { concurrent: false }, () => { accessToken = await getAccessToken(client, account, ["write"]); }); + it("preserves opt-in Markdown across API, federation, and canonical HTML", async () => { + vi.stubEnv( + "MARKDOWN_EXTENSIONS", + "footnote,deflist,sub,sup,mark,ruby,ins,abbr,tasklist,math", + ); + const source = + "Note[^n] H~2~O x^2^ ==bright== ++added++ {漢字:한자} HTML $x^2$\n\nTerm\n: Meaning\n\n- [x] Done\n\n*[HTML]: Hypertext Markup Language\n\n[^n]: Detail"; + const headers = { + authorization: bearerAuthorization(accessToken), + "Content-Type": "application/json", + }; + const createdResponse = await app.request("/api/v1/statuses", { + method: "POST", + headers, + body: JSON.stringify({ status: source }), + }); + expect(createdResponse.status).toBe(200); + const created = await createdResponse.json(); + for (const tag of [ + "", + "", + "", + "", + "", + " { const { pollNotifications } = await import("../../federation/federation"); const enqueue = vi diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 03e649d9..c3a7c80c 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -424,7 +424,11 @@ app.post( const content = data.status == null ? null - : await formatPostContent(db, data.status, language, fmtOpts); + : await formatPostContent(db, data.status, language, { + ...fmtOpts, + documentId: id, + permalink: url.href, + }); const summary = data.spoiler_text == null || data.spoiler_text.trim() === "" ? null @@ -675,6 +679,12 @@ app.put( return c.json({ error: "invalid_request", zod_error: result.error }, 422); } + const existingPost = await db.query.posts.findFirst({ + where: { id: { eq: id }, accountId: { eq: owner.id } }, + }); + if (existingPost == null) { + return c.json({ error: "Record not found" }, 404); + } const data = result.data; const fedCtx = federation.createContext(c.req.raw, undefined); @@ -690,7 +700,11 @@ app.put( const content = data.status == null ? null - : await formatPostContent(db, data.status, language, fmtOpts); + : await formatPostContent(db, data.status, language, { + ...fmtOpts, + documentId: id, + permalink: existingPost.url ?? existingPost.iri, + }); const summary = data.spoiler_text == null || data.spoiler_text.trim() === "" ? null @@ -708,12 +722,6 @@ app.put( if (content?.previewLink != null) { previewCard = await fetchPreviewCard(content.previewLink); } - const existingPost = await db.query.posts.findFirst({ - where: { id: { eq: id }, accountId: { eq: owner.id } }, - }); - if (existingPost == null) { - return c.json({ error: "Record not found" }, 404); - } const mediaIds = data.media_ids; const mediaIdSet = mediaIds == null ? null : new Set(mediaIds); if (mediaIds != null) { diff --git a/src/components/Post.tsx b/src/components/Post.tsx index 920adfe8..b6159e01 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -1,5 +1,5 @@ import { renderCustomEmojis } from "../custom-emoji"; -import { stripQuoteInlineFallbacks } from "../html"; +import { stripFootnoteIds, stripQuoteInlineFallbacks } from "../html"; import { orderMedia } from "../media-order"; import { proxyUrl } from "../media-proxy"; import type { PreviewCard } from "../previewcard"; @@ -57,6 +57,7 @@ export interface PostProps { readonly pinned?: boolean; readonly quoted?: boolean; readonly featured?: boolean; + readonly canonical?: boolean; readonly baseUrl: URL | string; } @@ -73,6 +74,7 @@ export function Post({ pinned, quoted, featured, + canonical, baseUrl, }: PostProps) { if (post.sharing != null) @@ -161,7 +163,12 @@ export function Post({
{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 = + ':smile:\\text{:smile:}'; + 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: :smile:$`; + const result = renderCustomEmojis(fallback + " :smile:", emojis, baseUrl); + expect(result).toContain(fallback); + expect(result.match(/ { + const math = + ":smile::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( + ':smile:', + emojis, + baseUrl, + ), + ).toContain(":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 = + '漢字(かんじ)' + + '漢字\\text{漢字}' + + '$漢字$' + + '
$$漢字$$
'; + + 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( + '
  • back
  • text

    ', + ), + ).toBe( + '
  • back
  • text

    ', + ); + }); +}); 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(); + // Container parsers temporarily rewrite line indentation. Cache boundaries + // within each recursive tokenize call, restoring the outer scope afterward. + const boundaries = new WeakMap>(); + const tokenize = md.block.tokenize; + md.block.tokenize = function (state, startLine, endLine) { + const previous = boundaries.get(state); + boundaries.set(state, new Map()); + try { + return tokenize.call(this, state, startLine, endLine); + } finally { + if (previous == null) boundaries.delete(state); + else boundaries.set(state, previous); + } + }; + function nextBoundary( + state: StateBlock, + startLine: number, + endLine: number, + ): number { + const scope = boundaries.get(state); + let index = scope?.get(state.blkIndent); + if (index == null) { + index = new Int32Array(state.lineMax + 1).fill(endLine); + let next = endLine; + for (let line = endLine - 1; line >= startLine; line--) { + if (!state.isEmpty(line) && state.sCount[line] < state.blkIndent) + next = line; + index[line] = next; + } + scope?.set(state.blkIndent, index); + state.env[MATH_SCAN_WORK] = + Number(state.env[MATH_SCAN_WORK] ?? 0) + endLine - startLine; + } + return index[startLine]; + } + // MarkdownIt's plain-text renderer drops unknown token types in image alt + // text. Give it a text view of math without rendering MathML or changing + // the tokens used for metadata and normal HTML rendering. + const inlineAsText = md.renderer.renderInlineAsText; + md.renderer.renderInlineAsText = function (tokens, options, env) { + return inlineAsText.call( + this, + tokens.map((token) => { + if (token.type !== "math_inline") return token; + const literal = Object.create(token) as Token; + literal.type = "text"; + literal.content = `$${token.content}$`; + return literal; + }), + options, + env, + ); + }; + + function indices(state: StateInline | StateBlock): Delimiters { + let result = cache.get(state); + if (result == null) { + result = scanMathDelimiters(state.src); + cache.set(state, result); + state.env[MATH_SCAN_WORK] = + Number(state.env[MATH_SCAN_WORK] ?? 0) + result.visits; + } + return result; + } + md.core.ruler.before("block", "math_budget", (state) => { + state.env[BUDGET] = { count: 0, bytes: 0 }; + state.env[MATH_SCAN_WORK] = 0; + }); + md.inline.ruler.before("escape", "math_inline", (state, silent) => { + // Link-label lookahead must still see its closing bracket. Actual label + // parsing supplies posMax and can then recognize bounded math normally. + if (silent) return false; + const start = state.pos; + if ( + state.src[start] !== "$" || + state.src[start + 1] === "$" || + state.src[start - 1] === "$" || + start + 1 >= state.posMax || + /\s/u.test(state.src[start + 1]) + ) + return false; + const index = indices(state); + if (index.escaped[start]) return false; + const end = index.single[start]; + if (end < 0 || end >= state.posMax) return false; + if (!silent) { + const token = state.push("math_inline", "math", 0); + token.content = state.src.slice(start + 1, end); + token.markup = "$"; + } + state.pos = end + 1; + return true; + }); + md.block.ruler.before( + "fence", + "math_block", + (state, startLine, endLine, silent) => { + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + const start = state.bMarks[startLine] + state.tShift[startLine]; + if (state.src.slice(start, start + 2) !== "$$") return false; + const index = indices(state); + const end = index.double[start + 1]; + if ( + end < 0 || + endLine <= startLine || + end + 2 > state.eMarks[endLine - 1] + ) + return false; + let low = startLine; + let high = endLine; + while (low + 1 < high) { + const middle = (low + high) >>> 1; + if (state.bMarks[middle] <= end) low = middle; + else high = middle; + } + if (end < state.bMarks[low] || state.sCount[low] < state.blkIndent) + return false; + if (low >= nextBoundary(state, startLine, endLine)) return false; + if (silent) return true; + const body = state + .getLines(startLine, low + 1, state.blkIndent, false) + .trimStart(); + const token = state.push("math_block", "math", 0); + token.block = true; + token.markup = "$$"; + token.map = [startLine, low + 1]; + token.content = body.slice(2, body.lastIndexOf("$$")); + state.line = low + 1; + return true; + }, + { alt: ["paragraph", "reference", "blockquote", "list"] }, + ); + + function render(source: string, block: boolean, env: Env = {}): string { + const tag = block ? "div" : "span"; + const delimiters = block ? "$$" : "$"; + const literal = () => + `<${tag} class="${MATH_CLASS} ${MARKDOWN_PRESERVE_CLASS}">${md.utils.escapeHtml(delimiters + source + delimiters)}`; + if (draft) return literal(); + const budget = env[BUDGET] as { count: number; bytes: number }; + if (++budget.count > MAX_EXPRESSIONS || source.length > MAX_EXPRESSION) + return literal(); + try { + const html = engine(source, { + output: "mathml", + displayMode: block, + trust: () => { + throw new Error("Untrusted math command"); + }, + strict: "ignore", + errorColor: UNSUPPORTED_COLOR, + throwOnError: true, + maxExpand: 1_000, + maxSize: 10, + macros: {}, + }); + // KaTeX rejects malformed URL schemes before calling trust(). Its + // unsupported-command renderer uses this distinct error color instead + // of throwing; preserve all arguments through the same literal fallback. + if (html.includes(`mathcolor="${UNSUPPORTED_COLOR}"`)) return literal(); + const bytes = Buffer.byteLength(html); + if ( + bytes > MAX_EXPRESSION_OUTPUT || + budget.bytes + bytes > MAX_TOTAL_OUTPUT + ) + return literal(); + budget.bytes += bytes; + return html.replace( + /])/u, + ` + render(tokens[idx].content, false, env); + md.renderer.rules.math_block = (tokens, idx, _options, env) => + render(tokens[idx].content, true, env) + "\n"; +} diff --git a/src/markdown-plugins.d.ts b/src/markdown-plugins.d.ts new file mode 100644 index 00000000..5cddcc6b --- /dev/null +++ b/src/markdown-plugins.d.ts @@ -0,0 +1,30 @@ +// These plugins have no declarations for markdown-it 15. Hollo uses only +// their default, single-argument plugin entry points. +declare module "markdown-it-footnote" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-deflist" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-sub" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-sup" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-mark" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-ins" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} +declare module "markdown-it-abbr" { + const plugin: (md: import("markdown-it").MarkdownIt) => void; + export default plugin; +} diff --git a/src/markdown-settings.test.ts b/src/markdown-settings.test.ts new file mode 100644 index 00000000..e9010fb0 --- /dev/null +++ b/src/markdown-settings.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + getMarkdownExtensions, + MARKDOWN_EXTENSIONS, + parseMarkdownExtensions, +} from "./markdown-settings"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("parseMarkdownExtensions", () => { + it.each([undefined, "", " , , "])("disables extensions for %s", (value) => { + expect([...parseMarkdownExtensions(value)]).toEqual([]); + }); + + it("trims, ignores empty entries, and deduplicates explicit names", () => { + expect([ + ...parseMarkdownExtensions(" math, footnote,math, ,ruby "), + ]).toEqual(["math", "footnote", "ruby"]); + }); + + it("accepts every documented extension", () => { + expect([...parseMarkdownExtensions(MARKDOWN_EXTENSIONS.join(","))]).toEqual( + MARKDOWN_EXTENSIONS, + ); + }); + + it.each(["Math", "all", "footnotes", "math,unknown"])( + "rejects unknown names instead of silently changing behavior: %s", + (value) => { + expect(() => parseMarkdownExtensions(value)).toThrow( + /Unknown MARKDOWN_EXTENSIONS value:/u, + ); + }, + ); + + it("reads the current environment rather than caching the first value", () => { + vi.stubEnv("MARKDOWN_EXTENSIONS", "math"); + expect([...getMarkdownExtensions()]).toEqual(["math"]); + vi.stubEnv("MARKDOWN_EXTENSIONS", "ruby,sub"); + expect([...getMarkdownExtensions()]).toEqual(["ruby", "sub"]); + vi.stubEnv("MARKDOWN_EXTENSIONS", undefined); + expect([...getMarkdownExtensions()]).toEqual([]); + }); +}); diff --git a/src/markdown-settings.ts b/src/markdown-settings.ts new file mode 100644 index 00000000..1bc53d04 --- /dev/null +++ b/src/markdown-settings.ts @@ -0,0 +1,36 @@ +export const MARKDOWN_EXTENSIONS = [ + "footnote", + "deflist", + "sub", + "sup", + "mark", + "ruby", + "ins", + "abbr", + "tasklist", + "math", +] as const; + +export type MarkdownExtension = (typeof MARKDOWN_EXTENSIONS)[number]; + +export function parseMarkdownExtensions( + value: string | undefined, +): ReadonlySet { + const result = new Set(); + for (const item of value?.split(",") ?? []) { + const name = item.trim(); + if (name === "") continue; + if (!MARKDOWN_EXTENSIONS.includes(name as MarkdownExtension)) { + throw new Error( + `Unknown MARKDOWN_EXTENSIONS value: ${name}. Valid values: ${MARKDOWN_EXTENSIONS.join(", ")}`, + ); + } + result.add(name as MarkdownExtension); + } + return result; +} + +export function getMarkdownExtensions(): ReadonlySet { + // oxlint-disable-next-line typescript/dot-notation + return parseMarkdownExtensions(process.env["MARKDOWN_EXTENSIONS"]); +} diff --git a/src/markdown.test.ts b/src/markdown.test.ts new file mode 100644 index 00000000..3a54ea8d --- /dev/null +++ b/src/markdown.test.ts @@ -0,0 +1,474 @@ +import { load } from "cheerio"; +import type { KatexOptions } from "katex"; +import MarkdownIt, { type Env } from "markdown-it"; +import { describe, expect, it, vi } from "vitest"; + +import { createMarkdownIt, createPostMarkdownIt } from "./markdown"; +import { + installMath, + MARKDOWN_PRESERVE_CLASS, + MATH_SCAN_WORK, + MAX_MATH_INPUT, + scanMathDelimiters, +} from "./markdown-math"; +import { + MARKDOWN_EXTENSIONS, + type MarkdownExtension, +} from "./markdown-settings"; + +const permalink = "https://example.com/@alice/post"; + +async function render( + source: string, + extensions: readonly MarkdownExtension[] = MARKDOWN_EXTENSIONS, + options: { documentId?: string; allowHtml?: boolean; draft?: boolean } = {}, +) { + const md = await createPostMarkdownIt({ + extensions: new Set(extensions), + documentId: "post-a", + permalink, + ...options, + }); + const env: Env = {}; + const html = md.render(source, env); + return { html, $: load(html), env }; +} + +describe("post Markdown extensions", () => { + it.each([ + ["footnote", "word[^a]\n\n[^a]: Note", "[id].footnote-item"], + ["deflist", "Term\n: Definition", "dl dt"], + ["sub", "H~2~O", "sub"], + ["sup", "x^2^", "sup"], + ["mark", "==marked==", "mark"], + ["ruby", "{漢字:かんじ}", "ruby rt"], + ["ins", "++inserted++", "ins"], + ["abbr", "HTML\n\n*[HTML]: Hypertext Markup Language", "abbr"], + ["tasklist", "- [x] done", "li.task-list-item"], + ["math", "$x^2$", "math"], + ] as const)( + "enables %s independently", + async (extension, source, selector) => { + const { $ } = await render(source, [extension]); + expect($(selector).length).toBeGreaterThan(0); + }, + ); + + it("keeps optional syntax literal when extensions are disabled", async () => { + const { $, html } = await render( + "H~2~O x^2^ ==mark== ++ins++ {漢字:かんじ} $x$\n\n- [x] done", + [], + ); + expect($("sub,sup,mark,ins,ruby,math").length).toBe(0); + expect(html).toContain("[x] done"); + expect($("body").text()).toContain("$x$"); + }); + + it("renders all extensions together without losing their structure", async () => { + const { $ } = await render( + "HTML H~2~O x^2^ ==mark== ++ins++ {漢字:かんじ} $x$ note[^a]\n\n" + + "- [x] done\n\nTerm\n: Definition\n\n*[HTML]: Hypertext Markup Language\n\n[^a]: Footnote", + ); + for (const tag of [ + "abbr", + "sub", + "sup", + "mark", + "ins", + "ruby", + "math", + "dl", + ]) { + expect($(tag).length).toBeGreaterThan(0); + } + expect($("input,label").length).toBe(0); + expect($("li").text()).toContain("[x]"); + expect($(".footnote-item").text()).toContain("Footnote"); + }); + + it("retains baseline fuzzy links, credentials, and URL punctuation", () => { + const md = createMarkdownIt(); + for (const url of [ + "https://user:pass@example.com/a_b*c", + "https://example.com/?a==b", + ]) { + expect(load(md.renderInline(url))("a").attr("href")).toBe(url); + } + expect(load(md.renderInline("example.com"))("a").attr("href")).toBe( + "http://example.com", + ); + }); + + it.each([ + ["mark", "==https://example.com/a==", "mark"], + ["ins", "++https://example.com/a++", "ins"], + ] as const)( + "keeps %s delimiters out of a bare URL", + async (extension, source, tag) => { + const { $ } = await render(source + " https://example.com/?a==b", [ + extension, + ]); + expect($(`${tag} a`).attr("href")).toBe("https://example.com/a"); + expect($("a").last().attr("href")).toBe("https://example.com/?a==b"); + }, + ); + + it.each([ + ["mark", "==", "mark"], + ["ins", "++", "ins"], + ] as const)( + "respects link-label boundaries for %s without nesting autolinks", + async (extension, delimiter, tag) => { + const bounded = await render( + `[${delimiter}https://inner.example/a${delimiter}](https://outer.example/)`, + [extension], + ); + expect(bounded.$("a").length).toBe(1); + expect(bounded.$("a").attr("href")).toBe("https://outer.example/"); + expect(bounded.$(`a ${tag}`).text()).toBe("https://inner.example/a"); + + const unbounded = await render( + `[${delimiter}https://inner.example/a](https://outer.example/) tail${delimiter}`, + [extension], + ); + expect(unbounded.$("a").length).toBe(1); + expect(unbounded.$("a").attr("href")).toBe("https://outer.example/"); + expect(unbounded.$(tag).length).toBe(0); + expect(unbounded.$("a").text()).toBe( + `${delimiter}https://inner.example/a`, + ); + }, + ); + + it("resolves relative links without adding preview or quote lookups", async () => { + const { $, env } = await render("[jump](#part) [path](/about)", []); + expect($("a").first().attr("href")).toBe("https://example.com/#part"); + expect($("a").last().attr("href")).toBe("https://example.com/about"); + expect(env.links).toEqual([]); + expect(env.previewLink).toBeNull(); + }); + + it("ignores links hidden in image labels while collecting image sources", async () => { + const { env } = await render( + "![alt [hidden](https://remote.example/post)](/image.png) ![visible](https://images.example/a.png)", + [], + ); + expect(env.links).toEqual(["https://images.example/a.png"]); + expect(env.previewLink).toBe("https://images.example/a.png"); + }); + + it("retains literal math in image alternative text", async () => { + const { $, env } = await render("![Equation $x^2$](/plot.png)", ["math"]); + expect($("img").attr("alt")).toBe("Equation $x^2$"); + expect(env.links).toEqual([]); + }); + + it("does not consume another list item as display math", async () => { + const { $ } = await render("- $$\n x\n\n- second\n $$", ["math"]); + expect($("li").length).toBe(2); + expect($("li").last().text()).toContain("second"); + expect($("math").length).toBe(0); + }); + + it("preserves absolute HTTP hrefs without eager URL validation", async () => { + const { $, env } = await render( + "[quote](https://remote.example/post) [broken](http://)", + [], + ); + expect($("a").last().attr("href")).toBe("http://"); + expect(env.links).toEqual(["https://remote.example/post", "http://"]); + }); + + it.each([ + "\\href{https://example.com}{click}", + "\\href{foo}{click}", + "\\url{https://example.com}", + "\\includegraphics{https://example.com/picture.png}", + "\\htmlId{example}{x}", + "\\href{1x:y}{click}", + "\\url{:x}", + "\\includegraphics{1:x}", + "\\href{x:y}{click}", + ])( + "shows the complete source for a blocked math command: %s", + async (expression) => { + const { $, env } = await render(`$${expression}$`, ["math"]); + expect($("math").length).toBe(0); + expect($(`.${MARKDOWN_PRESERVE_CLASS}`).text()).toBe(`$${expression}$`); + expect(env.links).toEqual([]); + }, + ); + + it("uses absolute, document-specific footnote links with actual numbers", async () => { + const source = "One[^b], two[^a], again[^b].\n\n[^a]: A\n[^b]: B"; + const first = await render(source, ["footnote"], { documentId: "post-a" }); + const second = await render(source, ["footnote"], { documentId: "post-b" }); + expect(first.$(".footnote-ref").first().text()).toContain("1"); + expect(first.$(".footnote-ref").eq(1).text()).toContain("2"); + for (const anchor of first + .$(".footnote-ref a,.footnote-backref") + .toArray()) { + const href = first.$(anchor).attr("href"); + expect(href).toMatch(/^https:\/\/example\.com\/@alice\/post#/u); + expect(first.$(`[id="${new URL(href!).hash.slice(1)}"]`).length).toBe(1); + } + const ids = first + .$("[id]") + .toArray() + .map((element) => first.$(element).attr("id")); + expect( + second + .$("[id]") + .toArray() + .map((element) => second.$(element).attr("id")), + ).not.toEqual(expect.arrayContaining(ids)); + expect(first.env.links).toEqual([]); + expect(first.env.previewLink).toBeNull(); + }); + + it("collects only referenced footnote mentions, hashtags, and preview links", async () => { + const { env } = await render( + "Visible[^used]\n\n[^unused]: @hidden@example.net #hidden https://hidden.example/\n" + + "[^used]: @visible@example.net #visible https://visible.example/", + ["footnote"], + ); + expect(env.mentions).toEqual(["@visible@example.net"]); + expect(env.hashtags).toEqual(["#visible"]); + expect(env.links).toContain("https://visible.example/"); + expect(env.links).not.toContain("https://hidden.example/"); + expect(env.previewLink).toBe("https://visible.example/"); + }); + + it("preserves ordinary punctuation in definition terms and definitions", async () => { + const { $ } = await render("C++: why?\n: Use a:b; c,d!", ["deflist"]); + expect($("dt").text()).toBe("C++: why?:"); + expect($("dt br").length).toBe(1); + expect($("dd").text().trim()).toBe("Use a:b; c,d!"); + }); + + it("keeps subscript, superscript, and footnotes distinct", async () => { + const { $ } = await render("H~2~O x^2^ note[^n]\n\n[^n]: Note", [ + "sub", + "sup", + "footnote", + ]); + expect($("sub").text()).toBe("2"); + expect($("sup").not(".footnote-ref").text()).toBe("2"); + expect($(".footnote-ref").length).toBe(1); + }); + + it("renders ruby fallback parentheses and marks its container for preservation", async () => { + const { $ } = await render("{漢字:かんじ}", ["ruby"]); + expect($("ruby").hasClass(MARKDOWN_PRESERVE_CLASS)).toBe(true); + expect($("ruby rt").text()).toBe("かんじ"); + expect($("ruby rp").length).toBe(2); + }); + + it("expands the first unlinked abbreviation and preserves Korean word boundaries", async () => { + const { $ } = await render( + '[HTML](https://example.org/) HTML ' + + "HTML HTML. HTML은 HTML이다.\n\n*[HTML]: Hypertext Markup Language", + ["abbr"], + { allowHtml: true }, + ); + expect($("a abbr").length).toBe(0); + expect($("abbr").length).toBe(2); + expect($("abbr").first().text()).toBe("HTML"); + expect($("body").text()).toContain("HTML은 HTML이다."); + expect($("abbr").attr("title")).toBe("Hypertext Markup Language"); + expect( + $("body") + .text() + .match(/Hypertext Markup Language/gu), + ).toHaveLength(1); + }); + + it("does not expand abbreviations inside mentions and hashtags", async () => { + const { $ } = await render( + "@HTML@example.org #HTML HTML\n\n*[HTML]: Hypertext Markup Language", + ["abbr"], + ); + expect($("a abbr").length).toBe(0); + expect($("abbr").length).toBe(1); + }); + + it("renders accessible text task markers without form controls or stealing references", async () => { + const { $ } = await render( + "- [ ] waiting\n- [x] done\n- [X] also done\n\n[x]: https://example.com/\n\n[x]", + ["tasklist"], + ); + expect($("input,label").length).toBe(0); + expect($("li.task-list-item").length).toBe(3); + expect($("li").eq(0).text()).toContain("[ ]"); + expect($("li").eq(1).text()).toContain("[x]"); + expect($("li").eq(2).text()).toContain("[x]"); + expect($("a").last().attr("href")).toBe("https://example.com/"); + }); + + it("uses MathML with original-source annotation and excludes math from visible text", async () => { + const { $, env } = await render( + "Before $x^2$ after.\n\n$$\ny = x + 1\n$$", + ["math"], + ); + expect($("math").length).toBe(2); + expect($("annotation[encoding='application/x-tex']").first().text()).toBe( + "x^2", + ); + expect( + $("math").first().closest(`.${MARKDOWN_PRESERVE_CLASS}`).length, + ).toBe(1); + const visibleText = (env.visibleText as string[]).join(""); + expect(visibleText).toContain("Before"); + expect(visibleText).toContain("after."); + expect(visibleText).not.toContain("x^2"); + expect(visibleText).not.toContain("y = x + 1"); + }); + + it("keeps currency, escaped dollars, and code literal", async () => { + const { $, html } = await render("$20 and $30. \\$x$ `$x$`\n\n $x$", [ + "math", + ]); + expect($("math").length).toBe(0); + expect($("code").text()).toContain("$x$"); + expect(html).toContain("$20"); + }); + + it("protects invalid math and keeps TeX macros isolated between formulas", async () => { + const { $, html } = await render( + "$\\gdef\\leak{SECRET}$ $\\leak$ $\\notACommand$", + ["math"], + ); + expect($("body").text()).toContain("$\\leak$"); + expect($("body").text()).toContain("$\\notACommand$"); + expect(html).toContain(MARKDOWN_PRESERVE_CLASS); + expect($("math").length).toBeLessThan(3); + }); + + it("keeps draft math literal while preserving final mention discovery", async () => { + const { $, env } = await render( + "$@hidden@example.net$ @visible@example.net", + ["math"], + { draft: true }, + ); + expect($("math").length).toBe(0); + expect($("body").text()).toContain("$@hidden@example.net$"); + expect(env.mentions).toEqual(["@visible@example.net"]); + }); + + it("allows math at the UTF-16 document boundary", async () => { + const source = "$x$ " + "a".repeat(MAX_MATH_INPUT - 4); + const { $ } = await render(source, ["math"]); + expect(source.length).toBe(MAX_MATH_INPUT); + expect($("math").length).toBe(1); + }); + + it("disables math above the document limit while retaining ordinary Markdown", async () => { + const { $, html } = await render( + "**bold** $x$ " + "a".repeat(MAX_MATH_INPUT), + ["math"], + ); + expect($("math").length).toBe(0); + expect($("strong").text()).toBe("bold"); + expect(html).toContain("$x$"); + }); +}); + +function mathParser( + engine = (_source: string, _options: KatexOptions) => + "x", +) { + const md = new MarkdownIt(); + installMath(md, engine); + return md; +} + +describe("math resource limits", () => { + it("renders at most 64 formulas and resets that budget for every render", () => { + const engine = vi.fn(() => "x"); + const md = mathParser(engine); + const source = Array.from({ length: 65 }, () => "$x$").join(" "); + const html = md.render(source); + expect(engine).toHaveBeenCalledTimes(64); + expect(load(html)("math").length).toBe(64); + expect(load(html)("body").text()).toContain("$x$"); + md.render("$x$"); + expect(engine).toHaveBeenCalledTimes(65); + }); + + it("does not send an oversized expression to the engine", () => { + const engine = vi.fn(() => "x"); + const $ = load(mathParser(engine).render("$" + "x".repeat(8_193) + "$")); + expect(engine).not.toHaveBeenCalled(); + expect($("math").length).toBe(0); + expect($("body").text()).toContain("$" + "x".repeat(8_193) + "$"); + }); + + it("falls back to escaped protected text when the engine fails", () => { + const md = mathParser(() => { + throw new Error("invalid math"); + }); + const $ = load(md.render("$$")); + expect($("script").length).toBe(0); + expect($(`.${MARKDOWN_PRESERVE_CLASS}`).text()).toBe( + "$$", + ); + }); + + it("limits both individual and aggregate MathML output in bytes", () => { + const large = "" + "é".repeat(131_073) + ""; + expect(load(mathParser(() => large).render("$x$"))("math").length).toBe(0); + const medium = "" + "x".repeat(220_000) + ""; + const html = mathParser(() => medium).render("$a$ $b$ $c$ $d$ $e$"); + expect(load(html)("math").length).toBe(4); + expect(load(html)("body").text()).toContain("$e$"); + }); + + it("gives every expression a separate untrusted, bounded macro environment", () => { + const macros: NonNullable[] = []; + const md = mathParser((_source, options) => { + macros.push(options.macros!); + expect(options).toMatchObject({ + output: "mathml", + trust: expect.any(Function), + maxExpand: 1_000, + maxSize: 10, + }); + expect(options.macros).toEqual({}); + options.macros!["\\example"] = "changed"; + return "x"; + }); + md.render("$a$ $b$"); + expect(macros[0]).not.toBe(macros[1]); + }); + + it("indexes hostile unmatched delimiter input with linear scan work", () => { + const source = "$a ".repeat(5_000) + "\\$".repeat(2_000); + expect(scanMathDelimiters(source).visits).toBeLessThanOrEqual( + source.length * 3, + ); + const env: Env = {}; + mathParser().render(source, env); + expect(env[MATH_SCAN_WORK]).toBeLessThanOrEqual(source.length * 6); + }); + + it("respects inline parser boundaries during silent link lookahead", () => { + const $ = load( + mathParser().render("[label $x](https://example.com/) tail$"), + ); + expect($("a").attr("href")).toBe("https://example.com/"); + expect($("a").text()).toBe("label $x"); + expect($("a math").length).toBe(0); + }); + + it("renders bounded inline math inside a Markdown link label", () => { + const $ = load(mathParser().render("[label $x$](https://example.com/)")); + expect($("a").attr("href")).toBe("https://example.com/"); + expect($("a math").length).toBe(1); + }); + + it("does not borrow a block math closer from outside its list container", () => { + const $ = load(mathParser().render("- $$\n x\n\noutside\n$$")); + expect($("li math").length).toBe(0); + expect($("p").text()).toContain("outside"); + }); +}); diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 00000000..f26e17f9 --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,346 @@ +import { + hashtag, + type PluginOptions as HashtagOptions, +} from "@fedify/markdown-it-hashtag"; +import { + mention, + type PluginOptions as MentionOptions, +} from "@fedify/markdown-it-mention"; +import { ruby } from "@mdit/plugin-ruby"; +import MarkdownIt, { + type Env, + type MarkdownIt as Parser, + type StateCore, + type StateInline, + type Token, +} from "markdown-it"; +import abbr from "markdown-it-abbr"; +import deflist from "markdown-it-deflist"; +import footnote from "markdown-it-footnote"; +import ins from "markdown-it-ins"; +import mark from "markdown-it-mark"; +import sub from "markdown-it-sub"; +import sup from "markdown-it-sup"; + +import { installDelimiterLinkify } from "./markdown-linkify"; +import { + installMath, + MARKDOWN_PRESERVE_CLASS, + MAX_MATH_INPUT, +} from "./markdown-math"; +import type { MarkdownExtension } from "./markdown-settings"; + +export function createMarkdownIt(allowHtml = false): Parser { + const md = new MarkdownIt({ linkify: true, html: allowHtml }); + md.linkify.set({ fuzzyLink: true, urlAuth: true }); + return md; +} + +export interface PostMarkdownOptions { + extensions: ReadonlySet; + documentId: string; + permalink: string; + allowHtml?: boolean; + draft?: boolean; + mention?: MentionOptions; + hashtag?: HashtagOptions; +} + +// The Fedify plugins publish markdown-it 14 types but support the v15 API. +function plugin(value: unknown): (md: Parser, options: T) => void { + return value as (md: Parser, options: T) => void; +} + +let katexPromise: Promise | undefined; + +export async function createPostMarkdownIt( + options: PostMarkdownOptions, +): Promise { + const md = createMarkdownIt(options.allowHtml); + const enabled = options.extensions; + if (enabled.has("deflist")) { + md.use(deflist); + md.renderer.rules.dt_close = () => ":
    \n"; + // Keep paragraphs even in a tight definition so removing dl/dt/dd does + // not concatenate a term with its definition. + md.core.ruler.push("definition_paragraphs", (state) => { + let depth = 0; + for (const token of state.tokens) { + if (token.type === "dd_open") depth++; + if ( + depth > 0 && + (token.type === "paragraph_open" || token.type === "paragraph_close") + ) + token.hidden = false; + if (token.type === "dd_close") depth--; + } + }); + } + if (enabled.has("sub")) md.use(sub); + if (enabled.has("sup")) md.use(sup); + if (enabled.has("mark")) md.use(mark); + if (enabled.has("ins")) md.use(ins); + if (enabled.has("mark") || enabled.has("ins")) installDelimiterLinkify(md); + if (enabled.has("ruby")) installRuby(md); + if (enabled.has("abbr")) { + md.use(abbr); + md.core.ruler.at("abbr_replace", replaceAbbreviations); + } + md.use(plugin(mention), options.mention ?? {}); + md.use(plugin(hashtag), options.hashtag ?? {}); + if (enabled.has("footnote")) { + // after('inline') inserts before the already registered Fedify rules. + md.use(footnote); + installFootnoteRenderers(md, options.permalink); + } + if (enabled.has("tasklist")) installTasklists(md); + if (enabled.has("math")) { + // Loading KaTeX is opt-in. The draft still renders mention tokens, but + // its math renderer never calls KaTeX. + if (!options.draft) katexPromise ??= import("katex"); + const engine = options.draft + ? () => "" + : (await katexPromise!).renderToString; + installMath(md, engine, options.draft); + } + md.core.ruler.before("block", "post_markdown_environment", (state) => { + state.env.mentions = []; + state.env.hashtags = []; + state.env.links = []; + state.env.previewLink = null; + state.env.visibleText = []; + state.env.docId = `hollo-${options.documentId}`; + delete state.env.footnotes; + delete state.env.abbreviations; + if (enabled.has("math")) { + const allowMath = state.src.length <= MAX_MATH_INPUT; + if (allowMath) { + md.inline.ruler.enable(["math_inline"]); + md.block.ruler.enable(["math_block"]); + } else { + md.inline.ruler.disable(["math_inline"]); + md.block.ruler.disable(["math_block"]); + } + } + }); + md.core.ruler.push("post_markdown_metadata", (state) => { + const links = state.env.links as string[]; + const visibleText = state.env.visibleText as string[]; + for (const token of walkTokens(state.tokens)) { + if (token.type.startsWith("math_")) continue; + if (token.type === "link_open" || token.type === "image") { + const attribute = token.type === "image" ? "src" : "href"; + const link = token.attrGet(attribute); + if (link != null) { + const original = String(link); + const external = /^https?:\/\//u.test(original); + const absolute = external + ? original + : new URL(original, new URL("/", options.permalink)).href; + token.attrSet(attribute, absolute); + // Match legacy metadata: resolving a relative href must not start + // a new preview or quote lookup. + if (external) { + links.push(absolute); + state.env.previewLink = absolute; + } + } + } + if (token.children == null && token.content !== "") + visibleText.push(token.content); + } + }); + return md; +} + +export function* walkTokens(tokens: Token[]): Generator { + for (const token of tokens) { + yield token; + if ( + token.children != null && + token.type !== "image" && + !token.type.startsWith("math_") + ) + yield* walkTokens(token.children); + } +} + +function installFootnoteRenderers(md: Parser, permalink: string): void { + const url = new URL(permalink); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error("Post permalink must be an HTTP(S) URL"); + const target = (id: string) => { + const result = new URL(url); + result.hash = id; + return md.utils.escapeHtml(result.href); + }; + const id = (token: Token, env: Env = {}) => + `${String(env.docId)}-${Number(token.meta?.id) + 1}`; + const referenceId = (token: Token, env: Env = {}) => + `fnref-${id(token, env)}${Number(token.meta?.subId) > 0 ? `:${Number(token.meta?.subId)}` : ""}`; + md.renderer.rules.footnote_ref = (tokens, idx, _opts, env) => { + const token = tokens[idx]; + const name = referenceId(token, env); + return `[${Number(token.meta?.id) + 1}]`; + }; + md.renderer.rules.footnote_open = (tokens, idx, _opts, env) => + `
  • [${Number(tokens[idx].meta?.id) + 1}] `; + md.renderer.rules.footnote_anchor = (tokens, idx, _opts, env) => + ` ↩︎`; +} + +function installTasklists(md: Parser): void { + md.core.ruler.before("inline", "tasklist_source", (state) => { + for (let i = 0; i + 2 < state.tokens.length; i++) { + const item = state.tokens[i]; + const paragraph = state.tokens[i + 1]; + const inline = state.tokens[i + 2]; + if ( + item.type !== "list_item_open" || + paragraph.type !== "paragraph_open" || + inline.type !== "inline" + ) + continue; + const match = /^\[([ xX])\](?:[ \t]+|$)/u.exec(inline.content); + if (match == null) continue; + inline.meta = { + ...inline.meta, + taskStatus: match[1].toLowerCase() === "x", + }; + inline.content = inline.content.slice(match[0].length); + item.attrJoin("class", "task-list-item"); + } + }); + md.core.ruler.push("tasklist_marker", (state) => { + for (const token of state.tokens) { + if ( + token.type !== "inline" || + typeof token.meta?.taskStatus !== "boolean" + ) + continue; + const marker = new state.Token("text", "", 0); + marker.content = token.meta.taskStatus ? "[x] " : "[ ] "; + token.children?.unshift(marker); + } + }); +} + +function replaceAbbreviations(state: StateCore): void { + const definitions = state.env.abbreviations as + | Record + | undefined; + if (definitions == null) return; + const labels = Object.keys(definitions) + .map((label) => label.slice(1)) + .sort((a, b) => b.length - a.length); + if (labels.length === 0) return; + const boundary = "[\\p{P}\\p{Z}\\s$+<=>^`|~]"; + const regexp = new RegExp( + `(^|${boundary})(${labels.map(state.md.utils.escapeRE).join("|")})(?=$|${boundary})`, + "gu", + ); + const expanded = new Set(); + for (const block of state.tokens) { + if (block.type !== "inline" || block.children == null) continue; + let depth = 0; + let htmlDepth = 0; + const output: Token[] = []; + for (const token of block.children) { + if (token.type === "link_open") depth++; + if (token.type === "link_close") depth--; + if (token.type === "html_inline") { + if (/^)/iu.test(token.content)) htmlDepth++; + if (/^<\/a\s*>/iu.test(token.content)) + htmlDepth = Math.max(0, htmlDepth - 1); + } + if (token.type !== "text" || depth > 0 || htmlDepth > 0) { + output.push(token); + continue; + } + let pos = 0; + for (const match of token.content.matchAll(regexp)) { + const start = match.index + match[1].length; + if (start > pos) { + const text = new state.Token("text", "", 0); + text.content = token.content.slice(pos, start); + output.push(text); + } + const label = match[2]; + const title = definitions[`:${label}`]; + const open = new state.Token("abbr_open", "abbr", 1); + open.attrs = [["title", title]]; + const text = new state.Token("text", "", 0); + text.content = label; + output.push(open, text, new state.Token("abbr_close", "abbr", -1)); + if (!expanded.has(label)) { + const expansion = new state.Token("text", "", 0); + expansion.content = ` (${title})`; + output.push(expansion); + expanded.add(label); + } + pos = start + label.length; + } + if (pos < token.content.length) { + const rest = new state.Token("text", "", 0); + rest.content = token.content.slice(pos); + output.push(rest); + } + } + block.children = output; + } +} + +function installRuby(md: Parser): void { + const previous = new Set(md.inline.ruler.getRules("")); + md.use(ruby); + const rule = md.inline.ruler + .getRules("") + .find((candidate) => !previous.has(candidate))!; + // Limit ruby to a non-nested brace pair. Pre-index failed candidates so + // malformed '{' runs cannot make the upstream scanner quadratic. + const cache = new WeakMap< + StateInline, + { colon: Int32Array; close: Int32Array; open: Int32Array } + >(); + md.inline.ruler.at("ruby", (state, silent) => { + if (silent || state.src[state.pos] !== "{") return false; + let index = cache.get(state); + if (index == null) { + const n = state.src.length; + index = { + colon: new Int32Array(n + 1).fill(-1), + close: new Int32Array(n + 1).fill(-1), + open: new Int32Array(n + 1).fill(-1), + }; + let colon = -1; + let close = -1; + let open = -1; + for (let i = n - 1; i >= 0; i--) { + index.colon[i] = colon; + index.close[i] = close; + index.open[i] = open; + if (state.src[i - 1] === "\\") continue; + if (state.src[i] === ":") colon = i; + if (state.src[i] === "}") close = i; + if (state.src[i] === "{") open = i; + } + cache.set(state, index); + } + const start = state.pos; + const close = index.close[start]; + const colon = index.colon[start]; + if ( + close < 0 || + close >= state.posMax || + colon <= start + 1 || + colon >= close - 1 || + (index.open[start] >= 0 && index.open[start] < close) + ) + return false; + return rule(state, silent); + }); + md.renderer.rules.ruby_open = () => + ``; + md.renderer.rules.rt_open = () => "("; + md.renderer.rules.rt_close = () => ")"; +} diff --git a/src/pages/profile/profilePost.tsx b/src/pages/profile/profilePost.tsx index 973ab94e..87cf14ea 100644 --- a/src/pages/profile/profilePost.tsx +++ b/src/pages/profile/profilePost.tsx @@ -65,7 +65,12 @@ function PostPage({ post, accountOwner, baseUrl }: PostPageProps) { themeColor={accountOwner.themeColor} >
    - + {post.replies.length > 0 && (

    diff --git a/src/text.ts b/src/text.ts index 6af7b28b..7e1222f7 100644 --- a/src/text.ts +++ b/src/text.ts @@ -10,7 +10,7 @@ import { type DocumentLoader, isActor, lookupObject } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { inArray } from "drizzle-orm"; import { escape } from "es-toolkit"; -import MarkdownIt, { +import { type Env as MarkdownItEnv, type MarkdownIt as MarkdownItInstance, } from "markdown-it"; @@ -21,6 +21,12 @@ import type { DatabaseLike } from "./db"; import { persistAccount } from "./federation/account"; import { type ASPost, isPost } from "./federation/post"; import { transformPostHtmlWithGukhanmun } from "./gukhanmun"; +import { + createMarkdownIt as createBaseMarkdownIt, + createPostMarkdownIt, + type PostMarkdownOptions, +} from "./markdown"; +import { getMarkdownExtensions } from "./markdown-settings"; import * as schema from "./schema"; import type { Uuid } from "./uuid"; @@ -86,9 +92,7 @@ async function getShikiPlugin(): Promise { } export function createMarkdownIt(): MarkdownItInstance { - const md = new MarkdownIt({ linkify: true, html: ALLOW_HTML }); - md.linkify.set({ fuzzyLink: true, urlAuth: true }); - return md; + return createBaseMarkdownIt(ALLOW_HTML); } function containsCodeBlock(text: string): boolean { @@ -104,13 +108,24 @@ export async function formatText( url: URL | string; contextLoader?: DocumentLoader; documentLoader?: DocumentLoader; + markdown?: Pick< + PostMarkdownOptions, + "extensions" | "documentId" | "permalink" + >; }, ): Promise { // List all mentions: - const draft = createMarkdownIt() - .use(mentionPlugin, {}) - .use(hashtagPlugin, {}); - const draftEnv: { mentions: string[] } = { mentions: [] }; + const draft = + options.markdown == null + ? createMarkdownIt().use(mentionPlugin, {}).use(hashtagPlugin, {}) + : await createPostMarkdownIt({ + ...options.markdown, + allowHtml: ALLOW_HTML, + draft: true, + }); + const draftEnv: { mentions: string[]; visibleText?: string[] } = { + mentions: [], + }; draft.render(text, draftEnv); // Collect already persisted accounts: @@ -146,7 +161,10 @@ export async function formatText( // Collect custom emojis: const emojis: string[] = []; - for (const m of text.matchAll(CUSTOM_EMOJI_REGEXP)) emojis.push(m[1]); + for (const m of (draftEnv.visibleText?.join("\n") ?? text).matchAll( + CUSTOM_EMOJI_REGEXP, + )) + emojis.push(m[1]); const customEmojis = emojis.length > 0 ? await db.query.customEmojis.findMany({ @@ -155,44 +173,55 @@ export async function formatText( : []; // Render the final HTML: - const md = createMarkdownIt() - .use(mentionPlugin, { - link(handle) { - if (handle in handles) return handles[handle].href; - return null; - }, - linkAttributes(handle: string) { - return { - "data-account-id": handles[handle].id, - "data-account-handle": handle, - translate: "no", - class: "h-card u-url mention", - }; - }, - label(handle: string) { - const bareHandle = handle.replaceAll(/(?:^@)|(?:@[^@]+$)/g, ""); - return `@${escape(bareHandle)}`; - }, - }) - .use(hashtagPlugin, { - link(tag) { - return new URL( - `/tags/${encodeURIComponent(tag.substring(1))}`, - options.url, - ).href; - }, - linkAttributes(tag: string) { - return { - "data-tag": tag.substring(1), - class: "mention hashtag", - rel: "tag", - }; - }, - label(tag: string) { - return `#${escape(tag.substring(1))}`; - }, - }) - .use(replaceLinkPlugin, { + const mentionOptions: MentionPluginOptions = { + link(handle) { + if (handle in handles) return handles[handle].href; + return null; + }, + linkAttributes(handle: string) { + return { + "data-account-id": handles[handle].id, + "data-account-handle": handle, + translate: "no", + class: "h-card u-url mention", + }; + }, + label(handle: string) { + const bareHandle = handle.replaceAll(/(?:^@)|(?:@[^@]+$)/g, ""); + return `@${escape(bareHandle)}`; + }, + }; + const hashtagOptions: HashtagPluginOptions = { + link(tag) { + return new URL( + `/tags/${encodeURIComponent(tag.substring(1))}`, + options.url, + ).href; + }, + linkAttributes(tag: string) { + return { + "data-tag": tag.substring(1), + class: "mention hashtag", + rel: "tag", + }; + }, + label(tag: string) { + return `#${escape(tag.substring(1))}`; + }, + }; + const md = + options.markdown == null + ? createMarkdownIt() + .use(mentionPlugin, mentionOptions) + .use(hashtagPlugin, hashtagOptions) + : await createPostMarkdownIt({ + ...options.markdown, + allowHtml: ALLOW_HTML, + mention: mentionOptions, + hashtag: hashtagOptions, + }); + if (options.markdown == null) + md.use(replaceLinkPlugin, { processHTML: false, replaceLink(link: string, env: Env) { if (link.startsWith("http://") || link.startsWith("https://")) { @@ -212,7 +241,16 @@ export async function formatText( links: [], }; const html = md.render(text, env); - logger.debug("Markdown-It environment: {env}", { env }); + // Parser internals can include the full body and footnote definitions. + // Keep diagnostics limited to the metadata logged before extensions. + logger.debug("Markdown-It environment: {env}", { + env: { + mentions: env.mentions, + hashtags: env.hashtags, + previewLink: env.previewLink, + links: env.links, + }, + }); let quoteTarget: ASPost | null = null; for (const link of env.links) { const object = await lookupObject(link, options); @@ -263,11 +301,20 @@ export async function formatPostContent( language: string | null | undefined, options: { url: URL | string; + documentId: string; + permalink: string; contextLoader?: DocumentLoader; documentLoader?: DocumentLoader; }, ): Promise { - const result = await formatText(db, text, options); + const result = await formatText(db, text, { + ...options, + markdown: { + extensions: getMarkdownExtensions(), + documentId: options.documentId, + permalink: options.permalink, + }, + }); result.html = await transformPostHtmlWithGukhanmun(result.html, language); return result; } diff --git a/src/xss.test.ts b/src/xss.test.ts new file mode 100644 index 00000000..458b6a11 --- /dev/null +++ b/src/xss.test.ts @@ -0,0 +1,45 @@ +import { renderToString } from "katex"; +import { describe, expect, it } from "vitest"; + +import { sanitizeHtml } from "./xss"; + +describe("sanitizeHtml Markdown extensions", () => { + it.each([ + String.raw`\sum_{i=0}^{n} \frac{1}{x_i}`, + String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, + String.raw`\begin{aligned}a&=b\\c&=d\end{aligned}`, + String.raw`\overbrace{x+y}^{z} \not= \sqrt[3]{x} \mathbb{R} \text{漢字}`, + String.raw`\color{red}{x} \rule{1em}{1ex} \overset{a}{\to} \pmod{n}`, + String.raw`\displaystyle\bigl(\small x\bigr) + \mathop{f}\limits_{x}`, + ])("preserves MathML semantics generated for %s", (source) => { + const html = renderToString(source, { + output: "mathml", + displayMode: true, + trust: false, + strict: "ignore", + }).replace("/g, " />")); + expect(sanitizeHtml(html)).toContain('encoding="application/x-tex"'); + }); + + it("retains authored ruby protection and semantic extension elements", () => { + const html = + '漢字(한자)highlightadded23HTML'; + expect(sanitizeHtml(html)).toBe(html); + }); + + it("keeps scripts, event handlers, URLs, and unapproved MathML attributes filtered", () => { + const html = + 'x'; + const result = sanitizeHtml(html); + expect(result).not.toMatch(/<(?:iframe|foreignObject)\b/i); + expect(result).not.toContain('onclick="'); + expect(result).not.toContain('onload="'); + expect(result).not.toContain('href="javascript:'); + expect(result).not.toContain('style="position:'); + expect(sanitizeHtml("")).toBe( + "<script>alert(1)</script>", + ); + }); +}); diff --git a/src/xss.ts b/src/xss.ts index 5f93d8aa..89c19c45 100644 --- a/src/xss.ts +++ b/src/xss.ts @@ -91,7 +91,7 @@ const htmlXss = new FilterXSS({ q: ["lang", "translate", "cite"], rp: ["lang", "translate"], rt: ["lang", "translate"], - ruby: ["lang", "translate"], + ruby: ["lang", "translate", "class"], s: ["lang", "translate"], samp: ["lang", "translate"], section: ["lang", "translate", "class"], @@ -142,7 +142,7 @@ const htmlXss = new FilterXSS({ ], // MathML - math: ["class", "xmlns"], + math: ["class", "xmlns", "display"], maction: ["actiontype", "selection"], annotation: ["encoding"], "annotation-xml": ["encoding"], @@ -153,7 +153,17 @@ const htmlXss = new FilterXSS({ mi: ["mathvariant"], mmultiscripts: ["subscriptshift", "superscriptshift"], mn: ["mathvariant"], - mo: ["fence", "lspace", "rspace", "stretchy"], + mo: [ + "fence", + "lspace", + "rspace", + "stretchy", + "separator", + "largeop", + "minsize", + "maxsize", + "mathcolor", + ], mover: ["accent"], mpadded: ["height", "depth", "width", "lspace", "voffset"], mphantom: ["class"], @@ -162,13 +172,20 @@ const htmlXss = new FilterXSS({ mrow: ["displaystyle"], ms: ["lquote", "rquote"], semantics: ["class"], - mspace: ["depth", "height", "width"], + mspace: ["depth", "height", "width", "mathbackground", "linebreak"], msqrt: ["displaystyle"], - mstyle: ["displaystyle", "mathcolor", "mathbackground"], + mstyle: [ + "displaystyle", + "mathcolor", + "mathbackground", + "scriptlevel", + "mathsize", + ], msub: ["subscriptshift"], msup: ["superscriptshift"], msubsup: ["subscriptshift", "superscriptshift"], mtable: [ + "width", "align", "columnalign", "columnspacing", diff --git a/uno.config.ts b/uno.config.ts index 15295306..fb5938ef 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -100,6 +100,15 @@ export default defineConfig({ button:disabled, [role="button"][aria-disabled="true"] { cursor: not-allowed; } + .prose .footnotes-list { list-style: none; padding-inline-start: 0; } + .prose .footnote-item > p:first-of-type { display: inline; } + .prose .footnote-number { font-variant-numeric: tabular-nums; } + .prose .task-list-item { padding-inline-start: .25em; } + .prose math[display="block"] { overflow-x: auto; max-width: 100%; } + .prose mark { background: rgb(var(--theme-200)); color: rgb(var(--theme-900)); } + @media (prefers-color-scheme: dark) { + .prose mark { background: rgb(var(--theme-800)); color: rgb(var(--theme-100)); } + } ::selection { background-color: rgb(var(--theme-200)); color: rgb(var(--theme-900)); From 67ed18bc7df551de065c3fdb739cd4ab1aac89aa Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 23 Oct 2026 00:35:06 +0900 Subject: [PATCH 2/5] Handle hyphens in abbreviation labels markdown-it's escapeRE() escapes hyphens as \-, which is invalid in a Unicode regular expression. Labels such as Wi-Fi therefore made draft and final rendering throw. Use RegExp.escape() for literal labels. Test hyphenated labels and regex metacharacters in both passes, including one visible expansion for repeated labels. https://github.com/fedify-dev/hollo/pull/680#discussion_r4350257630 Changelog: none Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-opus-5-5 --- src/markdown.test.ts | 24 ++++++++++++++++++++++++ src/markdown.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/markdown.test.ts b/src/markdown.test.ts index 3a54ea8d..c1c28823 100644 --- a/src/markdown.test.ts +++ b/src/markdown.test.ts @@ -264,6 +264,30 @@ describe("post Markdown extensions", () => { expect($("ruby rp").length).toBe(2); }); + it.each([true, false])( + "keeps abbreviation labels literal (draft: %s)", + async (draft) => { + for (const label of ["Wi-Fi", "COVID-19", "C++", "A|B"]) { + const { $ } = await render( + `${label} ${label}\n\n*[${label}]: Expanded meaning`, + ["abbr"], + { draft }, + ); + expect( + $("abbr") + .map((_, element) => $(element).text()) + .get(), + ).toEqual([label, label]); + expect($("abbr").first().attr("title")).toBe("Expanded meaning"); + expect( + $("body") + .text() + .match(/Expanded meaning/gu), + ).toHaveLength(1); + } + }, + ); + it("expands the first unlinked abbreviation and preserves Korean word boundaries", async () => { const { $ } = await render( '[HTML](https://example.org/) HTML ' + diff --git a/src/markdown.ts b/src/markdown.ts index f26e17f9..4370ab85 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -236,7 +236,7 @@ function replaceAbbreviations(state: StateCore): void { if (labels.length === 0) return; const boundary = "[\\p{P}\\p{Z}\\s$+<=>^`|~]"; const regexp = new RegExp( - `(^|${boundary})(${labels.map(state.md.utils.escapeRE).join("|")})(?=$|${boundary})`, + `(^|${boundary})(${labels.map(RegExp.escape).join("|")})(?=$|${boundary})`, "gu", ); const expanded = new Set(); From a4867ed6beb849b7cebe04f6ea71afa89ff8871b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 23 Oct 2026 01:03:28 +0900 Subject: [PATCH 3/5] Keep abbreviation matching compatible with Node 23 Escape regex syntax characters directly so abbreviation rendering works on Node.js 23, where RegExp.escape is unavailable. Hyphens remain literal in the Unicode-mode matcher. https://github.com/fedify-dev/hollo/pull/680#discussion_r4350466408 Changelog: none Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-opus-5-5 --- src/markdown.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/markdown.ts b/src/markdown.ts index 4370ab85..dad9bf4e 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -236,7 +236,9 @@ function replaceAbbreviations(state: StateCore): void { if (labels.length === 0) return; const boundary = "[\\p{P}\\p{Z}\\s$+<=>^`|~]"; const regexp = new RegExp( - `(^|${boundary})(${labels.map(RegExp.escape).join("|")})(?=$|${boundary})`, + `(^|${boundary})(${labels + .map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|")})(?=$|${boundary})`, "gu", ); const expanded = new Set(); From e62b11e4d1e37db8fd945abf79b034e63f742d29 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 23 Oct 2026 04:49:07 +0900 Subject: [PATCH 4/5] Exercise Markdown across real federated servers Add a Create/Update acceptance runner and record actual Mastodon 4.7.3 and Misskey 2026.10.0 output for all ten extensions. Record the matching Misskey inbox log, and document rendering losses and unsupported Note updates in all five guides. Browser, client, and accessibility checks remain pending. https://github.com/fedify-dev/hollo/pull/680#pullrequestreview-5617399504 Changelog: none Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-opus-5-5 --- docs/src/content/docs/ja/markdown.mdx | 8 + docs/src/content/docs/ko/markdown.mdx | 8 + docs/src/content/docs/markdown.mdx | 8 + docs/src/content/docs/zh-cn/markdown.mdx | 7 + docs/src/content/docs/zh-tw/markdown.mdx | 7 + scripts/test-markdown-federation.ts | 361 ++++++++++++++++++ tests/markdown-federation/README.md | 93 +++++ tests/markdown-federation/config.example.json | 6 + .../misskey-update-log.json | 52 +++ .../markdown-federation/receiver-results.json | 221 +++++++++++ 10 files changed, 771 insertions(+) create mode 100644 scripts/test-markdown-federation.ts create mode 100644 tests/markdown-federation/README.md create mode 100644 tests/markdown-federation/config.example.json create mode 100644 tests/markdown-federation/misskey-update-log.json create mode 100644 tests/markdown-federation/receiver-results.json diff --git a/docs/src/content/docs/ja/markdown.mdx b/docs/src/content/docs/ja/markdown.mdx index b8f5d363..523a0054 100644 --- a/docs/src/content/docs/ja/markdown.mdx +++ b/docs/src/content/docs/ja/markdown.mdx @@ -137,3 +137,11 @@ Mastodonは数式の注釈をドル記号付きのTeX文字列として表示で 数式の組版を保証するものではありません。Misskeyでは数式の表示テキストと TeX注釈が連結される場合があります。表示はサーバーやクライアントによって 異なります。数式が重要な意味を持つ場合は、通常の言葉でも説明を添えてください。 + +実際のサーバー間[受け入れテスト]ではMastodon 4.7.3とMisskey 2026.10.0で +全10種類の例の受信を確認しました。Mastodonは編集を反映しましたが、 +Misskey 2026.10.0はNoteの更新を無視し、元の内容を保持しました。 +記録には受信したHTML/MFMと失われた情報が含まれます。 +ブラウザー、クライアント、アクセシビリティの確認は未完了です。 + +[受け入れテスト]: https://github.com/fedify-dev/hollo/blob/main/tests/markdown-federation/README.md diff --git a/docs/src/content/docs/ko/markdown.mdx b/docs/src/content/docs/ko/markdown.mdx index b59928c9..518e6e94 100644 --- a/docs/src/content/docs/ko/markdown.mdx +++ b/docs/src/content/docs/ko/markdown.mdx @@ -138,3 +138,11 @@ Mastodon은 수식 주석을 달러로 감싼 TeX 텍스트로 표시할 수 있 조판을 보장하지 않습니다. Misskey에서는 수식 표시 텍스트와 TeX 주석이 이어 붙을 수 있습니다. 서버와 클라이언트마다 표시가 다를 수 있습니다. 수식이 핵심 의미를 전달한다면 일반 언어로 된 설명도 함께 쓰세요. + +실제 서버 간 [수락 테스트]에서 Mastodon 4.7.3과 Misskey 2026.10.0 모두 +열 가지 예시를 수신했습니다. Mastodon은 수정을 반영했지만, +Misskey 2026.10.0은 Note 갱신을 무시하고 최초 내용을 유지했습니다. +수신한 HTML/MFM과 손실 내역을 기록해 두었습니다. +브라우저·클라이언트·접근성 검증은 아직 완료하지 않았습니다. + +[수락 테스트]: https://github.com/fedify-dev/hollo/blob/main/tests/markdown-federation/README.md diff --git a/docs/src/content/docs/markdown.mdx b/docs/src/content/docs/markdown.mdx index 8e27e62a..45c237c1 100644 --- a/docs/src/content/docs/markdown.mdx +++ b/docs/src/content/docs/markdown.mdx @@ -137,3 +137,11 @@ Mastodon can expose math annotations as dollar-delimited TeX text; this is not a guarantee of typeset mathematics. Misskey can concatenate presentation text with the TeX annotation. Display varies between servers and clients. Explain essential formulas in ordinary language too. + +Actual server-to-server [acceptance tests] covered Mastodon 4.7.3 and +Misskey 2026.10.0. Both stored all ten examples. Mastodon applied edits; +Misskey 2026.10.0 ignored Note updates and retained the original content. +The recorded results include received HTML/MFM and rendering losses. +Browser, client, and accessibility checks remain pending. + +[acceptance tests]: https://github.com/fedify-dev/hollo/blob/main/tests/markdown-federation/README.md diff --git a/docs/src/content/docs/zh-cn/markdown.mdx b/docs/src/content/docs/zh-cn/markdown.mdx index 35a13f72..5a3d1a94 100644 --- a/docs/src/content/docs/zh-cn/markdown.mdx +++ b/docs/src/content/docs/zh-cn/markdown.mdx @@ -134,3 +134,10 @@ Mastodon 可能将公式注释显示为带美元符号的 TeX 文本,这并不 Misskey 可能将公式显示文本和 TeX 注释连接在一起。 不同服务器和客户端的显示可能不同。 当公式承载重要含义时,请附上普通语言的说明。 + +实际服务器间的[验收测试]覆盖了 Mastodon 4.7.3 和 Misskey 2026.10.0, +两者均收到了全部十种示例。Mastodon 应用了编辑;Misskey 2026.10.0 +忽略了 Note 更新,保留原始内容。记录包含接收到的 HTML/MFM 及信息损失。 +浏览器、客户端和无障碍验证尚未完成。 + +[验收测试]: https://github.com/fedify-dev/hollo/blob/main/tests/markdown-federation/README.md diff --git a/docs/src/content/docs/zh-tw/markdown.mdx b/docs/src/content/docs/zh-tw/markdown.mdx index ac7e01f2..d7be906a 100644 --- a/docs/src/content/docs/zh-tw/markdown.mdx +++ b/docs/src/content/docs/zh-tw/markdown.mdx @@ -134,3 +134,10 @@ Mastodon 可能將公式註解顯示為帶美元符號的 TeX 文字,這並不 Misskey 可能將公式顯示文字和 TeX 註解連接在一起。 不同伺服器和客戶端的顯示可能不同。 當公式承載重要含義時,請附上一般語言的說明。 + +實際伺服器間的[驗收測試]涵蓋了 Mastodon 4.7.3 和 Misskey 2026.10.0, +兩者均收到了全部十種範例。Mastodon 套用了編輯;Misskey 2026.10.0 +忽略了 Note 更新,保留原始內容。記錄包含接收到的 HTML/MFM 及資訊遺失。 +瀏覽器、客戶端和無障礙驗證尚未完成。 + +[驗收測試]: https://github.com/fedify-dev/hollo/blob/main/tests/markdown-federation/README.md diff --git a/scripts/test-markdown-federation.ts b/scripts/test-markdown-federation.ts new file mode 100644 index 00000000..4e63f73b --- /dev/null +++ b/scripts/test-markdown-federation.ts @@ -0,0 +1,361 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { setTimeout } from "node:timers/promises"; +import { parseArgs } from "node:util"; + +import { load } from "cheerio"; +import { z } from "zod"; + +const serverSchema = z.object({ + url: z.url(), + token: z.string().min(1), +}); +const configSchema = z.object({ + hollo: serverSchema, + mastodon: serverSchema, + misskey: serverSchema, + misskeyNoteUpdates: z.enum(["applied", "ignored"]).default("applied"), +}); +type Server = z.infer; +type Status = { id: string; uri: string; content: string }; +type Note = { id: string; uri?: string; text: string | null }; + +const { values } = parseArgs({ + options: { + config: { type: "string" }, + report: { type: "string" }, + }, +}); +assert(values.config && values.report, "Use --config FILE --report FILE"); +const config = configSchema.parse( + JSON.parse(await readFile(values.config, "utf8")), +); + +async function request( + server: Server, + path: string, + body?: object, + method = body == null ? "GET" : "POST", +): Promise { + const options: RequestInit = { + method, + headers: { + authorization: `Bearer ${server.token}`, + "content-type": "application/json", + }, + signal: AbortSignal.timeout(15_000), + }; + if (body != null) { + assert.notEqual(method, "GET"); + options.body = JSON.stringify(body); + } + const response = await fetch(new URL(path, server.url), options); + assert(response.ok, `${method} ${path}: HTTP ${response.status}`); + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +async function misskey(path: string, body: object = {}): Promise { + return await request(config.misskey, `/api/${path}`, { + i: config.misskey.token, + ...body, + }); +} + +async function eventually( + label: string, + check: () => Promise, +): Promise { + const deadline = Date.now() + 120_000; + do { + const result = await check(); + if (result != null) return result; + await setTimeout(1_000); + } while (Date.now() < deadline); + throw new Error(`Timed out waiting for ${label}`); +} + +const fixtures = [ + { + name: "footnote", + selector: "sup.footnote-ref a[href]", + source: "Reference[^n]\n\n[^n]: Footnote detail", + visible: ["Reference", "Footnote detail", "1"], + }, + { + name: "deflist", + selector: "dl dt", + source: "Term\n: Definition", + visible: ["Term", "Definition"], + }, + { + name: "sub", + selector: "sub", + source: "Water H~2~O", + visible: ["Water", "2"], + }, + { + name: "sup", + selector: "sup", + source: "Power x^2^", + visible: ["Power", "2"], + }, + { + name: "mark", + selector: "mark", + source: "==Highlight==", + visible: ["Highlight"], + }, + { + name: "ruby", + selector: "ruby rt", + source: "{漢字:かんじ}", + visible: ["漢字", "かんじ"], + }, + { + name: "ins", + selector: "ins", + source: "++Addition++", + visible: ["Addition"], + }, + { + name: "abbr", + selector: "abbr", + source: "HTML\n\n*[HTML]: Hypertext Markup Language", + visible: ["HTML", "Hypertext Markup Language"], + }, + { + name: "tasklist", + selector: "li.task-list-item", + source: "- [x] Done\n- [ ] Pending", + visible: ["[x] Done", "[ ] Pending"], + }, + { + name: "math", + selector: "math annotation", + source: "Inline $x^2$\n\n$$\nx^2\n$$", + visible: ["Inline", "x", "2"], + }, +]; + +const report: { + testedAt: string; + servers: { hollo: string; mastodon: string; misskey: string }; + results: object[]; + error?: string; + cleanupErrors?: string[]; +} = { + testedAt: new Date().toISOString(), + servers: { hollo: "", mastodon: "", misskey: "" }, + results: [], +}; +const createdIds: string[] = []; +try { + const [holloInstance, mastodonInstance, misskeyInstance, sender] = + await Promise.all([ + request<{ version: string }>(config.hollo, "/api/v1/instance"), + request<{ version: string }>(config.mastodon, "/api/v2/instance"), + misskey<{ version: string }>("meta"), + request<{ id: string; url: string }>( + config.hollo, + "/api/v1/accounts/verify_credentials", + ), + ]); + report.servers = { + hollo: holloInstance.version, + mastodon: mastodonInstance.version, + misskey: misskeyInstance.version, + }; + if (config.misskeyNoteUpdates === "ignored") { + assert.equal( + misskeyInstance.version, + "2026.10.0", + "The recorded Note update limitation is specific to Misskey 2026.10.0", + ); + } + const search = new URLSearchParams({ + q: sender.url, + type: "accounts", + resolve: "true", + }); + const mastodonSearch = await request<{ accounts: { id: string }[] }>( + config.mastodon, + `/api/v2/search?${search}`, + ); + assert.equal(mastodonSearch.accounts.length, 1); + const mastodonSender = mastodonSearch.accounts[0].id; + await request( + config.mastodon, + `/api/v1/accounts/${mastodonSender}/follow`, + {}, + ); + const misskeyResolved = await misskey<{ + type: string; + object: { id: string }; + }>("ap/show", { uri: sender.url }); + assert.equal(misskeyResolved.type, "User"); + const misskeySender = misskeyResolved.object.id; + const relationship = await misskey<{ isFollowing: boolean }>("users/show", { + userId: misskeySender, + }); + if (!relationship.isFollowing) + await misskey("following/create", { userId: misskeySender }); + await eventually("both follows to reach Hollo", async () => { + const account = await request<{ followers_count: number }>( + config.hollo, + "/api/v1/accounts/verify_credentials", + ); + return account.followers_count >= 2 ? true : undefined; + }); + + const runId = crypto.randomUUID(); + for (const fixture of fixtures) { + const marker = `HolloMarkdown ${runId} ${fixture.name}`; + const source = `${marker} Phase1\n\n${fixture.source}`; + const created = await request(config.hollo, "/api/v1/statuses", { + status: source, + visibility: "public", + language: "en", + }); + createdIds.push(created.id); + assert( + load(created.content)(fixture.selector).length > 0, + `${fixture.name}: extension was not rendered by Hollo`, + ); + const storedSource = await request<{ text: string }>( + config.hollo, + `/api/v1/statuses/${created.id}/source`, + ); + assert.equal(storedSource.text, source); + const mastodonCreated = await eventually( + `${fixture.name} Create on Mastodon`, + async () => { + const statuses = await request( + config.mastodon, + `/api/v1/accounts/${mastodonSender}/statuses?limit=40`, + ); + return statuses.find((status) => status.uri === created.uri); + }, + ); + const misskeyCreated = await eventually( + `${fixture.name} Create on Misskey`, + async () => { + const notes = await misskey("users/notes", { + userId: misskeySender, + limit: 100, + }); + return notes.find((note) => note.uri === created.uri); + }, + ); + const updatedSource = source.replace("Phase1", "Phase2"); + const updated = await request( + config.hollo, + `/api/v1/statuses/${created.id}`, + { status: updatedSource }, + "PUT", + ); + assert( + load(updated.content)(fixture.selector).length > 0, + `${fixture.name}: extension was not rendered after editing`, + ); + const updatedStoredSource = await request<{ text: string }>( + config.hollo, + `/api/v1/statuses/${created.id}/source`, + ); + assert.equal(updatedStoredSource.text, updatedSource); + const mastodonUpdated = await eventually( + `${fixture.name} Update on Mastodon`, + async () => { + const status = await request( + config.mastodon, + `/api/v1/statuses/${mastodonCreated.id}`, + ); + return status.content.includes("Phase2") ? status : undefined; + }, + ); + const misskeyUpdated = + config.misskeyNoteUpdates === "ignored" + ? (await setTimeout(5_000), + await misskey("notes/show", { noteId: misskeyCreated.id })) + : await eventually(`${fixture.name} Update on Misskey`, async () => { + const note = await misskey("notes/show", { + noteId: misskeyCreated.id, + }); + return note.text?.includes("Phase2") ? note : undefined; + }); + for (const [phase, mastodon, misskeyNote] of [ + ["Phase1", mastodonCreated, misskeyCreated], + ["Phase2", mastodonUpdated, misskeyUpdated], + ] as const) { + const mastodonText = load(mastodon.content).text(); + const misskeyText = misskeyNote.text ?? ""; + for (const [receiver, text] of [ + ["mastodon", mastodonText], + ["misskey", misskeyText], + ] as const) { + const expectedPhase = + receiver === "misskey" && config.misskeyNoteUpdates === "ignored" + ? "Phase1" + : phase; + assert( + !text.includes(expectedPhase === "Phase1" ? "Phase2" : "Phase1"), + ); + for (const expected of [marker, expectedPhase, ...fixture.visible]) { + assert( + text.includes(expected), + `${fixture.name} ${phase}: missing ${JSON.stringify(expected)}`, + ); + } + } + } + report.results.push({ + extension: fixture.name, + source: fixture.source, + uri: created.uri, + receiverIds: { mastodon: mastodonCreated.id, misskey: misskeyCreated.id }, + create: { + hollo: created.content, + mastodon: mastodonCreated.content, + misskey: misskeyCreated.text, + }, + update: { + hollo: updated.content, + mastodon: mastodonUpdated.content, + misskey: misskeyUpdated.text, + misskeyNoteUpdates: config.misskeyNoteUpdates, + ...(config.misskeyNoteUpdates === "ignored" + ? { + observation: + "unchanged after 5 seconds; delivery must be checked in receiver logs", + } + : {}), + }, + }); + console.log( + `${fixture.name}: Create received on both; Mastodon updated; Misskey update ${config.misskeyNoteUpdates}`, + ); + } +} catch (error) { + report.error = error instanceof Error ? error.message : String(error); + throw error; +} finally { + const cleanup = await Promise.allSettled( + createdIds.map(async (id) => { + await request( + config.hollo, + `/api/v1/statuses/${id}`, + undefined, + "DELETE", + ); + }), + ); + report.cleanupErrors = cleanup.flatMap((result) => + result.status === "rejected" ? [String(result.reason)] : [], + ); + await writeFile(values.report, `${JSON.stringify(report, null, 2)}\n`); +} +assert.equal( + report.cleanupErrors?.length, + 0, + "Cleanup failed; see report.cleanupErrors", +); diff --git a/tests/markdown-federation/README.md b/tests/markdown-federation/README.md new file mode 100644 index 00000000..56949459 --- /dev/null +++ b/tests/markdown-federation/README.md @@ -0,0 +1,93 @@ +Markdown federation acceptance tests +==================================== + +Run *scripts/test-markdown-federation.ts* against three disposable servers to +check actual HTTP inbox delivery. The runner creates one public post per +extension, checks the saved Markdown and rendered Hollo HTML, waits for both +receivers to store the post, then edits it and checks each receiver again. +It queries account timelines and stored receiver IDs, without resolving post +URLs that could fetch missing activities on demand. Posts are deleted in +cleanup; the receiver accounts continue following the sender. + +This is an API and federation check. It does not establish browser rendering, +keyboard, touch, screen-reader behavior, or compatibility with other API +clients. Those checks remain pending before release. + + +Run the tests +------------- + +Use a Hollo checkout with all ten `MARKDOWN_EXTENSIONS` enabled and one local +account. Prepare one account each on Mastodon and Misskey, with federation +enabled, and run their delivery workers. Use tokens that can read, post, and +follow. All three servers must resolve each other's canonical HTTPS origins +and trust their certificates. Use canonical HTTPS port 443, as in the recorded +run. + +Copy *config.example.json* to a private file outside the checkout, replace the +origins and tokens, and restrict its permissions: + +~~~~ bash +chmod 600 /path/to/private-config.json +mise exec -- pnpm exec tsx scripts/test-markdown-federation.ts \ + --config /path/to/private-config.json \ + --report /path/to/receiver-results.json +~~~~ + +The default expectation is that both receivers apply edits. Set +`misskeyNoteUpdates` to `ignored` only for Misskey 2026.10.0, whose handler does +not support `Update(Note)`. In that mode the runner checks that the original +text remains after a five-second observation window. Check the receiver's +inbox logs separately to distinguish ignored delivery from delayed or missing +delivery. The recorded run below includes that log evidence. + +The runner fails if Hollo does not render an extension, visible fallback text +disappears, a receiver does not store a post or apply an expected edit, or +cleanup fails. +Its JSON report contains versions, Markdown source, object URIs, receiver IDs, +and the Hollo HTML, Mastodon HTML, and Misskey MFM before and after editing. +It omits tokens. A failed run writes its partial results and error too. + + +Recorded run +------------ + +On 23 October 2026 (KST), the runner exercised Hollo 0.10.0 at +`a4867ed6beb849b7cebe04f6ea71afa89ff8871b` against unmodified official images: + +| Receiver | Version | Image digest | +| -------- | --------- | --------------------------------------------------------------------------------------------------- | +| Mastodon | 4.7.3 | `ghcr.io/mastodon/mastodon@sha256:e013a8b5c0be1e5a204791d3be7b1be544026cb5ca28ed3dfff9e69dbf4812a1` | +| Misskey | 2026.10.0 | `docker.io/misskey/misskey@sha256:17fad0b0607cafb57c08da72e1a2b1b9ccee5d3a671b4458536ca580a18d6aae` | + +The disposable lab used PostgreSQL 18, Redis 7, Node.js 24.22.0, and Caddy +2.11.2 with a local CA. The servers communicated through a dedicated rootless +Podman network and canonical port 443. The CA was trusted only in the test +processes. Private-address access was enabled only in these isolated +instances. No global trust store or existing service was changed. + +Both receivers stored all ten posts. Mastodon applied all ten +edits. Misskey retained every original note: its inbox logged +`skip: Unknown type: Note` for each Update. The [release's update handler] +supports actor and Question updates, without a Note branch. Edits therefore do +not reach Misskey 2026.10.0 users. + +See *receiver-results.json* for the actual API output and +*misskey-update-log.json* for the matching inbox observations. Local lab URLs +are evidence identifiers, not publicly accessible posts. Receiver output was: + +| Extension | Mastodon 4.7.3 | Misskey 2026.10.0 | +| --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | +| Footnotes | Keeps numbers and absolute links; removes local IDs | Keeps numbers and absolute links in MFM | +| Definition lists | Flattens semantic tags; keeps colon and separation | Keeps term, colon, and separated definition | +| Subscript/superscript | Flattens `H₂O`/`x²` to `H2O`/`x2` | Flattens to `H2O`/`x2` | +| Highlight/insertion | Keeps text, loses marking | Keeps text, loses marking | +| Ruby | Retains `ruby`, `rt`, and fallback `rp` | Converts the fixture to `$[ruby 漢字 かんじ]` | +| Abbreviations | Keeps visible first-use expansion; drops tooltip | Keeps visible first-use expansion | +| Task lists | Keeps list and literal `[x]`/`[ ]` | Keeps literal markers in text | +| Math | Converts annotations to dollar-delimited TeX, without MathML | Concatenates presentation text and TeX; inline `x^2` becomes `x2x^2` | + +These results cover the recorded examples and receiver versions. They do +not promise that arbitrary ruby, formulas, or clients behave the same way. + +[release's update handler]: https://github.com/misskey-dev/misskey/blob/de89118a225fbe0833e42ab94e8d5af1e5211208/packages/backend/src/core/activitypub/ApInboxService.ts#L780-L803 diff --git a/tests/markdown-federation/config.example.json b/tests/markdown-federation/config.example.json new file mode 100644 index 00000000..ca5f034c --- /dev/null +++ b/tests/markdown-federation/config.example.json @@ -0,0 +1,6 @@ +{ + "hollo": { "url": "https://hollo.example", "token": "REPLACE_ME" }, + "mastodon": { "url": "https://mastodon.example", "token": "REPLACE_ME" }, + "misskey": { "url": "https://misskey.example", "token": "REPLACE_ME" }, + "misskeyNoteUpdates": "ignored" +} diff --git a/tests/markdown-federation/misskey-update-log.json b/tests/markdown-federation/misskey-update-log.json new file mode 100644 index 00000000..cd85229c --- /dev/null +++ b/tests/markdown-federation/misskey-update-log.json @@ -0,0 +1,52 @@ +[ + { + "extension": "footnote", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1#update-2026-10-22T19:46:13.122Z reason=skip: Unknown type: Note" + }, + { + "extension": "deflist", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-6bd3-7602-becf-25b46c9fca56", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-6bd3-7602-becf-25b46c9fca56#update-2026-10-22T19:46:20.34Z reason=skip: Unknown type: Note" + }, + { + "extension": "sub", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-8819-7d54-ac40-e27809a08c20", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-8819-7d54-ac40-e27809a08c20#update-2026-10-22T19:46:28.612Z reason=skip: Unknown type: Note" + }, + { + "extension": "sup", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-a893-77d9-a696-b0ee76662ca0", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-a893-77d9-a696-b0ee76662ca0#update-2026-10-22T19:46:35.906Z reason=skip: Unknown type: Note" + }, + { + "extension": "mark", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-c4d3-7403-bef1-64816c2397eb", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-c4d3-7403-bef1-64816c2397eb#update-2026-10-22T19:46:43.123Z reason=skip: Unknown type: Note" + }, + { + "extension": "ruby", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-e11c-72ff-ab04-151746b24527", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-e11c-72ff-ab04-151746b24527#update-2026-10-22T19:46:50.366Z reason=skip: Unknown type: Note" + }, + { + "extension": "ins", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-fd5d-70dd-9e91-9dd734461d37", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16526-fd5d-70dd-9e91-9dd734461d37#update-2026-10-22T19:46:57.593Z reason=skip: Unknown type: Note" + }, + { + "extension": "abbr", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-1998-71b5-ab67-febe5b52ffbe", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16527-1998-71b5-ab67-febe5b52ffbe#update-2026-10-22T19:47:04.832Z reason=skip: Unknown type: Note" + }, + { + "extension": "tasklist", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-35d0-7a80-a452-5d4d22278e92", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16527-35d0-7a80-a452-5d4d22278e92#update-2026-10-22T19:47:12.054Z reason=skip: Unknown type: Note" + }, + { + "extension": "math", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-5204-7634-ac2e-f402fa22ca10", + "inbox": "WARN 1\t[queue inbox]\tinbox activity ignored (maybe): id=https://hollo.127.0.0.1.sslip.io/@sender/01a16527-5204-7634-ac2e-f402fa22ca10#update-2026-10-22T19:47:19.318Z reason=skip: Unknown type: Note" + } +] diff --git a/tests/markdown-federation/receiver-results.json b/tests/markdown-federation/receiver-results.json new file mode 100644 index 00000000..de4903f9 --- /dev/null +++ b/tests/markdown-federation/receiver-results.json @@ -0,0 +1,221 @@ +{ + "testedAt": "2026-10-22T19:46:11.780Z", + "servers": { + "hollo": "0.10.0", + "mastodon": "4.7.3", + "misskey": "2026.10.0" + }, + "results": [ + { + "extension": "footnote", + "source": "Reference[^n]\n\n[^n]: Footnote detail", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1", + "receiverIds": { + "mastodon": "117486280513325969", + "misskey": "asnimy0dsu05000e" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase1

    Reference[1]

    \n
    \n
    \n
      \n
    1. [1]

      Footnote detail ↩︎

      \n
    2. \n
    \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase1

    Reference[1]

    \n \n \n
      \n
    1. [1]

      Footnote detail ↩︎

      \n
    2. \n
    \n \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase1\n\nReference[[1]](https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1#fn-hollo-01a16526-4fa7-7957-8e92-4f2aecec10e1-1)\n\n\n\n\n[1] \n\nFootnote detail [↩︎](https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1#fnref-hollo-01a16526-4fa7-7957-8e92-4f2aecec10e1-1)" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase2

    Reference[1]

    \n
    \n
    \n
      \n
    1. [1]

      Footnote detail ↩︎

      \n
    2. \n
    \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase2

    Reference[1]

    \n \n \n
      \n
    1. [1]

      Footnote detail ↩︎

      \n
    2. \n
    \n \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 footnote Phase1\n\nReference[[1]](https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1#fn-hollo-01a16526-4fa7-7957-8e92-4f2aecec10e1-1)\n\n\n\n\n[1] \n\nFootnote detail [↩︎](https://hollo.127.0.0.1.sslip.io/@sender/01a16526-4fa7-7957-8e92-4f2aecec10e1#fnref-hollo-01a16526-4fa7-7957-8e92-4f2aecec10e1-1)", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "deflist", + "source": "Term\n: Definition", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-6bd3-7602-becf-25b46c9fca56", + "receiverIds": { + "mastodon": "117486280985691135", + "misskey": "asnin3kksu05000f" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase1

    \n
    \n
    Term:
    \n
    \n

    Definition

    \n
    \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase1

    \n \n Term:
    \n \n

    Definition

    \n \n \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase1\n\n\nTerm:\n\n\n\n\n\nDefinition" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase2

    \n
    \n
    Term:
    \n
    \n

    Definition

    \n
    \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase2

    \n \n Term:
    \n \n

    Definition

    \n \n \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 deflist Phase1\n\n\nTerm:\n\n\n\n\n\nDefinition", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "sub", + "source": "Water H~2~O", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-8819-7d54-ac40-e27809a08c20", + "receiverIds": { + "mastodon": "117486281576867091", + "misskey": "asnin95msu05000g" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase1

    Water H2O

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase1

    Water H2O

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase1\n\nWater H2O" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase2

    Water H2O

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase2

    Water H2O

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sub Phase1\n\nWater H2O", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "sup", + "source": "Power x^2^", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-a893-77d9-a696-b0ee76662ca0", + "receiverIds": { + "mastodon": "117486282005617090", + "misskey": "asninfkksu05000h" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase1

    Power x2

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase1

    Power x2

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase1\n\nPower x2" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase2

    Power x2

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase2

    Power x2

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 sup Phase1\n\nPower x2", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "mark", + "source": "==Highlight==", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-c4d3-7403-bef1-64816c2397eb", + "receiverIds": { + "mastodon": "117486282478977410", + "misskey": "asninl5gsu05000i" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase1

    Highlight

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase1

    Highlight

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase1\n\nHighlight" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase2

    Highlight

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase2

    Highlight

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 mark Phase1\n\nHighlight", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "ruby", + "source": "{漢字:かんじ}", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-e11c-72ff-ab04-151746b24527", + "receiverIds": { + "mastodon": "117486282953130478", + "misskey": "asninqqnsu05000j" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase1

    漢字(かんじ)

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase1

    漢字(かんじ)

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase1\n\n$[ruby 漢字 かんじ]" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase2

    漢字(かんじ)

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase2

    漢字(かんじ)

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ruby Phase1\n\n$[ruby 漢字 かんじ]", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "ins", + "source": "++Addition++", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16526-fd5d-70dd-9e91-9dd734461d37", + "receiverIds": { + "mastodon": "117486283426535238", + "misskey": "asninwbisu05000k" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase1

    Addition

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase1

    Addition

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase1\n\nAddition" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase2

    Addition

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase2

    Addition

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 ins Phase1\n\nAddition", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "abbr", + "source": "HTML\n\n*[HTML]: Hypertext Markup Language", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-1998-71b5-ab67-febe5b52ffbe", + "receiverIds": { + "mastodon": "117486283900522062", + "misskey": "asnio1wasu05000l" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase1

    HTML (Hypertext Markup Language)

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase1

    HTML (Hypertext Markup Language)

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase1\n\nHTML (Hypertext Markup Language)" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase2

    HTML (Hypertext Markup Language)

    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase2

    HTML (Hypertext Markup Language)

    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 abbr Phase1\n\nHTML (Hypertext Markup Language)", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "tasklist", + "source": "- [x] Done\n- [ ] Pending", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-35d0-7a80-a452-5d4d22278e92", + "receiverIds": { + "mastodon": "117486284375168806", + "misskey": "asnio7gxsu05000m" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase1

      \n
    • [x] Done
    • \n
    • [ ] Pending
    • \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase1

      \n
    • [x] Done
    • \n
    • [ ] Pending
    • \n
    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase1\n\n[x] Done\n\n[ ] Pending" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase2

      \n
    • [x] Done
    • \n
    • [ ] Pending
    • \n
    \n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase2

      \n
    • [x] Done
    • \n
    • [ ] Pending
    • \n
    \n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 tasklist Phase1\n\n[x] Done\n\n[ ] Pending", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + }, + { + "extension": "math", + "source": "Inline $x^2$\n\n$$\nx^2\n$$", + "uri": "https://hollo.127.0.0.1.sslip.io/@sender/01a16527-5204-7634-ac2e-f402fa22ca10", + "receiverIds": { + "mastodon": "117486284848504546", + "misskey": "asniod1jsu05000n" + }, + "create": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase1

    Inline x2x^2

    \nx2\nx^2\n\n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase1

    Inline $x^2$

    \n$$\nx^2\n$$\n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase1\n\nInline x2x^2\nx2\nx^2" + }, + "update": { + "hollo": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase2

    Inline x2x^2

    \nx2\nx^2\n\n", + "mastodon": "

    HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase2

    Inline $x^2$

    \n$$\nx^2\n$$\n", + "misskey": "HolloMarkdown e58646ae-b204-46d7-942e-6632a37486d0 math Phase1\n\nInline x2x^2\nx2\nx^2", + "misskeyNoteUpdates": "ignored", + "observation": "unchanged after 5 seconds; delivery must be checked in receiver logs" + } + } + ], + "cleanupErrors": [] +} From 4e7ea63045f46da3cd92692aa47a3e3f3b7f972a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 23 Oct 2026 13:40:46 +0900 Subject: [PATCH 5/5] Protect federation test credentials in transit Require HTTPS server URLs and reject redirects for authenticated acceptance-test requests, including Misskey's token-bearing POSTs. https://github.com/fedify-dev/hollo/pull/680#discussion_r4352898551 Changelog: none Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-opus-5-5 --- scripts/test-markdown-federation.ts | 5 ++++- tests/markdown-federation/README.md | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/test-markdown-federation.ts b/scripts/test-markdown-federation.ts index 4e63f73b..aa004a08 100644 --- a/scripts/test-markdown-federation.ts +++ b/scripts/test-markdown-federation.ts @@ -7,7 +7,9 @@ import { load } from "cheerio"; import { z } from "zod"; const serverSchema = z.object({ - url: z.url(), + url: z.url().refine((url) => new URL(url).protocol === "https:", { + message: "Server URL must use HTTPS", + }), token: z.string().min(1), }); const configSchema = z.object({ @@ -39,6 +41,7 @@ async function request( ): Promise { const options: RequestInit = { method, + redirect: "error", headers: { authorization: `Bearer ${server.token}`, "content-type": "application/json", diff --git a/tests/markdown-federation/README.md b/tests/markdown-federation/README.md index 56949459..0e4dd5b5 100644 --- a/tests/markdown-federation/README.md +++ b/tests/markdown-federation/README.md @@ -22,7 +22,8 @@ account. Prepare one account each on Mastodon and Misskey, with federation enabled, and run their delivery workers. Use tokens that can read, post, and follow. All three servers must resolve each other's canonical HTTPS origins and trust their certificates. Use canonical HTTPS port 443, as in the recorded -run. +run. The runner accepts only HTTPS origins and rejects redirects on +authenticated requests. Copy *config.example.json* to a private file outside the checkout, replace the origins and tokens, and restrict its permissions: