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..523a0054 --- /dev/null +++ b/docs/src/content/docs/ja/markdown.mdx @@ -0,0 +1,147 @@ +--- +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注釈が連結される場合があります。表示はサーバーやクライアントによって +異なります。数式が重要な意味を持つ場合は、通常の言葉でも説明を添えてください。 + +実際のサーバー間[受け入れテスト]では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/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..518e6e94 --- /dev/null +++ b/docs/src/content/docs/ko/markdown.mdx @@ -0,0 +1,148 @@ +--- +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 주석이 +이어 붙을 수 있습니다. 서버와 클라이언트마다 표시가 다를 수 +있습니다. 수식이 핵심 의미를 전달한다면 일반 언어로 된 설명도 함께 쓰세요. + +실제 서버 간 [수락 테스트]에서 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 new file mode 100644 index 00000000..45c237c1 --- /dev/null +++ b/docs/src/content/docs/markdown.mdx @@ -0,0 +1,147 @@ +--- +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. + +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/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..5a3d1a94 --- /dev/null +++ b/docs/src/content/docs/zh-cn/markdown.mdx @@ -0,0 +1,143 @@ +--- +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 注释连接在一起。 +不同服务器和客户端的显示可能不同。 +当公式承载重要含义时,请附上普通语言的说明。 + +实际服务器间的[验收测试]覆盖了 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/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..d7be906a --- /dev/null +++ b/docs/src/content/docs/zh-tw/markdown.mdx @@ -0,0 +1,143 @@ +--- +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 註解連接在一起。 +不同伺服器和客戶端的顯示可能不同。 +當公式承載重要含義時,請附上一般語言的說明。 + +實際伺服器間的[驗收測試]涵蓋了 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/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/scripts/test-markdown-federation.ts b/scripts/test-markdown-federation.ts new file mode 100644 index 00000000..aa004a08 --- /dev/null +++ b/scripts/test-markdown-federation.ts @@ -0,0 +1,364 @@ +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().refine((url) => new URL(url).protocol === "https:", { + message: "Server URL must use HTTPS", + }), + 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, + redirect: "error", + 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/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..c1c28823 --- /dev/null +++ b/src/markdown.test.ts @@ -0,0 +1,498 @@ +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.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 ' + + "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..dad9bf4e --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,348 @@ +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((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .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/tests/markdown-federation/README.md b/tests/markdown-federation/README.md new file mode 100644 index 00000000..0e4dd5b5 --- /dev/null +++ b/tests/markdown-federation/README.md @@ -0,0 +1,94 @@ +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. 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: + +~~~~ 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": [] +} 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));