diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8855c7..fd8faad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Reference links and images, plus editable footnotes with insertion, keyboard navigation, and export support. See [syntax and limits](docs/editor-guide.md#reference-links-and-footnotes) ([#113](https://github.com/BinaryOutlook/binary-markdown/issues/113)). + ## 0.5.2 A compatible update following 0.5.1. It keeps unsaved edits when another program writes the open file, groups settings into categories, and adds optional icon-only code-block buttons. See the [0.5.2 notes](release-notes/0.5.2.md) for what changes after updating and its known limitations. [GitHub Releases](https://github.com/BinaryOutlook/binary-markdown/releases) identifies published packages and their validation. diff --git a/docs/editor-guide.md b/docs/editor-guide.md index 6c65e815..cbb11c29 100644 --- a/docs/editor-guide.md +++ b/docs/editor-guide.md @@ -36,7 +36,7 @@ Code spans, code blocks, equations, and generated document blocks cannot be unde ## Insert menu -The Insert workspace remains available in both **Full** and **Simple** mode. Open the three-dot **More toolbar actions** menu, search for **Insert**, and choose the **Insert** command. The workspace includes inline equations, block equations, tables, code blocks, links, images, Mermaid diagrams, and a managed table of contents. Search the three-dot menu for an individual insertion command to run it directly, or use its existing toolbar button, shortcut, or Action Palette entry. +The Insert workspace remains available in both **Full** and **Simple** mode. Open the three-dot **More toolbar actions** menu, search for **Insert**, and choose the **Insert** command. The workspace includes inline equations, block equations, tables, code blocks, links, images, footnotes, Mermaid diagrams, and a managed table of contents. Search the three-dot menu for an individual insertion command to run it directly, or use its existing toolbar button, shortcut, or Action Palette entry. Opening the menu retains your caret or selection. Use up/down arrows or `Home`/`End` to navigate, `Enter` or `Space` to choose an item, and `Escape` to close it. Opening, navigating, or cancelling the menu does not edit the document. Cancelling a link or image dialog also leaves the document and undo history unchanged. Confirmed insertions use the retained location and can be undone in one step. @@ -83,6 +83,27 @@ Search the Insert workspace or choose **All**, **Structure**, **Equations**, **C --- +## Reference links and footnotes + +Full (`[text][guide]`), collapsed (`[guide][]`), and shortcut (`[guide]`) references, including images, share definitions and optional titles. Open **Link definition […] — Edit definition** to change a shared destination or title. Editing link text keeps its target, expanding collapsed or shortcut syntax when needed. + +```markdown +Read the [project guide][guide] and [release notes][]. +The result has a supporting note.[^method] + +[guide]: https://example.com/guide "Project guide" +[release notes]: https://example.com/releases +[^method]: The measurement follows the documented procedure. +``` + +Choose **Footnote** from Insert or the Action Palette to add a marker at the caret, or after selected text. The editor creates an unused `note-N` definition at the end and selects its text. Repeated references share a number and have separate return links; use `Tab`, then `Enter` or `Space`, to navigate. + +Open **Footnote … — Edit definition** to edit note Markdown; indent continuation blocks by four spaces. Press `Ctrl/Cmd+Enter` or leave the field to refresh its preview. Save and Undo work in the field. Visual, Source, and Split share definitions; Split's preview is read-only. + +Labels ignore case; link labels also normalize whitespace. Footnote labels cannot be empty or contain whitespace or square brackets. The first definition wins; duplicates and unresolved full/collapsed links or notes show explanations. Unresolved and malformed syntax stays editable. Code and escaped markers stay literal. + +Unchanged blocks preserve source spelling, identifiers, and definition placement; edited blocks may normalize formatting. HTML/PDF keep notes at their definition locations, not page bottoms. DOCX/EPUB use native notes and may number repeats separately. Unused and duplicate notes stay in source but are omitted from exports. See [export support](export-subsystem.md#rendering-and-format-support). + ## Keyboard operations diff --git a/docs/export-subsystem.md b/docs/export-subsystem.md index 29c8d8f7..53a1c49e 100644 --- a/docs/export-subsystem.md +++ b/docs/export-subsystem.md @@ -70,7 +70,10 @@ The [fixed fixtures](../test/fixtures/exports/README.md) exercise the following | Images and fonts | Resolve and embed needed assets; standalone HTML works after relocation offline. | Package compatible media; preserve source resolution without deliberate downsampling. | | Raw HTML, editor extensions, unsupported constructs | Follow declared renderer support and remove editor-only machinery. | Explicit target-specific behavior; visible fallback/warning for known incompatible content. | | Front matter, TOC, internal image directives | Exclude front matter/editor-only directives from visible content. Save expands `[TOC]` markers and refreshes managed contents. Exports require current TOCs and include links to unique heading destinations without editor controls or boundary comments. | Normalize temporary input deliberately; avoid duplicate TOCs or leaked directives. | -| Footnotes | Existing footnote markers/definitions remain visible source with a warning; linked footnotes are not generated. | Use Pandoc's supported native note/navigation structures, with target-specific verification. | +| Reference-style links and images | Full, collapsed, and shortcut references resolve shared definitions and titles; unresolved source stays visible. | Pandoc resolves references and packages supported images. | +| Footnotes | Numbered links and return links at definition locations, not page bottoms. Unresolved/malformed notes stay visible with warnings. | Native notes/navigation; repeated references may receive separate numbers. | + +The first definition wins. Unused/duplicate notes stay in source but are omitted from output. See [syntax and source preservation](editor-guide.md#reference-links-and-footnotes). The saved-revision requirement still applies. Do not claim a parser capability merely because Pandoc can write its output format. Existing rendering bugs are not silently expanded into export guarantees. Declare known limitations and preserve supported content; fallback acceptance is not permission to hide a regression by downgrading the support matrix. diff --git a/media/export-help.md b/media/export-help.md index 7e020c99..efca6c1c 100644 --- a/media/export-help.md +++ b/media/export-help.md @@ -108,9 +108,10 @@ The anchored Export panel keeps format availability, setup actions, and job stat ## Initial format limitations -- HTML and PDF reuse supported editor rendering. HTML retains the editor theme; PDF uses the white/theme setting above. Inline `$...$` / `\(...\)`, display `$$...$$` / `\[...\]`, and fenced `math` blocks render with KaTeX. Backslash recognition follows `binary-markdown.math.backslashDelimiters` (enabled by default). Saved tables of contents render as links to headings. Footnotes remain visible source with warnings. Invalid supported math/diagrams receive source fallbacks. +- HTML and PDF reuse supported editor rendering. HTML retains the editor theme; PDF uses the white/theme setting above. Inline `$...$` / `\(...\)`, display `$$...$$` / `\[...\]`, and fenced `math` blocks render with KaTeX. Backslash recognition follows `binary-markdown.math.backslashDelimiters` (enabled by default). Saved tables of contents render as links to headings. Invalid supported math/diagrams receive source fallbacks. - PDF uses simple pagination. Generous blank areas are acceptable; oversized text and tables split, while graphics scale to fit. - DOCX and EPUB prioritize editable structure and native math. Enabled backslash equation delimiters are converted to dollars in an export-only copy held in memory; the source file is preserved, and temporary conversion files are removed when the job ends. TeX command support and layout can differ from KaTeX. Raw HTML is exported as readable source when its behavior cannot be preserved. +- Reference links/images resolve shared definitions and titles. HTML/PDF notes have numbered links and return links at definition locations, not page bottoms; unresolved/malformed notes stay visible with warnings. DOCX/EPUB use native notes and may number repeats separately. The first definition wins; unused/duplicate notes stay in source but are omitted from output. - DOCX code blocks have a pale background and, when unnumbered, a border. DOCX and direct PDF exports can show the declared language (for example, `python`) in a small italic tab at the selected corner. The outline faces the adjoining code edge and uses a matching background and border. Blocks without a language do not receive a guessed label. DOCX code remains editable, with syntax coloring for languages Pandoc recognizes; PDF retains its browser-rendered code formatting. - Supported images retain source resolution. Unsupported image formats, unreadable resources and unrepresentable diagrams receive visible fallbacks. - Only needed referenced resources may be fetched. Conversion remains local. Standalone HTML embeds supported resources for offline viewing. diff --git a/package-lock.json b/package-lock.json index 0b52de6c..7c19c339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@xmldom/xmldom": "0.9.12", "katex": "0.18.2", "markdown-it": "14.3.3", + "markdown-it-footnote": "4.0.0", "mermaid": "10.9.8", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2" @@ -4541,6 +4542,12 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it-footnote": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-4.0.0.tgz", + "integrity": "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==", + "license": "MIT" + }, "node_modules/markdown-it/node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", diff --git a/package.json b/package.json index 51f15a31..61337284 100644 --- a/package.json +++ b/package.json @@ -607,6 +607,7 @@ "@xmldom/xmldom": "0.9.12", "katex": "0.18.2", "markdown-it": "14.3.3", + "markdown-it-footnote": "4.0.0", "mermaid": "10.9.8", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2" diff --git a/reports/README.md b/reports/README.md index 35e26262..e9d17840 100644 --- a/reports/README.md +++ b/reports/README.md @@ -23,6 +23,7 @@ These records preserve observations about particular revisions and environments. | Recorded scope | Report | Evidence boundary | | --- | --- | --- | +| 2026-11-14 footnotes and reference links | [Issue #113 acceptance audit](validation/2026-11-14-footnotes-references.md) | Local implementation, parser/browser regressions, four-format export inspection, and remaining installed-host acceptance | | 2026-10-07 0.5.0 readiness | [Integrated scope, candidate acceptance and validation follow-up](validation/2026-10-07-0.5.0-readiness.md) | Exact inspected main baseline, accepted RC2, dependency and diagnostic fixes, candidate boundaries and remaining release gates | | 2026-10-02 scoped AI visual fidelity | [Review summary and archived evidence](validation/2026-10-02-ui-ux-visual-fidelity/report.md) | Assessed source, 82 required cases, verdict boundaries, archive checksums and links to complete retained evidence; owner acceptance remains separate | | 2026-10-01 selected UI/UX implementation | [Selected designs, screenshots, and security audit](validation/2026-10-01-selected-ui-ux/report.md) | All 12 selected directions, synthetic browser visuals, local regression results, scoped security review, and pending maintainer acceptance | diff --git a/reports/validation/2026-11-14-footnotes-references.md b/reports/validation/2026-11-14-footnotes-references.md new file mode 100644 index 00000000..60f38a07 --- /dev/null +++ b/reports/validation/2026-11-14-footnotes-references.md @@ -0,0 +1,56 @@ +# Footnotes and reference links: issue #113 validation + +Reviewed on 2026-11-14 (Asia/Singapore), before publication. [Issue #113](https://github.com/BinaryOutlook/binary-markdown/issues/113) was implemented on `feat/footnotes-reference-links` from remote `main` at `7982568add8140bcfa2edef7bd3af2b6fed122c1`. The [receipt](evidence/2026-11-14-footnotes-references.json) records changed runtime inputs, tool versions, and output hashes. Artifacts remain in the ignored local `validation-evidence/issue-113/` directory. This is development validation, not release acceptance. + +## Acceptance checklist + +| Criterion | Result | +| --- | --- | +| Reference forms and images | Full, collapsed, shortcut, repeated, and image references resolve shared definitions and titles. Parser and browser tests cover edits and linked images. | +| Footnote editing and navigation | Insertion avoids identifier collisions. Definition editing, keyboard navigation, repeated references, missing definitions, and unused/duplicate preview navigation pass. | +| Source preservation | Adjacent edits preserve unrelated definitions, placement, identifiers, destinations, and titles. Link text edits retain the shared target. | +| Views, history, save/reopen | Visual/Source/Split, Undo/Redo, save acknowledgments, and reload pass in the production browser harness. Installed-VS-Code persistence remains unverified. | +| Export and readers | HTML/PDF links and native DOCX/EPUB notes pass converter and reader checks below. Native tests also verify image bytes and unchanged source. | +| Syntax edge cases | Code, escapes, Unicode, titles, duplicates, malformed definitions, nested images, and reference-like math have regression coverage. | +| Documentation | The editor guide, support matrix, packaged help, and changelog describe syntax and limits. Source and rendered documentation were reviewed. | + +## Verification + +| Check | Result | +| --- | --- | +| Compilation and production fixtures | Passed; Node 24.21.0 | +| Complete unit suite | 475 passed, 20 optional checks skipped, 0 failed | +| TypeScript lint | 0 errors; 11 warnings in unchanged files | +| Full browser run | 1,275 passed, 1 stale command-count assertion failed | +| Final affected browser run | 149 passed, 0 failed, retries disabled | +| Real Pandoc reference/footnote test | 1 passed; Pandoc 3.8.3 produced DOCX and EPUB | +| Dependency audit | 0 vulnerabilities | +| Documentation and whitespace | Passed | + +The command-count assertion was corrected for the new Footnote action. The final affected run covers that fix, reference/footnote behavior, insertion, exports, and selected UI checks, including new Split and unused-note navigation regressions. The full suite was not repeated after those corrections. + +The installed Chromium sandbox helper could not initialize. Browser checks used a temporary local configuration with Chromium's inner sandbox disabled; repository and CI settings were unchanged. Optional converter/package checks were skipped in the ordinary unit run; the explicit Pandoc test ran separately. + +## Output inspection + +A synthetic document combined shared links, repeated case-varied notes, a table, a quotation, literal code, multi-paragraph notes, and math. + +- **HTML:** Chromium displayed resolved links and readable notes without editor controls. +- **PDF:** Rasterized output was visually inspected. All six note/return links resolved; the document also contained six external links. +- **DOCX:** Headless LibreOffice rendered readable page-bottom notes, formatting, links, and math. Repeated references received separate numbers. Interactive Word editing was not tested. +- **EPUB:** EPUB.js displayed the exported book, three native note references, and their bodies. Forward and return navigation worked. + +HTML/PDF retain notes at authored locations and share repeated-reference numbers; DOCX/EPUB may repeat native notes. These observations do not establish identical behavior across readers or platforms. + +## Reproduction and remaining acceptance + +Use the repository root, `.node-version`, and the [testing](../../docs/testing/README.md) and [export verification](../../docs/export-verification.md) guides. The feature-specific checks are: + +```sh +CI=1 npx playwright test test/specs/references-footnotes.spec.ts --workers=2 --retries=0 +EXPORT_REAL_TOOLS=1 node --test --test-name-pattern='real Pandoc preserves reference links' test/unit/export-backends.test.js +``` + +Compile and regenerate fixtures first. Set `EXPORT_PANDOC_PATH` if Pandoc is outside tool discovery; the commands use the normal browser configuration. + +Remaining acceptance: install a fresh development VSIX in an isolated VS Code profile, edit references and notes in Visual/Source/Split, then save, close, reopen, and compare source while exercising Undo/Redo. Interactive Word editing and save/reopen remain unverified. This report does not close the issue. diff --git a/reports/validation/evidence/2026-11-14-footnotes-references.json b/reports/validation/evidence/2026-11-14-footnotes-references.json new file mode 100644 index 00000000..41790309 --- /dev/null +++ b/reports/validation/evidence/2026-11-14-footnotes-references.json @@ -0,0 +1,56 @@ +{ + "reviewDate": "2026-11-14", + "timezone": "Asia/Singapore", + "issue": 113, + "baseCommit": "7982568add8140bcfa2edef7bd3af2b6fed122c1", + "sourceState": "Uncommitted development changes on feat/footnotes-reference-links", + "changedRuntimeInputsSha256": { + "package-lock.json": "d3821188e3b2096b331f167f3f94ef97630334b42d8df3656ebc44edf3b5584c", + "package.json": "a93785b289e995fd1d7808eb5d56aa6a882e539cf3b82acec98069b01675e638", + "src/i18n/locales/en.ts": "be30010cf9add5982a4181d55ca5d852991bef89b158428d187e2b093b93f3ef", + "src/i18n/locales/es.ts": "7f9f61be9b3b96e9f51734dabf2407999c1dcbf44aa912bba475ec2c7c1d4f10", + "src/i18n/locales/fr.ts": "ebd81847c3c8f508e9f0669b6fb02d9cff0cfc1ab9708fb857db5d454ef53795", + "src/i18n/locales/ja.ts": "fd1033c09bf4a8a8e5bfc890c09a7e789f59160b5bbc357655c3bcaf08f9c193", + "src/i18n/locales/ko.ts": "bdec4b3b5bf93eca562bce372d37a9e805d4742b85ba02e1b37dac1ebf7206bb", + "src/i18n/locales/zh-cn.ts": "8faab73a5420526c087b02b12d22df94a4751821ed5d2f4fa407bbadaf162a38", + "src/i18n/locales/zh-tw.ts": "697d4d44bd6daa619d83c2949289ed0bf7f96e276430155f998c30cca0e63600", + "src/shared/markdown-blocks.js": "7b5b22a49f31a1b0e0fea890870f6979defd64cba736570808e240ccb6ab0c8a", + "src/shared/markdown-references.js": "660db80896ccb67fec766a68fa69a9f71bd3d907888b42248734471a96b22e55", + "src/webview/editor.js": "e991a36b6be97b20ef4a4ea9be3467ce97909a8576dfb1d50dcfc73e9e6f08cf", + "src/webview/editor/blocks/export.js": "4849125c267067aaab0eace7048f18fee8c3a9d971d1705ec66bb8eb340968ea", + "src/webview/editor/blocks/references.js": "57e8e35e1365d9eccaf35c2a11bd443b5198e72e3efd211ca5fb99d006391ddb", + "src/webview/editor/blocks/render.js": "95c804bedff36c03cdf80b0db5b428284ed72faab29594344f75fd44f9655ece", + "src/webview/editor/blocks/special.js": "60155da84d34b891903dc006ca1ec17958d1a3dd74a028f669113382ba33f879", + "src/webview/editor/codec/blocks.js": "09017a2a7b290fdbbe250b388f2dbccc94f15b18ca8dea4b055edab227cd687d", + "src/webview/editor/codec/inline.js": "c659a06ca18d978b2312f7c8063756ab5a4fa03a65b2e60db74ca1522e623649", + "src/webview/editor/codec/references.js": "e8e9dde8e2cd9589629e350d3269aa0d364511f4447161a0072a5e83ae499d17", + "src/webview/editor/input/dispatch.js": "973640d48542f99d2db6d7e23aa0fb21acddfbfe012d7af52faaba80e0206e60", + "src/webview/editor/transfer/clipboard.js": "736ae9ea03599137849c047b6d1c7c96a90f7f2afbae49e7151b7a8b17392483", + "src/webview/editor/transfer/paste.js": "24ef9fb2888e5b476fe418ac9a7a62698f30bb1d7ea4151e3f789d4a05abdce9", + "src/webview/editor/ui/commands.js": "5e7d2f95b0f3639d08eff680cedd61e7efdfc6c4c78f6763351e3bfbae46121c", + "src/webview/editor/ui/menus.js": "4346fde5d9dded8b4575a63342a003ce74bad13ac45fc8157ba83b080a02eb70", + "src/webview/styles.css": "4b684910c37725f217ba55291be8ae606b308731e24520023fb5b3286b9628b9" + }, + "artifactSha256": { + "review.md": "b381a6acfc605c7fde9efd3998b2c986911b9fc6afaed3096a956fa5fced46b0", + "review.html": "222cdc0d2712e455e1a069311a7bcc1a2b713f998f7c8cde1bc8bd42229d8f7a", + "review.pdf": "3b0cd33ffd31f2c2a10112164137d4cf326be45b0d4b9b7f208aadc748b7369b", + "review.docx": "c506d215818828d1c4f1e23ef2ffd1faf6419a7918564e54733a8b5b5251c202", + "review.epub": "dfec1ab79666a558f819440a9740a8930097665131eb1e53edd412bcb16e97bf", + "docx-reader/review.pdf": "d9d2fd019805d55d5c6910f7076ed1bf71f3c8749f23042216e034272b429190" + }, + "pdfLinkInspection": { + "pages": 1, + "namedNoteLinks": 6, + "externalLinks": 6, + "allNamedLinksResolve": true + }, + "tools": { + "node": "24.21.0", + "chromium": "154.0.8037.57", + "pandoc": "3.8.3", + "docxReader": "LibreOfficeDev 26.8.0.0.alpha0 (headless PDF rendering)", + "epubReader": "EPUB.js 0.3.93 in Chromium", + "pdfInspection": "PyMuPDF 1.26.6; Poppler rasterization" + } +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e77d3187..bd48c710 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "Duplicate definition. The first definition is used; this source is retained.", + footnote: "Footnote", + footnotePlaceholder: "Write the note here.", + footnoteBack: "Back to reference", + referenceDefinition: "Link definition", + editDefinition: "Edit definition", + referenceUnresolved: "No matching definition. Edit the source to add or correct it.", + insertDescriptionFootnote: "Insert a numbered note with an editable definition.", settingsEditor: "Editor & Appearance", settingsOutline: "Outline", settingsWriting: "Writing Tools", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 8e1c0257..889270a7 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "Definición duplicada. Se usa la primera; esta fuente se conserva.", + footnote: "Nota al pie", + footnotePlaceholder: "Escriba la nota aquí.", + footnoteBack: "Volver a la referencia", + referenceDefinition: "Definición del enlace", + editDefinition: "Editar definición", + referenceUnresolved: "No hay una definición coincidente. Añádala o corríjala en el código fuente.", + insertDescriptionFootnote: "Inserta una nota numerada con una definición editable.", settingsEditor: "Editor y apariencia", settingsOutline: "Esquema", settingsWriting: "Herramientas de escritura", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4e4a74ff..7b3ac0d8 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "Définition en double. La première est utilisée ; cette source est conservée.", + footnote: "Note de bas de page", + footnotePlaceholder: "Écrivez la note ici.", + footnoteBack: "Retour à la référence", + referenceDefinition: "Définition du lien", + editDefinition: "Modifier la définition", + referenceUnresolved: "Aucune définition correspondante. Ajoutez-la ou corrigez-la dans la source.", + insertDescriptionFootnote: "Insère une note numérotée avec une définition modifiable.", settingsEditor: "Éditeur et apparence", settingsOutline: "Plan", settingsWriting: "Outils de rédaction", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index f1ae3a78..397a388b 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "定義が重複しています。最初の定義が使用され、このソースも保持されます。", + footnote: "脚注", + footnotePlaceholder: "ここに脚注を入力します。", + footnoteBack: "参照に戻る", + referenceDefinition: "リンク定義", + editDefinition: "定義を編集", + referenceUnresolved: "対応する定義がありません。ソースで追加または修正してください。", + insertDescriptionFootnote: "編集可能な定義を持つ番号付き脚注を挿入します。", settingsEditor: "エディターと外観", settingsOutline: "アウトライン", settingsWriting: "編集ツール", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index c84c989b..5b1fd1fd 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "중복 정의입니다. 첫 번째 정의를 사용하며 이 소스는 보존됩니다.", + footnote: "각주", + footnotePlaceholder: "여기에 각주를 입력하세요.", + footnoteBack: "참조로 돌아가기", + referenceDefinition: "링크 정의", + editDefinition: "정의 편집", + referenceUnresolved: "일치하는 정의가 없습니다. 소스에서 추가하거나 수정하세요.", + insertDescriptionFootnote: "편집 가능한 정의가 있는 번호 매긴 각주를 삽입합니다.", settingsEditor: "편집기 및 모양", settingsOutline: "개요", settingsWriting: "작성 도구", diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 761f2ecd..2ef4357f 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "定义重复。使用第一个定义,并保留此源码。", + footnote: "脚注", + footnotePlaceholder: "在此输入注释。", + footnoteBack: "返回引用", + referenceDefinition: "链接定义", + editDefinition: "编辑定义", + referenceUnresolved: "没有匹配的定义。请在源码中添加或更正。", + insertDescriptionFootnote: "插入带有可编辑定义的编号脚注。", settingsEditor: "编辑器与外观", settingsOutline: "大纲", settingsWriting: "写作工具", diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 2300c4b1..bfd2f90f 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -43,6 +43,14 @@ export const messages = { }; export const webviewMessages = { + referenceDuplicate: "定義重複。使用第一個定義,並保留此原始碼。", + footnote: "註腳", + footnotePlaceholder: "在此輸入註解。", + footnoteBack: "返回引用", + referenceDefinition: "連結定義", + editDefinition: "編輯定義", + referenceUnresolved: "沒有相符的定義。請在原始碼中新增或修正。", + insertDescriptionFootnote: "插入帶有可編輯定義的編號註腳。", settingsEditor: "編輯器與外觀", settingsOutline: "大綱", settingsWriting: "寫作工具", diff --git a/src/shared/markdown-blocks.js b/src/shared/markdown-blocks.js index e5ddde9b..651e0574 100644 --- a/src/shared/markdown-blocks.js +++ b/src/shared/markdown-blocks.js @@ -3,13 +3,12 @@ const MarkdownIt = require('markdown-it/lib/index.mjs').default; const math = require('./math-syntax'); const documentAux = require('./document-aux'); +const references = require('./markdown-references'); // Block structure and source layout are separate. Blank separators are source // slices, never empty document paragraphs or hidden editable DOM nodes. const parser = new MarkdownIt({ html: false, linkify: false, typographer: false }); -// References stay literal until the editor's inline renderer supports them; -// consuming definitions here would hide user content without rendering links. -parser.block.ruler.disable('reference'); +references.configure(parser); // The editor has always authored nested empty items as an indented "- ". // Prefer that editing position over a setext underline inside a list. const setext = require('markdown-it/lib/rules_block/lheading.mjs').default; @@ -107,7 +106,8 @@ function parse(source, options = {}) { auxiliary.set(startLine, { raw, endLine: startLine + raw.replace(/\n$/, '').split('\n').length }); } } catch (_) { /* Incomplete auxiliary syntax remains editable text. */ } - const blocks = tokenTree(parser.parse(body, { auxiliary, backslashDelimiters: options.backslashDelimiters })); + const env = { auxiliary, references: Object.create(null), backslashDelimiters: options.backslashDelimiters }; + const blocks = tokenTree(parser.parse(body, env)); let previousEnd = 0; for (const block of blocks) { const start = offsets[block.map[0]]; @@ -127,7 +127,7 @@ function parse(source, options = {}) { if (front.raw.endsWith('\n') && !blocks.length) trailing = '\n' + trailing; if (front.raw) blocks.unshift({ type: 'front_matter', content: front.raw, source: front.raw.replace(/\n$/, ''), before: '', children: [] }); if (front.raw && blocks.length > 1 && front.raw.endsWith('\n')) blocks[1].before = '\n' + blocks[1].before; - return { blocks, trailing }; + return { blocks, trailing, references: references.contextFor(blocks, env) }; } -module.exports = { parse }; +module.exports = { parse, inlineReferences: references.inlineSpans }; diff --git a/src/shared/markdown-references.js b/src/shared/markdown-references.js new file mode 100644 index 00000000..3867b37e --- /dev/null +++ b/src/shared/markdown-references.js @@ -0,0 +1,164 @@ +'use strict'; + +const MarkdownIt = require('markdown-it/lib/index.mjs').default; +const footnote = require('markdown-it-footnote'); +const referenceRule = require('markdown-it/lib/rules_block/reference.mjs').default; +const { normalizeReference } = require('markdown-it/lib/common/utils.mjs'); +const math = require('./math-syntax'); + +// Keep definitions in the block stream. The editor must never hide source in +// parser state alone, including unused and duplicate definitions. +function configure(parser) { + parser.use(footnote); + parser.core.ruler.disable('footnote_tail'); + parser.inline.ruler.disable('footnote_inline'); + parser.block.ruler.at('reference', (state, start, end, silent) => { + const at = state.bMarks[start] + state.tShift[start]; + if (state.src.slice(at, at + 2) === '[^') return false; + if (!referenceRule(state, start, end, silent)) return false; + if (!silent) { + const token = state.push('binary_reference', 'div', 0); + token.map = [start, state.line]; + token.content = state.getLines(start, state.line, state.blkIndent, false).replace(/\n$/, ''); + token.meta = { label: /^ *\[((?:\\.|[^\]\\])+)\]:/.exec(token.content)?.[1] || '' }; + } + return true; + }); + // Select the registered rule name, not Function.name (minification changes + // the latter). Both parser and plugin are pinned and bundled together. + const definition = parser.block.ruler.__rules__.find(rule => rule.name === 'footnote_def').fn; + parser.block.ruler.at('footnote_def', (state, start, end, silent) => { + const index = state.tokens.length; + const indent = state.blkIndent; + if (!definition(state, start, end, silent)) return false; + if (!silent) { + const token = state.tokens[index]; + token.map = [start, state.line]; + token.content = state.getLines(start, state.line, indent, false).replace(/\n+$/, ''); + } + return true; + }, { alt: ['paragraph', 'reference'] }); +} + +const inline = new MarkdownIt({ html: false, linkify: false, typographer: false }); +inline.inline.ruler.before('escape', 'binary_reference_escape', (state, silent) => { + const raw = state.src.slice(state.pos, state.pos + 2); + if (!/^\\[\\[\]]$/.test(raw)) return false; + if (!silent) { + state.env.spans.push({ start: state.pos, end: state.pos + 2, raw, escaped: true }); + state.push('text', '', 0).content = raw.slice(1); + } + state.pos += 2; + return true; +}); +// Capture recognized source spans using the same CommonMark rules as blocks. +// Code spans, escapes, nested brackets, Unicode labels and titles therefore do +// not depend on a second regular-expression definition of reference syntax. +const rules = { link: require('markdown-it/lib/rules_inline/link.mjs').default, + image: require('markdown-it/lib/rules_inline/image.mjs').default }; +for (const [name, rule] of Object.entries(rules)) { + inline.inline.ruler.at(name, (state, silent) => { + const start = state.pos; + if (name === 'link' && state.env.insideLink) return false; + if (name === 'image' ? state.src.slice(start, start + 2) !== '![' : state.src[start] !== '[') return false; + const labelStart = start + (name === 'image' ? 2 : 1); + const labelEnd = state.md.helpers.parseLinkLabel(state, labelStart - 1, name === 'link'); + const tokenStart = state.tokens.length; + const spansStart = state.env.spans.length; + if (!rule(state, silent)) { + if (labelEnd < 0 || state.src[labelEnd + 1] !== '[') return false; + const end = state.md.helpers.parseLinkLabel(state, labelEnd + 1); + if (end < 0) return false; + if (!silent) { + const raw = state.src.slice(start, end + 1); + state.env.spans.push({ start, end: end + 1, raw, unresolved: true }); + state.push('text', '', 0).content = raw; + } + state.pos = end + 1; + return true; + } + if (!silent && name === 'image') state.env.spans.splice(spansStart); + if (!silent && labelEnd >= 0 && state.src[labelEnd + 1] !== '(') { + const raw = state.src.slice(start, state.pos); + const text = state.src.slice(labelStart, labelEnd); + const suffix = state.src.slice(labelEnd + 1, state.pos); + const label = suffix.startsWith('[') && suffix !== '[]' ? suffix.slice(1, -1) : text; + const token = state.tokens.slice(tokenStart).find(token => token.type === (name === 'image' ? 'image' : 'link_open')); + state.env.spans.splice(spansStart); + state.env.spans.push({ start, end: state.pos, raw, text, label, image: name === 'image', + href: token.attrGet(name === 'image' ? 'src' : 'href'), title: token.attrGet('title') || '' }); + } + return true; + }); +} +inline.inline.ruler.before('link', 'binary_note', (state, silent) => { + if (silent || state.linkLevel || state.env.insideLink || state.env.inAlt) return false; + const match = /^\[\^([^\s\[\]]+)\]/.exec(state.src.slice(state.pos, state.posMax)); + if (!match) return false; + if (!silent) { + state.env.spans.push({ start: state.pos, end: state.pos + match[0].length, + raw: match[0], note: match[1] }); + const token = state.push('text', '', 0); + token.content = match[0]; + } + state.pos += match[0].length; + return true; +}); + +function inlineSpans(source, context = {}) { + const env = { references: context.references || Object.create(null), spans: [], insideLink: context.insideLink, inAlt: context.inAlt }; + inline.parseInline(source, env); + return env.spans.sort((a, b) => a.start - b.start); +} + +function contextFor(blocks, env) { + const notes = Object.create(null); + const definitions = []; + const linkLabels = new Set(); + function visit(block) { + if (block.type === 'binary_reference') { + const key = normalizeReference(block.meta.label); + block.meta.duplicate = linkLabels.has(key); + linkLabels.add(key); + } + if (block.type === 'footnote_reference') { + definitions.push(block); + const key = normalizeReference(block.meta.label); + if (!notes[key]) notes[key] = block; + } + block.children.forEach(visit); + } + blocks.forEach(visit); + const noteNumbers = Object.create(null); + const counts = Object.create(null); + const ordered = []; + function count(block) { + if (block.type === 'footnote_reference') return; + if (block.type === 'inline') { + const equations = math.inline(block.content, env.backslashDelimiters !== false); + for (const span of inlineSpans(block.content, env)) { + if (!span.note) continue; + if (equations.some(equation => equation.start <= span.start && equation.end >= span.end)) continue; + const key = normalizeReference(span.note); + if (!notes[key]) continue; + if (!noteNumbers[key]) { ordered.push(key); noteNumbers[key] = ordered.length; } + counts[key] = (counts[key] || 0) + 1; + } + } + block.children.forEach(count); + } + blocks.forEach(count); + // Visit each referenced note once, including notes referenced by notes. + for (let index = 0; index < ordered.length; index++) notes[ordered[index]].children.forEach(count); + for (const block of definitions) { + const key = normalizeReference(block.meta.label); + block.meta.primary = notes[key] === block; + block.meta.duplicate = !block.meta.primary; + block.meta.key = key; + block.meta.number = noteNumbers[key]; + block.meta.count = counts[key] || 0; + } + return { references: env.references || Object.create(null), notes, noteNumbers, occurrences: Object.create(null), noteKey: normalizeReference }; +} + +module.exports = { configure, inlineSpans, contextFor, normalizeReference }; diff --git a/src/webview/editor.js b/src/webview/editor.js index 19d091a5..aefc4c9d 100644 --- a/src/webview/editor.js +++ b/src/webview/editor.js @@ -246,6 +246,7 @@ window.BinaryMath = require('../shared/math-syntax'); get editInlineMath() { return blocksSpecialService.editInlineMath; }, get requestHostInsertion() { return uiChromeService.requestHostInsertion; }, get insertManagedToc() { return blocksRenderService.insertManagedToc; }, + get insertFootnote() { return blocksRenderService.insertFootnote; }, get commandPalette() { return uiMenusService.commandPalette; }, get stopCommandPaletteOutsideClicks() { return uiMenusService.stopCommandPaletteOutsideClicks; }, get commandPaletteRepositionHandler() { return uiMenusService.commandPaletteRepositionHandler; }, @@ -490,6 +491,8 @@ window.BinaryMath = require('../shared/math-syntax'); get scrollCursorIntoView() { return selectionDomService.scrollCursorIntoView; } }); const codecInlineService = require('./editor/codec/inline').createInlineController({ + get i18n() { return i18n; }, + get markdown() { return coreSessionService.markdown; }, get documentBaseUri() { return documentBaseUri; }, get mathSyntax() { return mathSyntax; }, get mathBackslashDelimiters() { return mathBackslashDelimiters; }, @@ -498,6 +501,7 @@ window.BinaryMath = require('../shared/math-syntax'); get wrapInlineCode() { return codecBlocksService.wrapInlineCode; } }); const codecBlocksService = require('./editor/codec/blocks').createBlocksController({ + get i18n() { return i18n; }, get editor() { return editor; }, get logger() { return logger; }, get REGEX() { return codecInlineService.REGEX; }, diff --git a/src/webview/editor/blocks/export.js b/src/webview/editor/blocks/export.js index 950a57e0..74f675c6 100644 --- a/src/webview/editor/blocks/export.js +++ b/src/webview/editor/blocks/export.js @@ -124,11 +124,14 @@ function createExport(dependencies) { dependencies.assignHeadingAnchors(template.content, normalizedSource); template.content.querySelectorAll('.toc-refresh').forEach(button => button.remove()); template.content.querySelectorAll('[data-toc-source]').forEach(block => block.removeAttribute('data-toc-source')); + template.content.querySelectorAll('[data-reference], [data-note-source], [data-note-navigation], [data-reference-escape]').forEach(node => { + for (const name of ['data-reference', 'data-note-source', 'data-note-navigation', 'data-reference-escape']) node.removeAttribute(name); + }); sanitizeExportTree(template.content, warnings); // These are diagnostics about the current rendered output, not a new // Markdown parser. Literal notation stays exactly as the user sees it. const diagnosticTree = template.content.cloneNode(true); - diagnosticTree.querySelectorAll('pre,code,.math-wrapper,.math-inline,.mermaid-wrapper').forEach(element => element.remove()); + diagnosticTree.querySelectorAll('pre,code,.math-wrapper,.math-inline,.mermaid-wrapper,.reference-escape').forEach(element => element.remove()); const visibleSource = (diagnosticTree.textContent || '').replace(/\\\$/g, ''); if (/\$\$[\s\S]*?\$\$|\$(?!\$)(?=\S)[^$\n]*?[^\s$]\$(?![\d$])/.test(visibleSource)) { exportWarning(warnings, 'renderer-math-source', 'Unrecognized equation delimiters remain visible source in the exported document.'); @@ -137,7 +140,7 @@ function createExport(dependencies) { exportWarning(warnings, 'renderer-toc-source', 'A literal [TOC] marker remains visible. HTML/PDF do not generate a table of contents from that marker in the current renderer.'); } if (/\[\^[^\]\n]+\]/.test(visibleSource)) { - exportWarning(warnings, 'renderer-footnote-source', 'Footnote markers and definitions remain visible source. HTML/PDF do not generate linked footnotes in the current renderer.'); + exportWarning(warnings, 'renderer-footnote-source', 'An unresolved or malformed footnote remains visible source. Add or correct its definition to generate a linked note.'); } const container = document.createElement('div'); container.className = 'editor export-preparation'; diff --git a/src/webview/editor/blocks/references.js b/src/webview/editor/blocks/references.js new file mode 100644 index 00000000..923f8d1a --- /dev/null +++ b/src/webview/editor/blocks/references.js @@ -0,0 +1,84 @@ +'use strict'; +const { noteId } = require('../codec/references'); + +function createReferences(dependencies, render) { + const initialized = new WeakSet(); + function navigate(link, event) { + event.preventDefault(); + event.stopPropagation(); + const id = link.getAttribute('href').slice(1); + const target = Array.from(dependencies.editor.querySelectorAll('[id]')).find(node => node.id === id); + if (!target) return; + target.scrollIntoView({ block: 'center' }); + target.focus({ preventScroll: true }); + } + function setup() { + dependencies.editor.querySelectorAll('[data-note-navigation]').forEach(link => { + if (initialized.has(link)) return; + initialized.add(link); + link.addEventListener('click', event => navigate(link, event)); + link.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') navigate(link, event); + }); + }); + dependencies.editor.querySelectorAll('.reference-source').forEach(input => { + if (initialized.has(input)) return; + initialized.add(input); + input.addEventListener('beforeinput', () => dependencies.undoManager.saveSnapshotDebounced()); + input.addEventListener('input', event => { + event.stopPropagation(); + dependencies.markActivelyEditing(); + dependencies.syncMarkdownSync(); + }); + const refresh = () => { + const position = Array.from(dependencies.editor.querySelectorAll('.reference-source')).indexOf(input); + dependencies.syncMarkdownSync(); + render(); + const replacement = dependencies.editor.querySelectorAll('.reference-source')[position]; + replacement?.closest('details').querySelector('summary').focus(); + }; + input.addEventListener('change', refresh); + input.addEventListener('keydown', event => { + event.stopPropagation(); + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === 's') { event.preventDefault(); dependencies.saveCurrentDocument(); } + if (key === 'z') { event.preventDefault(); event.shiftKey ? dependencies.undoManager.redo() : dependencies.undoManager.undo(); } + if (key === 'enter') { event.preventDefault(); refresh(); } + }); + }); + } + function insertFootnote() { + const selection = window.getSelection(); + const range = selection?.rangeCount ? selection.getRangeAt(0) : null; + const element = range?.startContainer.nodeType === 1 ? range.startContainer : range?.startContainer.parentElement; + if (dependencies.isSourceMode || !range || !dependencies.editor.contains(range.commonAncestorContainer) || + element?.closest('pre,.reference-definition,.math-inline,.math-wrapper,.mermaid-wrapper,.front-matter,.toc-block')) return; + const current = dependencies.readCurrentMarkdown(); + // Include unresolved references and code examples: insertion must never + // accidentally bind an existing user-authored marker. + const used = new Set(Array.from(current.matchAll(/\[\^([^\]\r\n]+)\]/g), match => match[1].toUpperCase())); + let sequence = 1; + while (used.has('NOTE-' + sequence)) sequence++; + const label = 'note-' + sequence; + const marker = document.createTextNode('[^' + label + ']'); + range.collapse(false); + range.insertNode(marker); + dependencies.syncMarkdownSync(); + dependencies.markdown = dependencies.readCurrentMarkdown().replace(/\n*$/, '\n\n') + + '[^' + label + ']: ' + dependencies.i18n.footnotePlaceholder + '\n'; + dependencies.markAsEdited(); + render(); + dependencies.notifyChangeImmediate(); + const block = Array.from(dependencies.editor.querySelectorAll('.footnote-definition')).find(node => node.id === noteId(label.toUpperCase())); + if (block) { + block.querySelector('details').open = true; + const input = block.querySelector('textarea'); + input.focus(); + input.setSelectionRange(input.value.indexOf(':') + 2, input.value.length); + } + } + return { setup, insertFootnote }; +} + +module.exports = { createReferences }; diff --git a/src/webview/editor/blocks/render.js b/src/webview/editor/blocks/render.js index 795ff777..17d267fe 100644 --- a/src/webview/editor/blocks/render.js +++ b/src/webview/editor/blocks/render.js @@ -1,6 +1,7 @@ 'use strict'; // Construction defines capabilities; bootstrap controls the original initialization order. function createRender(dependencies) { + const references = require('./references').createReferences(dependencies, () => renderFromMarkdown()); let frontMatterOpen, editorRenderRevision, sourceBlockSequence, initializedLinks; function renderFromMarkdown(codeViews = dependencies.captureCodeViews()) { dependencies.closeInsertMenu(false); @@ -322,7 +323,7 @@ function createRender(dependencies) { }); } function setupLink(a) { - if (a.closest('.toc-block')) + if (a.closest('.toc-block') || a.hasAttribute('data-note-navigation')) return; // DOM-only details; keep this shared by rendered, inserted, and pasted links. if (!a.hasAttribute('title')) @@ -338,8 +339,10 @@ function createRender(dependencies) { function setupInteractiveElements() { dependencies.setupInlineMath(); setupDocumentAux(); + references.setup(); // Make checkboxes work dependencies.editor.querySelectorAll('input[type="checkbox"]').forEach(cb => { + if (cb.closest('.footnote-body')) { cb.disabled = true; return; } cb.addEventListener('change', () => { dependencies.markAsEdited(); // User has made an edit dependencies.syncMarkdown(); @@ -349,10 +352,12 @@ function createRender(dependencies) { dependencies.editor.querySelectorAll('a').forEach(setupLink); // Make table cells editable dependencies.editor.querySelectorAll('th, td').forEach(cell => { + if (cell.closest('.footnote-body')) { cell.removeAttribute('contenteditable'); return; } cell.setAttribute('contenteditable', 'true'); }); // Add resize handles to tables dependencies.editor.querySelectorAll('table').forEach(table => { + if (table.closest('.footnote-body')) return; dependencies.addTableResizeHandles(table); }); // Setup code block UI for all code blocks @@ -361,6 +366,11 @@ function createRender(dependencies) { // Setup all code blocks in the editor function setupAllCodeBlocks() { dependencies.editor.querySelectorAll('pre').forEach(pre => { + if (pre.closest('.footnote-body')) { + pre.removeAttribute('contenteditable'); + pre.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable')); + return; + } // Skip mermaid/math code blocks (they are handled by their own setup functions) if (pre.getAttribute('data-lang') === 'mermaid') return; @@ -451,6 +461,7 @@ function createRender(dependencies) { (initializedLinks = new WeakSet()); } return { + insertFootnote: references.insertFootnote, renderFromMarkdown, normalizeBlockHtml, blocksAreEqual, diff --git a/src/webview/editor/blocks/special.js b/src/webview/editor/blocks/special.js index 2df809fd..329c2ffd 100644 --- a/src/webview/editor/blocks/special.js +++ b/src/webview/editor/blocks/special.js @@ -200,6 +200,7 @@ function createSpecial(dependencies) { wrapper.dataset.mermaidSetup = 'true'; dependencies.logger.log('Setting up wrapper'); renderMermaidDiagram(wrapper); + if (wrapper.closest('.footnote-body')) return; // Add click handler to enter editing mode wrapper.addEventListener('click', function (e) { // Don't enter edit mode if clicking on the diagram itself when already in display mode @@ -280,7 +281,7 @@ function createSpecial(dependencies) { })); } function editInlineMath(span) { - if (dependencies.isSourceMode) + if (dependencies.isSourceMode || span.closest('.footnote-body')) return; if (finishInlineMathEdit) finishInlineMathEdit(true, false); @@ -434,6 +435,7 @@ function createSpecial(dependencies) { return; wrapper.dataset.mathSetup = 'true'; renderMathBlock(wrapper); + if (wrapper.closest('.footnote-body')) return; // Click → edit mode wrapper.addEventListener('click', function (e) { if (wrapper.getAttribute('data-mode') !== 'edit') { diff --git a/src/webview/editor/codec/blocks.js b/src/webview/editor/codec/blocks.js index 4e8fe29d..a86d1ffd 100644 --- a/src/webview/editor/codec/blocks.js +++ b/src/webview/editor/codec/blocks.js @@ -3,7 +3,8 @@ * Construction binds capabilities without rendering or modifying the document. * Sequence/directive owners stay outside the codec; the sentinel set is shared. */ -function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mathBackslashDelimiters, codeBlocksWithSentinel, parseInline, escapeHtml, renderFrontMatter, renderTocBlock, mathBlockHtml, inlineMathMarkdown, mathBlockMarkdown, mdGetInlineMarkdown, parseBlocks, formatTable, nextSourceBlockSequence, getCurrentImageDir, getCurrentForceRelativePath }) { +const references = require('./references'); +function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mathBackslashDelimiters, codeBlocksWithSentinel, parseInline, escapeHtml, renderFrontMatter, renderTocBlock, mathBlockHtml, inlineMathMarkdown, mathBlockMarkdown, mdGetInlineMarkdown, parseBlocks, formatTable, nextSourceBlockSequence, getCurrentImageDir, getCurrentForceRelativePath, labels = {} }) { function parseMarkdownLine(text, allowMath = true) { const parseLineInline = value => parseInline(value, allowMath); // Heading @@ -57,8 +58,17 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat // Regular paragraph return { tag: 'p', html: parseLineInline(text), consumed: false }; } - function renderMarkdownBlock(block, exportCode = false, parent = null, index = 0) { - const children = () => block.children.map((child, i) => renderMarkdownBlock(child, exportCode, block, i)).join(''); + function renderMarkdownBlock(block, exportCode = false, parent = null, index = 0, context) { + if (block.type === 'footnote_reference' && (!block.meta.primary || !block.meta.number)) { + if (exportCode) return ''; + // An unused/duplicate definition is a source preview, not another + // occurrence in the document's active footnote navigation. + context = { ...context, notePreview: true }; + } + const children = () => block.children.map((child, i) => renderMarkdownBlock(child, exportCode, block, i, context)).join(''); + const inline = (text, allowMath = true, prose = false) => parseInline(text, allowMath, prose, context); + if (block.type === 'binary_reference' || block.type === 'footnote_reference') + return references.renderDefinition(block, block.type === 'footnote_reference' ? children() : '', exportCode, labels); if (block.type === 'front_matter') return renderFrontMatter(block.content); if (block.type === 'binary_aux') @@ -66,18 +76,20 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat if (block.type === 'binary_math') return mathBlockHtml(block.meta); if (block.type === 'inline') - return parseInline(block.content, true, true); + return inline(block.content, true, true); if (block.type === 'paragraph') { const text = block.children.find(child => child.type === 'inline')?.content || ''; + if (/^ {0,3}\[(?:\\.|[^\]\\])+\]:/.test(text)) + return '

' + escapeHtml(text) + '

'; // Keep the established quote line-editing contract for a simple // quote. Quotes containing several blocks keep those blocks. if (parent?.type === 'blockquote' && parent.level === 0 && parent.children.length === 1) - return parseInline(text); + return inline(text); if (parent?.type === 'list_item' && index === 0) { const task = /^\[([ xX])\] +(.*)$/s.exec(text); if (task) - return '' + (parseInline(task[2], true, true) || '
'); - return parseInline(text, true, true) || '
'; + return '' + (inline(task[2], true, true) || '
'); + return inline(text, true, true) || '
'; } return '

' + children() + '

'; } @@ -138,11 +150,11 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat function markdownToHtmlFragment(markdownText, exportCode = false) { const parsed = parseBlocks(markdownText, { backslashDelimiters: mathBackslashDelimiters }); if (exportCode) - return parsed.blocks.map(block => renderMarkdownBlock(block, true)).join(''); + return parsed.blocks.map(block => renderMarkdownBlock(block, true, null, 0, parsed.references)).join(''); let previousId = ''; return parsed.blocks.map((block, index) => { const template = document.createElement('template'); - template.innerHTML = renderMarkdownBlock(block); + template.innerHTML = renderMarkdownBlock(block, false, null, 0, parsed.references); const element = template.content.firstElementChild; if (!element) return ''; @@ -213,6 +225,10 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat } if (node.nodeType !== 1) return ''; + const reference = references.readInline(node, mdGetInlineMarkdown); + if (reference !== null) return reference; + if (node.classList.contains('reference-definition')) + return node.querySelector(':scope > details > textarea').value + '\n'; if (node.classList.contains('math-inline')) return inlineMathMarkdown(node); const tag = node.tagName.toLowerCase(); @@ -496,6 +512,8 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat } } else if (node.nodeType === 1) { + const reference = references.readInline(node, mdGetInlineMarkdown); + if (reference !== null) { currentLine += reference; return; } const tag = node.tagName.toLowerCase(); if (tag === 'br') { // Line break - push current line (even if empty) and start new line @@ -599,6 +617,8 @@ function createBlockCodec({ document, editor, logger, REGEX, emptyTableCell, mat } if (node.nodeType !== 1) return ''; + const reference = references.readInline(node, mdGetInlineMarkdown); + if (reference !== null) return escapePipeInCell(reference); if (node.classList.contains('math-inline')) return escapePipeInCell(inlineMathMarkdown(node)); const tag = node.tagName.toLowerCase(); @@ -774,7 +794,7 @@ function createBlocksController(dependencies) { mathBlockMarkdown: (...args) => dependencies.mathBlockMarkdown(...args), mdGetInlineMarkdown: dependencies.mdGetInlineMarkdown, parseBlocks: (...args) => window.BinaryMarkdownBlocks.parse(...args), formatTable: (...args) => dependencies.tableFormat.format(...args), nextSourceBlockSequence: () => ++dependencies.sourceBlockSequence, getCurrentImageDir: () => dependencies.currentImageDir, - getCurrentForceRelativePath: () => dependencies.currentForceRelativePath + getCurrentForceRelativePath: () => dependencies.currentForceRelativePath, labels: dependencies.i18n })); } return { diff --git a/src/webview/editor/codec/inline.js b/src/webview/editor/codec/inline.js index 702a2649..2bad01e9 100644 --- a/src/webview/editor/codec/inline.js +++ b/src/webview/editor/codec/inline.js @@ -1,6 +1,7 @@ 'use strict'; +const references = require('./references'); // Keep inline rendering and serialization together so their markup contracts agree. -function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePath, inlineMathHtml, inlineMathMarkdown, wrapInlineCode, }) { +function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePath, inlineMathHtml, inlineMathMarkdown, wrapInlineCode, inlineReferences = () => [], getReferenceContext = () => ({}), labels = {} }) { const REGEX = { // parseMarkdownLine patterns heading: /^(#{1,6}) (.*)$/, @@ -44,9 +45,18 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa } return html.replace(/<(\/?)u>/gi, (literal, closing, index) => paired.has(index) ? (closing ? '' : '') : literal); } - function parseInline(text, allowMath = true, prose = false) { + function parseInline(text, allowMath = true, prose = false, context = getReferenceContext()) { if (!text) return ''; + let referenceMarker = '\x00BREF'; + while (text.includes(referenceMarker)) referenceMarker += 'X'; + const mathRanges = allowMath ? mathSyntax.inline(text, mathBackslashDelimiters) : []; + const spans = inlineReferences(text, context).filter(span => !mathRanges.some(equation => equation.start <= span.start && equation.end >= span.end)); + const renderedReferences = spans.map(reference => references.renderInline(reference, context, + value => parseInline(value, allowMath, prose, { ...context, insideLink: !reference.image || context.insideLink, inAlt: reference.image }), resolveImagePath, labels)); + for (let index = spans.length - 1; index >= 0; index--) { + text = text.slice(0, spans[index].start) + referenceMarker + index + '\x00' + text.slice(spans[index].end); + } const equations = allowMath ? mathSyntax.inline(text, mathBackslashDelimiters) : []; let mathMarker = '\x00BMATH'; while (text.includes(mathMarker)) @@ -103,6 +113,9 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa equations.forEach((equation, i) => { html = html.replace(mathMarker + i + '\x00', () => inlineMathHtml(equation)); }); + renderedReferences.forEach((rendered, index) => { + html = html.replace(referenceMarker + index + '\x00', () => rendered); + }); return html; } function formatInlineText(html) { @@ -214,6 +227,8 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa if (node.nodeType !== 1) { return result; } + const reference = references.readInline(node, mdGetInlineMarkdown); + if (reference !== null) return [{ char: reference, styles: new Set(currentStyles), isReference: true }]; if (node.classList.contains('math-inline')) { return [{ char: inlineMathMarkdown(node), styles: new Set(currentStyles), isMath: true }]; } @@ -321,6 +336,7 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa const canMerge = currentGroup && !c.isBreak && !currentGroup.isBreak && !c.isMath && !currentGroup.isMath && + !c.isReference && !currentGroup.isReference && !c.isImage && !currentGroup.isImage && !c.isLink && !currentGroup.isLink && !c.isCode && !currentGroup.isCode && @@ -343,6 +359,7 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa alt: c.alt, isCode: c.isCode, isMath: c.isMath, + isReference: c.isReference, isBreak: c.isBreak }; } @@ -389,7 +406,7 @@ function createInlineCodec({ mathSyntax, mathBackslashDelimiters, resolveImagePa function applyMarkdownStyle(group) { if (group.isBreak) return group.text; - if (group.isMath) + if (group.isMath || group.isReference) return applyInlineStyles(group.text, group.styles); // Handle special cases first if (group.isImage) { @@ -509,7 +526,10 @@ function createInlineController(dependencies) { ({ REGEX, parseInline, escapeHtml, mdGetInlineMarkdown } = createInlineCodec({ mathSyntax: dependencies.mathSyntax, mathBackslashDelimiters: dependencies.mathBackslashDelimiters, resolveImagePath: (...args) => resolveImagePath(...args), inlineMathHtml: (...args) => dependencies.inlineMathHtml(...args), inlineMathMarkdown: (...args) => dependencies.inlineMathMarkdown(...args), - wrapInlineCode: (...args) => dependencies.wrapInlineCode(...args) + wrapInlineCode: (...args) => dependencies.wrapInlineCode(...args), + inlineReferences: (...args) => window.BinaryMarkdownBlocks.inlineReferences(...args), + getReferenceContext: () => window.BinaryMarkdownBlocks.parse(dependencies.markdown, { backslashDelimiters: dependencies.mathBackslashDelimiters }).references, + labels: dependencies.i18n })); } return { diff --git a/src/webview/editor/codec/references.js b/src/webview/editor/codec/references.js new file mode 100644 index 00000000..a9df2f54 --- /dev/null +++ b/src/webview/editor/codec/references.js @@ -0,0 +1,96 @@ +'use strict'; + +const escape = text => String(text).replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +})[character]); +const encoded = value => escape(encodeURIComponent(value)); +const noteId = label => 'bm-note-' + Array.from(label).map(char => char.codePointAt(0).toString(16)).join('-'); + +function renderInline(reference, context, parseText, resolveImage, labels) { + if (reference.escaped) return '' + escape(reference.raw.slice(1)) + ''; + if (reference.unresolved) return '' + escape(reference.raw) + ''; + if (reference.note) { + const key = context?.noteKey?.(reference.note); + if (!context?.notes[key]) { + return '' + escape(reference.raw) + ''; + } + const number = context.noteNumbers[key]; + const id = noteId(key); + let referenceId = ''; + if (!context.notePreview) { + const occurrence = (context.occurrences[key] || 0) + 1; + context.occurrences[key] = occurrence; + referenceId = ' id="' + id + '-ref-' + occurrence + '"'; + } + return '' + + '' + escape(number || reference.note) + ''; + } + const text = parseText(reference.text); + const meta = encoded(JSON.stringify({ ...reference, html: text })); + const title = reference.title ? ' title="' + escape(reference.title) + '"' : ''; + if (reference.image) { + // Alt text is text, not active HTML. The browser decodes entities once. + const template = document.createElement('template'); + template.innerHTML = text; + return '' + escape(template.content.textContent) + ''; + } + return '' + text + ''; +} + +function readInline(node, readChildren) { + if (!node.dataset) return null; + if (node.dataset.referenceEscape) return (node.textContent || '').replace(/[\\[\]]/g, '\\$&'); + if (node.dataset.noteSource !== undefined) return decodeURIComponent(node.dataset.noteSource); + if (!node.dataset.reference) return null; + let reference; + try { reference = JSON.parse(decodeURIComponent(node.dataset.reference)); } + catch (_) { return null; } + if (!reference || !['raw', 'label', 'html', 'href'].every(key => typeof reference[key] === 'string')) return null; + const title = node.getAttribute('title') || ''; + const titleSource = title ? ' "' + title.replace(/[\\"]/g, '\\$&') + '"' : ''; + if (reference.image) { + const template = document.createElement('template'); + template.innerHTML = reference.html; + if (node.dataset.markdownPath !== reference.href) return '![' + node.alt.replace(/[\\[\]]/g, '\\$&') + '](' + node.dataset.markdownPath + titleSource + ')'; + if (node.alt === template.content.textContent) return reference.raw; + return '![' + node.alt.replace(/[\\[\]]/g, '\\$&') + '][' + reference.label + ']'; + } + const contents = document.createElement('span'); + for (const child of node.childNodes) contents.appendChild(child.cloneNode(true)); + if (node.innerHTML === reference.html && node.getAttribute('href') === reference.href) return reference.raw; + // Newly typed literal brackets must not terminate the authored link label. + // Keep code and retained nested references on their own serialization path. + const walker = document.createTreeWalker(contents, 4); + for (let text = walker.nextNode(); text; text = walker.nextNode()) { + if (!text.parentElement.closest('code,[data-reference],[data-reference-escape],.math-inline')) + text.textContent = text.textContent.replace(/[\\[\]]/g, '\\$&'); + } + if (node.getAttribute('href') !== reference.href) return '[' + readChildren(contents) + '](' + node.getAttribute('href') + titleSource + ')'; + return '[' + readChildren(contents) + '][' + reference.label + ']'; +} + +function renderDefinition(block, body, exportCode, labels) { + const isNote = block.type === 'footnote_reference'; + if (exportCode && !isNote) return ''; + if (exportCode && (!block.meta.primary || !block.meta.number)) return ''; + const label = isNote ? labels.footnote + ' ' + (block.meta.number || block.meta.label) : labels.referenceDefinition + ' [' + block.meta.label + ']'; + let preview = ''; + if (isNote) { + const id = noteId(block.meta.key); + const backlinks = block.meta.primary ? Array.from({ length: block.meta.count }, (_, index) => + '↩︎' + (index + 1) + '').join(' ') : ''; + preview = '
' + body + '
'; + } + const id = isNote && block.meta.primary ? ' id="' + noteId(block.meta.key) + '"' : ''; + if (exportCode) return '
' + escape(label) + '' + preview + '
'; + return '
' + preview + (block.meta.duplicate ? '

' + escape(labels.referenceDuplicate) + '

' : '') + + '
' + escape(label + ' — ' + labels.editDefinition) + '' + + '
'; +} + +module.exports = { renderInline, readInline, renderDefinition, noteId }; diff --git a/src/webview/editor/input/dispatch.js b/src/webview/editor/input/dispatch.js index a1d1dc7b..8ca3ffe4 100644 --- a/src/webview/editor/input/dispatch.js +++ b/src/webview/editor/input/dispatch.js @@ -81,7 +81,7 @@ function createDispatch(dependencies) { return false; } function handleKeydown(e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; dependencies.logger.log('Editor keydown:', e.key); if (dependencies.isSourceMode) @@ -382,7 +382,7 @@ function createDispatch(dependencies) { }, true); dependencies.editor.addEventListener('keydown', handleKeydown); dependencies.editor.addEventListener('beforeinput', function (e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; if (dependencies.isSourceMode) return; @@ -519,7 +519,7 @@ function createDispatch(dependencies) { } }); dependencies.editor.addEventListener('input', function (e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; if (dependencies.isSourceMode) return; diff --git a/src/webview/editor/transfer/clipboard.js b/src/webview/editor/transfer/clipboard.js index 5956c644..9213dbad 100644 --- a/src/webview/editor/transfer/clipboard.js +++ b/src/webview/editor/transfer/clipboard.js @@ -228,7 +228,7 @@ function createClipboard(dependencies) { dependencies.logger.log('No image found in drop'); }); dependencies.editor.addEventListener('copy', function (e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; if (dependencies.isSourceMode) return; @@ -539,7 +539,7 @@ function createClipboard(dependencies) { } }); dependencies.editor.addEventListener('cut', function (e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; if (dependencies.isSourceMode) return; diff --git a/src/webview/editor/transfer/paste.js b/src/webview/editor/transfer/paste.js index cc74b51a..a466f413 100644 --- a/src/webview/editor/transfer/paste.js +++ b/src/webview/editor/transfer/paste.js @@ -5,7 +5,7 @@ */ function createPaste({ editor, logger, getSourceMode, saveSnapshot, markAsEdited, saveImageAndInsert, syncMarkdown, syncMarkdownSync, setupLink, parseInline, markdownToHtmlFragment, setupInteractiveElements, }) { function handlePaste(e) { - if (e.target.closest && e.target.closest('.front-matter')) + if (e.target.closest && e.target.closest('.front-matter, .reference-definition')) return; if (getSourceMode()) return; diff --git a/src/webview/editor/ui/commands.js b/src/webview/editor/ui/commands.js index 1855ac54..ff306916 100644 --- a/src/webview/editor/ui/commands.js +++ b/src/webview/editor/ui/commands.js @@ -158,6 +158,9 @@ function createCommands(dependencies) { case 'image': dependencies.requestHostInsertion(action); break; + case 'footnote': + dependencies.insertFootnote(); + break; case 'toc': dependencies.insertManagedToc(); break; diff --git a/src/webview/editor/ui/menus.js b/src/webview/editor/ui/menus.js index 3946c3b8..5551d0bc 100644 --- a/src/webview/editor/ui/menus.js +++ b/src/webview/editor/ui/menus.js @@ -75,10 +75,10 @@ function createMenus(dependencies) { return dependencies.i18n.insertUnavailableSelection; const element = node => node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; const start = element(range.startContainer), end = element(range.endContainer); - const protectedBlock = 'pre, .math-wrapper, .mermaid-wrapper, .front-matter, .toc-block, .math-inline'; + const protectedBlock = 'pre, .math-wrapper, .mermaid-wrapper, .front-matter, .toc-block, .math-inline, .reference-definition'; if (start.closest(protectedBlock) || end.closest(protectedBlock)) return dependencies.i18n.insertUnavailableContext; - const inline = ['inlineMath', 'link', 'image'].includes(action); + const inline = ['inlineMath', 'link', 'image', 'footnote'].includes(action); if (!inline && (start.closest('li, td, th, blockquote') || end.closest('li, td, th, blockquote'))) { return dependencies.i18n.insertUnavailableBlock; } @@ -578,6 +578,7 @@ function createMenus(dependencies) { // Group: Insert { group: 'insert', action: 'link', i18nKey: 'insertLink', icon: 'link' }, { group: 'insert', action: 'image', i18nKey: 'insertImage', icon: 'image' }, + { group: 'insert', action: 'footnote', i18nKey: 'footnote', icon: 'link' }, { group: 'insert', action: 'toc', i18nKey: 'insertToc', icon: 'ul' }, { group: 'insert', action: 'table', i18nKey: 'insertTable', icon: 'table' }, { group: 'view', action: 'viewUndo', i18nKey: 'undo', icon: 'undo' }, @@ -603,9 +604,9 @@ function createMenus(dependencies) { (insertButton = document.getElementById('insertButton')); (insertMenu = document.getElementById('insertMenu')); (insertMenuRange = null); - (insertActions = ['table', 'inlineMath', 'codeblock', 'math', 'link', 'image', 'mermaid', 'toc']); - (insertCategory = { inlineMath: 'equationsCategory', math: 'equationsCategory', table: 'structureCategory', toc: 'structureCategory', codeblock: 'codeCategory', mermaid: 'codeCategory', link: 'mediaCategory', image: 'mediaCategory' }); - (insertSamples = { inlineMath: '$x^2$', math: '$$\\frac{a+b}{c}$$', table: '| A | B |\n| --- | --- |', codeblock: '```javascript\nconst value = 1;\n```', link: '[text](https://example.com)', image: '![description](image.png)', mermaid: 'graph TD\n A --> B', toc: '[TOC]' }); + (insertActions = ['table', 'inlineMath', 'codeblock', 'math', 'link', 'image', 'mermaid', 'toc', 'footnote']); + (insertCategory = { footnote: 'structureCategory', inlineMath: 'equationsCategory', math: 'equationsCategory', table: 'structureCategory', toc: 'structureCategory', codeblock: 'codeCategory', mermaid: 'codeCategory', link: 'mediaCategory', image: 'mediaCategory' }); + (insertSamples = { footnote: '[^note]', inlineMath: '$x^2$', math: '$$\\frac{a+b}{c}$$', table: '| A | B |\n| --- | --- |', codeblock: '```javascript\nconst value = 1;\n```', link: '[text](https://example.com)', image: '![description](image.png)', mermaid: 'graph TD\n A --> B', toc: '[TOC]' }); (actionDescription = action => ['viewUndo', 'viewRedo'].includes(action) ? dependencies.i18n.historyDescription : action.startsWith('view') ? dependencies.i18n.viewDescription : dependencies.i18n['insertDescription' + action[0].toUpperCase() + action.slice(1)] || (['bold', 'italic', 'underline', 'strikethrough', 'code'].includes(action) ? dependencies.i18n.formatDescription : dependencies.i18n.blockDescription)); (insertSearch = null); (insertCategorySelection = 'allCategory'); diff --git a/src/webview/styles.css b/src/webview/styles.css index 75782126..b8a5f38f 100644 --- a/src/webview/styles.css +++ b/src/webview/styles.css @@ -1339,6 +1339,14 @@ html[data-math-source-position="below"] .editor .math-wrapper[data-mode="edit"] /* Metadata is source text, separate from the editable prose. */ .document-aux { border: 1px solid var(--border-color); border-radius: 6px; margin: 1em 0; padding: 12px 16px; } +.reference-definition { border-inline-start: 3px solid var(--border-color); margin: 1em 0; padding: 8px 16px; } +.reference-definition summary { cursor: pointer; color: var(--text-color); font-size: .9em; } +.reference-source { box-sizing: border-box; display: block; width: 100%; margin-top: 8px; padding: 8px; resize: vertical; font: .85em/1.6 var(--font-mono); background: var(--code-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; } +.reference-source:focus-visible, .reference-definition:focus-visible, .footnote-ref a:focus-visible, .footnote-backlink:focus-visible { outline: 2px solid var(--link-color); outline-offset: 2px; } +.footnote-ref { font-size: .75em; vertical-align: super; line-height: 0; } +.footnote-ref a, .footnote-backlink { cursor: pointer; } +.footnote-backlinks { font-size: .85em; margin-block: .4em; } +.reference-unresolved { text-decoration: underline dotted; text-underline-offset: .2em; } .front-matter summary { cursor: pointer; color: var(--text-color); font-size: .9em; } .front-matter-source { box-sizing: border-box; display: block; width: 100%; margin-top: 12px; padding: 12px; resize: vertical; font: .85em/1.6 var(--font-mono); background: var(--code-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; } .front-matter-source:focus-visible { outline: 2px solid var(--link-color); outline-offset: 2px; } diff --git a/test/specs/command-palette.spec.ts b/test/specs/command-palette.spec.ts index 1ee46cd6..7868a0ba 100644 --- a/test/specs/command-palette.spec.ts +++ b/test/specs/command-palette.spec.ts @@ -68,8 +68,9 @@ test.describe('コマンドパレット', () => { // 全アイテム数を確認 const allItems = await page.locator('.command-palette-item').count(); - expect(allItems).toBe(34); // Formatting, insertion, history, and view actions; this fixture has no export host. + expect(allItems).toBe(35); // Formatting, insertion, history, and view actions; this fixture has no export host. await expect(page.locator('.command-palette-item[data-action="toc"]')).toHaveCount(1); + await expect(page.locator('.command-palette-item[data-action="footnote"]')).toHaveCount(1); await expect(page.locator('.command-palette-item[data-action="underline"]')).toHaveCount(1); // "heading" でフィルタ diff --git a/test/specs/export-editor.spec.ts b/test/specs/export-editor.spec.ts index f03f6b35..abde4733 100644 --- a/test/specs/export-editor.spec.ts +++ b/test/specs/export-editor.spec.ts @@ -299,7 +299,7 @@ test.describe('Export document-only rendering', () => { }); } - test('renders math and a saved TOC while disclosing literal footnotes', async ({ page }) => { + test('renders math, a saved TOC and linked footnotes', async ({ page }) => { const source = '# Literal notation\n\nInline $x^2$ and $y$.\n\n$$\nE = mc^2\n$$\n\n[TOC]\n\nText with a footnote.[^note]\n\n[^note]: Footnote body.\n'; await setMarkdown(page, source); const originalHtml = await page.locator('#editor').innerHTML(); @@ -309,11 +309,10 @@ test.describe('Export document-only rendering', () => { expect(rendered.html).not.toContain('$$'); expect(rendered.html).toContain('toc-block'); expect(rendered.html).not.toContain('[TOC]'); - expect(rendered.html).toContain('[^note]'); + expect(rendered.html).toContain('footnote-definition'); + expect(rendered.html).not.toContain('[^note]'); expect(rendered.html).toContain('katex'); - expect(rendered.warnings.map((warning: { code: string }) => warning.code)).toEqual([ - 'renderer-footnote-source' - ]); + expect(rendered.warnings).toEqual([]); expect(await page.locator('#editor').innerHTML()).toBe(originalHtml); await sendHost(page, { type: 'captureExportSnapshot', requestId: 'literal-source-unchanged' }); expect((await messages(page, 'exportSnapshot')).at(-1)?.content).toBe(source); diff --git a/test/specs/insert-menu.spec.ts b/test/specs/insert-menu.spec.ts index 1257dcd8..a87a9f00 100644 --- a/test/specs/insert-menu.spec.ts +++ b/test/specs/insert-menu.spec.ts @@ -35,18 +35,18 @@ async function open(page: Page) { } for (const mode of ['full', 'simple']) { - test(`${mode} Insert exposes eight items and cancels without editing`, async ({ page }) => { + test(`${mode} Insert exposes nine items and cancels without editing`, async ({ page }) => { await setup(page); await page.evaluate(mode => (window as any).__hostMessageHandler({ type: 'toolbarMode', value: mode }), mode); await select(page); await open(page); - await expect(page.locator('#insertMenu [role="menuitem"]')).toHaveCount(8); + await expect(page.locator('#insertMenu [role="menuitem"]')).toHaveCount(9); await expect(page.locator('#insertMenu [aria-disabled="true"]')).toHaveCount(0); await expect(page.locator('.insert-search input')).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(page.locator('[data-insert-action="table"]')).toBeFocused(); await page.keyboard.press('End'); - await expect(page.locator('[data-insert-action="toc"]')).toBeFocused(); + await expect(page.locator('[data-insert-action="footnote"]')).toBeFocused(); await page.keyboard.press('ArrowDown'); await expect(page.locator('[data-insert-action="table"]')).toBeFocused(); await page.keyboard.press('Escape'); @@ -125,7 +125,7 @@ test('Insert remains reachable in a narrow pane and its menu stays on screen', a expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(480); await page.keyboard.press('ArrowDown'); await page.keyboard.press('End'); - await expect(page.locator('[data-insert-action="toc"]')).toBeFocused(); + await expect(page.locator('[data-insert-action="footnote"]')).toBeFocused(); await page.keyboard.press('Escape'); expect(await page.evaluate(() => getSelection()!.toString())).toBe('target'); }); diff --git a/test/specs/references-footnotes.spec.ts b/test/specs/references-footnotes.spec.ts new file mode 100644 index 00000000..2c024051 --- /dev/null +++ b/test/specs/references-footnotes.spec.ts @@ -0,0 +1,295 @@ +import { test, expect, Page } from '@playwright/test'; +import { openInsertWorkspace } from '../utils/command-menu'; + +const message = (page: Page, payload: object) => page.evaluate(value => (window as any).__hostMessageHandler(value), payload); +const markdown = (page: Page): Promise => page.evaluate(() => (window as any).__testApi.getMarkdown()); +async function save(page: Page) { + await page.keyboard.press('Control+s'); + const saved = await page.evaluate(() => (window as any).__testApi.messages.filter((m: any) => m.type === 'save').at(-1)); + expect(saved?.content).toBeDefined(); + await message(page, { type: 'documentSaved', content: saved.content }); + await message(page, { type: 'saveResult', revision: saved.revision, success: true }); + return saved.content as string; +} +async function caret(page: Page, selector: string) { + await page.locator(selector).evaluate(element => { + document.getElementById('editor')!.focus(); + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); +} +async function reopen(page: Page, content: string) { + await page.reload(); + await page.waitForFunction(() => (window as any).__testApi?.ready); + await message(page, { type: 'update', content }); +} +test.beforeEach(async ({ page }) => { + await page.goto('/production-editor.html'); + await page.waitForFunction(() => (window as any).__testApi?.ready); +}); + +test('reference forms, shared titles and images survive editing, history and save/reopen', async ({ page }) => { + const source = 'Read [first][guide], [guide][] and [guide].\n\n![Icon][image]\n\nEnd\n\n[guide]: \'Project guide\'\n[image]: data:image/png;base64,iVBORw0KGgo= "Icon title"\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor p:first-child a')).toHaveCount(3); + await expect(page.locator('#editor p:first-child a').first()).toHaveAttribute('title', 'Project guide'); + await expect(page.locator('#editor img')).toHaveAttribute('title', 'Icon title'); + expect(await markdown(page)).toBe(source); + await caret(page, '#editor > p:nth-child(3)'); + await page.keyboard.type('!'); + expect(await save(page)).toBe(source.replace('End', 'End!')); + await caret(page, '#editor p:first-child a:nth-child(2)'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type(' changed'); + const changed = await save(page); + expect(changed).toContain('[guid changede][guide]'); + expect(changed).toContain("[guide]: 'Project guide'"); + expect(changed).toContain('[first][guide]'); + await message(page, { type: 'performUndo' }); + expect(await markdown(page)).not.toContain(' changed'); + await message(page, { type: 'performRedo' }); + expect(await markdown(page)).toBe(changed); + await reopen(page, changed); + expect(await save(page)).toBe(changed); + await message(page, { type: 'toggleSourceMode' }); + await expect(page.locator('#sourceEditor')).toHaveValue(changed); + await message(page, { type: 'toggleSourceMode' }); + expect(await markdown(page)).toBe(changed); +}); + +test('repeated footnotes support keyboard navigation and definition editing without losing source', async ({ page }) => { + const source = 'First.[^method] Again.[^method] Missing.[^unknown]\n\n[^method]: A **strong** note.\n\n Second paragraph.\n\nTail\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor .footnote-ref')).toHaveCount(2); + await expect(page.locator('#editor .footnote-body strong')).toHaveText('strong'); + await expect(page.locator('#editor .reference-unresolved')).toHaveText('[^unknown]'); + expect(await markdown(page)).toBe(source); + const first = page.locator('#editor .footnote-ref a').first(); + await first.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#editor .footnote-definition')).toBeFocused(); + await page.locator('#editor .footnote-backlink').nth(1).focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#editor .footnote-ref a').nth(1)).toBeFocused(); + expect(await markdown(page)).toBe(source); + await page.locator('#editor .footnote-definition summary').click(); + const input = page.locator('#editor .reference-source'); + await input.fill('[^method]: Revised **note**.\n\n Second paragraph.'); + const saved = await save(page); + expect(saved).toBe(source.replace('A **strong** note.', 'Revised **note**.')); + await page.keyboard.press('Control+Enter'); + await expect(page.locator('#editor .footnote-body strong')).toHaveText('note'); + await reopen(page, saved); + expect(await save(page)).toBe(saved); +}); + +test('footnote insertion avoids identifiers already present and remains undoable', async ({ page }) => { + const source = 'Text.[^NOTE-1] Missing.[^note-2]\n\n[^NOTE-1]: Existing note.\n'; + await message(page, { type: 'update', content: source }); + await caret(page, '#editor > p:first-child'); + await openInsertWorkspace(page); + await page.locator('#insertMenu [data-insert-action="footnote"]').click(); + const input = page.locator('#editor .reference-source').last(); + await expect(input).toBeFocused(); + expect(await markdown(page)).toContain('[^note-3]: Write the note here.'); + await input.fill('[^note-3]: New note.'); + expect(await save(page)).toContain('[^note-3]: New note.'); + await page.keyboard.press('Control+Enter'); + await expect(page.locator('#editor .footnote-body').last()).toHaveText('New note.'); + await message(page, { type: 'performUndo' }); + await message(page, { type: 'performUndo' }); + expect(await markdown(page)).toBe(source); +}); + +test('detached export resolves references and notes without touching live state', async ({ page }) => { + const source = '[Guide][g] and note.[^n] Repeated.[^n]\n\n[g]: https://example.com "Title"\n[^n]: Note **body**.\n'; + await message(page, { type: 'update', content: source }); + const before = await page.locator('#editor').innerHTML(); + await message(page, { type: 'prepareExport', requestId: 'notes', markdown: source, theme: 'github', fontSize: 16 }); + await page.waitForFunction(() => (window as any).__testApi.messages.some((m: any) => m.type === 'exportPrepared' && m.requestId === 'notes')); + const output = await page.evaluate(() => { + const result = (window as any).__testApi.messages.findLast((m: any) => m.type === 'exportPrepared'); + const doc = new DOMParser().parseFromString(result.html, 'text/html'); + return { warnings: result.warnings, controls: doc.querySelectorAll('textarea,details,button').length, + href: doc.querySelector('a[href="https://example.com"]')?.getAttribute('href'), notes: doc.querySelectorAll('.footnote-definition').length, + destinations: Array.from(doc.querySelectorAll('a[href^="#bm-note-"]')).every(link => doc.getElementById(link.getAttribute('href')!.slice(1))) }; + }); + expect(output).toEqual({ warnings: [], controls: 0, href: 'https://example.com', notes: 1, destinations: true }); + expect(await page.locator('#editor').innerHTML()).toBe(before); + expect(await markdown(page)).toBe(source); +}); + +test('reference-like text in code and escaped syntax stays literal after neighboring edits', async ({ page }) => { + const source = '`[a] [^n]` and \\[a].\n\n```md\n[a] [^n]\n```\n\nTail\n\n[a]: /target\n[^n]: Note.\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor > p:first-child a, #editor code a')).toHaveCount(0); + await caret(page, '#editor > p:nth-child(3)'); + await page.keyboard.type('!'); + expect(await save(page)).toBe(source.replace('Tail', 'Tail!')); +}); + +test('editing a shared link definition updates all references and preserves its position', async ({ page }) => { + const source = '[ref]: /first "Old"\n\nRead [first][ref] and [ref][] and [ref].\n\nTail\n'; + await message(page, { type: 'update', content: source }); + await page.locator('#editor .reference-definition summary').click(); + await page.locator('#editor .reference-source').fill('[ref]: /second "New title"'); + await page.keyboard.press('Control+Enter'); + await expect(page.locator('#editor p a[href="/second"]')).toHaveCount(3); + await expect(page.locator('#editor p a').first()).toHaveAttribute('title', 'New title'); + expect(await save(page)).toBe(source.replace('/first "Old"', '/second "New title"')); + await message(page, { type: 'performUndo' }); + expect(await markdown(page)).toBe(source); +}); + +test('notes in lists and tables preserve identifiers when surrounding content changes', async ({ page }) => { + const source = '- Read [guide] and note.[^n]\n\n| Value | Note |\n| --- | --- |\n| Text | [^n] and [guide] |\n\n[guide]: /guide\n[^n]: Body.\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor .footnote-ref')).toHaveCount(2); + await expect(page.locator('#editor a[data-reference]')).toHaveCount(2); + await caret(page, '#editor > ul > li'); + await page.keyboard.type('!'); + await page.locator('#editor td').first().fill('Changed'); + const saved = await save(page); + expect(saved).toContain('- Read [guide] and note.[^n]!'); + expect(saved).toContain('[^n] and [guide]'); + expect(saved).toContain('[guide]: /guide\n[^n]: Body.'); + await reopen(page, saved); + expect(await save(page)).toBe(saved); +}); + +test('duplicate notes and unresolved links retain source and explain resolution', async ({ page }) => { + const source = 'A[^ONE]. B[^one]. [text][absent]\n\n[^one]: First.\n[^ONE]: Second.\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor .footnote-ref a')).toHaveText(['1', '1']); + await expect(page.locator('#editor .reference-diagnostic')).toContainText('first definition'); + await expect(page.locator('#editor .reference-unresolved')).toHaveAttribute('title', /No matching definition/); + expect(await markdown(page)).toBe(source); + await page.locator('#editor .footnote-ref a').last().focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#editor .footnote-definition').first()).toBeFocused(); + await message(page, { type: 'toggleSourceMode' }); + await page.locator('#sourceEditor').fill(source + '\n[absent]: /resolved\n'); + await message(page, { type: 'toggleSourceMode' }); + await expect(page.locator('#editor a[href="/resolved"]')).toHaveText('text'); +}); + +test('complex footnote previews do not expose editing controls or change the definition', async ({ page }) => { + const source = 'Note.[^n]\n\n[^n]: Equation $x^2$.\n\n - [ ] Task\n\n | A | B |\n | --- | --- |\n | One | Two |\n\n ```js\n const n = 1;\n ```\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor .footnote-body [contenteditable="true"]')).toHaveCount(0); + await expect(page.locator('#editor .footnote-body input[type="checkbox"]')).toBeDisabled(); + await expect(page.locator('#editor .footnote-body .code-block-toolbar')).toHaveCount(0); + expect(await markdown(page)).toBe(source); +}); + +test('linked reference images and nested labels survive editing and reopening', async ({ page }) => { + const source = '[![alt][image]][guide] and ![alt [guide]](image.png).\n\n' + + '[image]: data:image/png;base64,iVBORw0KGgo= "Picture"\n[guide]: /guide\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor a[data-reference] > img[data-reference]')).toHaveCount(1); + await expect(page.locator('#editor > p')).toHaveCount(1); + expect(await markdown(page)).toBe(source); + await caret(page, '#editor > p'); + await page.keyboard.type('!'); + const saved = await save(page); + expect(saved).toContain('[![alt][image]][guide]'); + expect(saved).not.toContain('undefined'); + await reopen(page, saved); + expect(await save(page)).toBe(saved); +}); + +test('literal brackets typed into a reference label stay visible without breaking the target', async ({ page }) => { + const source = '[Guide][g] and \\[literal].\n\n[g]: /guide\n'; + await message(page, { type: 'update', content: source }); + await caret(page, '#editor a[data-reference]'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type(']'); + const saved = await save(page); + expect(saved).toContain('[Guid\\]e][g]'); + await reopen(page, saved); + await expect(page.locator('#editor a[data-reference]')).toHaveText('Guid]e'); + await expect(page.locator('#editor a[data-reference]')).toHaveAttribute('href', '/guide'); + expect(await save(page)).toBe(saved); +}); + +test('reference-like syntax inside math stays equation source', async ({ page }) => { + const source = 'Math $[x] + [^n]$ and [x].\n\n[x]: /x\n[^n]: Note.\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor .footnote-ref')).toHaveCount(0); + await expect(page.locator('#editor a[data-reference]')).toHaveCount(1); + await expect(page.locator('#editor .math-inline')).toHaveAttribute('data-math-tex', encodeURIComponent('[x] + [^n]')); + await caret(page, '#editor > p'); + await page.keyboard.type('!'); + expect(await save(page)).toBe(source.replace('and [x].', 'and [x].!')); +}); + +test('unused notes cannot consume exported backlink destinations', async ({ page }) => { + const source = '[^unused]: An unused note mentioning [^n].\n\nText.[^n]\n\n[^n]: Kept.\n[^N]: Duplicate.\n'; + await message(page, { type: 'prepareExport', requestId: 'unused', markdown: source, theme: 'github', fontSize: 16 }); + await page.waitForFunction(() => (window as any).__testApi.messages.some((m: any) => m.type === 'exportPrepared' && m.requestId === 'unused')); + const result = await page.evaluate(() => { + const prepared = (window as any).__testApi.messages.findLast((m: any) => m.type === 'exportPrepared'); + const doc = new DOMParser().parseFromString(prepared.html, 'text/html'); + return { text: doc.body.textContent, missing: Array.from(doc.querySelectorAll('a[href^="#"]')).filter(a => !doc.getElementById(a.getAttribute('href')!.slice(1))).length }; + }); + expect(result.text).not.toContain('unused'); + expect(result.text).not.toContain('Duplicate'); + expect(result.missing).toBe(0); +}); + +test('escaped markers stay literal and malformed definitions stay editable with an explanation', async ({ page }) => { + const source = 'Escaped \\[^n] and \\[guide].\n\n[guide]: javascript:alert(1)\n\n[^n]: Unused.\n'; + await message(page, { type: 'update', content: source }); + await expect(page.locator('#editor > p').first()).toHaveText('Escaped [^n] and [guide].'); + await expect(page.locator('#editor > p.reference-unresolved')).toHaveText('[guide]: javascript:alert(1)'); + await caret(page, '#editor > p:first-child'); + await page.keyboard.type('!'); + const saved = await save(page); + expect(saved).toContain('Escaped \\[^n] and \\[guide].!'); + await message(page, { type: 'prepareExport', requestId: 'escaped', markdown: saved, theme: 'github', fontSize: 16 }); + await page.waitForFunction(() => (window as any).__testApi.messages.some((m: any) => m.type === 'exportPrepared' && m.requestId === 'escaped')); + const result = await page.evaluate(() => (window as any).__testApi.messages.findLast((m: any) => m.type === 'exportPrepared')); + expect(result.warnings).toEqual([]); + expect(result.html).not.toContain('footnote-definition'); +}); + +test('unused and duplicate note previews cannot take return links from the main text', async ({ page }) => { + const source = '[^unused]: Preview with [^n].\n\nText.[^n] Again.[^n]\n\n[^n]: Kept.\n[^N]: Duplicate with [^n].\n'; + await message(page, { type: 'update', content: source }); + const references = page.locator('#editor > p .footnote-ref a'); + const backlinks = page.locator('#editor .footnote-backlink'); + await expect(references).toHaveCount(2); + await expect(backlinks).toHaveCount(2); + for (let index = 0; index < 2; index++) { + await expect(backlinks.nth(index)).toHaveAttribute('href', '#' + await references.nth(index).getAttribute('id')); + await backlinks.nth(index).focus(); + await page.keyboard.press('Enter'); + await expect(references.nth(index)).toBeFocused(); + } + expect(await markdown(page)).toBe(source); +}); + +test('Split keeps reference definitions read only while source edits share history and saved content', async ({ page }) => { + const source = 'Read [guide]. Note.[^説明]\n\n[guide]: /first\n[^説明]: Original note.\n'; + const changed = source.replace('/first', '/second').replace('Original note.', 'Revised note.'); + await message(page, { type: 'update', content: source }); + await page.locator('button[data-editor-mode="split"]').click(); + await expect(page.locator('#sourceEditor')).toHaveValue(source); + await expect(page.locator('#editor .reference-source').first()).toBeDisabled(); + await expect(page.locator('#editor .reference-source').last()).toBeDisabled(); + await page.locator('#sourceEditor').fill(changed); + await expect(page.locator('#editor a[data-reference]')).toHaveAttribute('href', '/second'); + await expect(page.locator('#editor .footnote-body')).toHaveText('Revised note.'); + expect(await save(page)).toBe(changed); + await page.locator('button[data-editor-mode="visual"]').click(); + await expect(page.locator('#editor .reference-source').last()).toBeEnabled(); + await message(page, { type: 'performUndo' }); + expect(await markdown(page)).toBe(source); + await message(page, { type: 'performRedo' }); + expect(await markdown(page)).toBe(changed); + await reopen(page, changed); + expect(await save(page)).toBe(changed); +}); diff --git a/test/specs/selected-ui-ux.spec.ts b/test/specs/selected-ui-ux.spec.ts index 288dd218..37ce06e3 100644 --- a/test/specs/selected-ui-ux.spec.ts +++ b/test/specs/selected-ui-ux.spec.ts @@ -407,13 +407,13 @@ test('narrow Find keeps the selected document match visible above its scrollable test('Insert workspace filters categories, previews choices and recovers an empty search', async ({ page }) => { await setup(page); await openInsertWorkspace(page); - await expect(page.locator('.insert-preview')).toHaveCount(8); + await expect(page.locator('.insert-preview')).toHaveCount(9); await page.locator('[data-insert-category="equationsCategory"]').click(); await expect(page.locator('#insertMenu [data-insert-action]:visible')).toHaveCount(2); await page.locator('.insert-search input').fill('unmatched-command'); await expect(page.locator('.insert-empty')).toBeVisible(); await page.locator('.insert-search button').click(); - await expect(page.locator('#insertMenu [data-insert-action]:visible')).toHaveCount(8); + await expect(page.locator('#insertMenu [data-insert-action]:visible')).toHaveCount(9); await page.keyboard.press('Escape'); expect((await snapshot(page)).content).toBe(authored); }); @@ -451,7 +451,7 @@ for (const offset of [0, -0.5]) test('narrow Insert exposes complete lower comma for (let step = 0; step < 8 && await next.isEnabled(); step++) await next.click(); await expect(page.locator('[data-insert-action="toc"]')).toBeVisible(); await expect(page.locator('[data-insert-action="mermaid"]')).toBeVisible(); - await expect(page.locator('.insert-scroll output')).toContainText('/ 8'); + await expect(page.locator('.insert-scroll output')).toContainText('/ 9'); await expect(next).toBeDisabled(); await expect(next).toHaveCSS('opacity', '0.45'); expect(await page.locator('.insert-options').evaluate(list => { diff --git a/test/unit/export-backends.test.js b/test/unit/export-backends.test.js index 0fea023e..02f80dda 100644 --- a/test/unit/export-backends.test.js +++ b/test/unit/export-backends.test.js @@ -527,6 +527,37 @@ test('real Pandoc exports structured content, native math and original assets wi assert.equal(await fs.readFile(sourceFile, 'utf8'), source); }); +test('real Pandoc preserves reference links, reference images and repeated native footnotes', { skip: !realTools }, async t => { + const directory = await temporary(t); + const status = await discoverTool('pandoc', process.env.EXPORT_PANDOC_PATH || ''); + assert.equal(status.available, true, status.error); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGP4z8DAAMIM/4EAAB/uBfsL2WiLAAAAAElFTkSuQmCC', 'base64'); + const markdown = 'Read [guide][ref], [ref][] and [ref]. Note.[^NOTE] Again.[^note]\n\n![Original][image]\n\n' + + '[ref]: https://example.com/guide "Guide title"\n[image]: assets/picture.png "Picture title"\n' + + '[^note]: NATIVE-NOTE-MARKER **preserved**.\n[^NOTE]: DUPLICATE-IGNORED-MARKER\n'; + const sourcePath = path.join(directory, 'references.md'); + await fs.writeFile(sourcePath, markdown); + const saved = { sourcePath, markdown, version: 1, theme: 'github', fontSize: 16 }; + const prepared = { html: '', theme: 'github', fontSize: 16, diagrams: [], warnings: [] }; + for (const format of ['docx', 'epub']) { + const requested = []; + const ops = operations(async reference => { + requested.push(reference); + return { bytes: png, mime: 'image/png' }; + }); + const entries = archiveEntries(await convertPandoc(format, saved, prepared, status.path, ops)); + const content = [...entries].filter(([name]) => /(?:document\.xml|footnotes\.xml|\.xhtml|\.rels)$/.test(name)).map(([, bytes]) => bytes.toString()).join('\n'); + assert.match(content, /NATIVE-NOTE-MARKER/); + assert.doesNotMatch(content, /DUPLICATE-IGNORED-MARKER/); + assert.match(content, /https:\/\/example.com\/guide/); + assert.equal((content.match(format === 'docx' ? / bytes.equals(png))); + assert.deepEqual(requested, ['assets/picture.png']); + assert.deepEqual(ops.warnings, []); + } + assert.equal(await fs.readFile(sourcePath, 'utf8'), markdown); +}); + test('real Pandoc preserves visible fallbacks when media and diagrams cannot be exported', { skip: !realTools }, async t => { const directory = await temporary(t); const status = await discoverTool('pandoc', process.env.EXPORT_PANDOC_PATH || ''); diff --git a/test/unit/markdown-blocks.test.js b/test/unit/markdown-blocks.test.js index c5b2077b..33572c77 100644 --- a/test/unit/markdown-blocks.test.js +++ b/test/unit/markdown-blocks.test.js @@ -62,9 +62,9 @@ test('an unclosed fence retains its final line even without a final newline', () assert.equal(parsed.blocks[0].content, 'example\n---\nIMAGE_DIR: still code\n'); }); -test('literal HTML and unsupported reference definitions remain visible source', () => { +test('literal HTML and reference definitions retain editable source blocks', () => { const source = '\n\n[label]: target.md\n'; const parsed = parse(source); assert.equal(reconstruct(parsed), source); - assert.deepEqual(parsed.blocks.map(block => block.type), ['paragraph', 'paragraph']); + assert.deepEqual(parsed.blocks.map(block => block.type), ['paragraph', 'binary_reference']); }); diff --git a/test/unit/markdown-references.test.js b/test/unit/markdown-references.test.js new file mode 100644 index 00000000..ed0ff224 --- /dev/null +++ b/test/unit/markdown-references.test.js @@ -0,0 +1,74 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parse, inlineReferences } = require('../../src/shared/markdown-blocks'); +const reconstruct = parsed => parsed.blocks.map(block => block.before + block.source).join('') + parsed.trailing; + +test('full, collapsed, shortcut, Unicode and image references share definitions and titles', () => { + const source = '[Guide]: "A title"\n[説明]: image.png\n'; + const context = parse(source).references; + const spans = inlineReferences('[full][guide] [Guide][] [GUIDE] ![説明][]', context); + assert.deepEqual(spans.map(span => span.label), ['guide', 'Guide', 'GUIDE', '説明']); + assert.equal(spans[0].title, 'A title'); + assert.equal(spans[2].href, 'https://example.com/a(b)?x=1&y=2'); + assert.equal(spans[3].image, true); + assert.equal(spans[3].href, 'image.png'); + const images = inlineReferences('![custom][説明] ![説明][] ![説明]', context); + assert.equal(images.length, 3); + assert.ok(images.every(span => span.image && span.href === 'image.png' && span.label === '説明')); +}); + +test('definitions, duplicates, unused notes and exact boundaries survive parsing', () => { + const source = '\nText [label] [^one] [^one].\n\n[label]: /first\n[label]: /second\n\n[^one]: First paragraph.\n\n Another **paragraph**.\n\n[^unused]: Retain me.\n\nEnd\n\n'; + const parsed = parse(source); + assert.equal(reconstruct(parsed), source); + assert.equal(parsed.references.references.LABEL.href, '/first'); + assert.equal(parsed.references.notes.ONE.meta.count, 2); + assert.equal(parsed.references.notes.ONE.children.length, 2); + assert.equal(parsed.references.notes.UNUSED.meta.number, undefined); +}); + +test('code, escapes, malformed and unsafe definitions stay literal', () => { + const source = '[a]: /safe\n\n[bad]: javascript:alert(1)\n\n```md\n[hidden]: /not-a-definition\n[^code]: not a note\n```\n'; + const parsed = parse(source); + assert.equal(reconstruct(parsed), source); + assert.equal(parsed.references.references.BAD, undefined); + assert.equal(parsed.references.references.HIDDEN, undefined); + assert.equal(parsed.references.notes.code, undefined); + assert.deepEqual(inlineReferences('`[a] [^n]` \\[a] [unknown] [bad]', parsed.references).filter(span => !span.escaped), []); + assert.deepEqual(inlineReferences('!`[a]` [^missing]', parsed.references).map(span => span.note), ['missing']); +}); + +test('escaped labels, normalized whitespace and prototype-like names resolve safely', () => { + const context = parse('[a\\]b]: /escaped\n[a b]: /space\n[constructor]: /constructor\n[__proto__]: /proto\n').references; + assert.deepEqual(inlineReferences('[x][a\\]b] [a\n b] [constructor] [__proto__]', context).map(span => span.href), + ['/escaped', '/space', '/constructor', '/proto']); +}); + +test('footnotes use normalized labels and the first duplicate definition', () => { + const source = 'A[^ONE] B[^one].\n\n[^one]: First.\n[^ONE]: Duplicate.\n'; + const parsed = parse(source); + assert.equal(reconstruct(parsed), source); + assert.equal(parsed.references.notes.ONE.content, '[^one]: First.'); + assert.equal(parsed.references.notes.ONE.meta.count, 2); + assert.equal(parsed.blocks.at(-1).meta.duplicate, true); +}); + +test('unresolved full and collapsed references remain identifiable source', () => { + const spans = inlineReferences('[text][missing] and [missing][] and [ordinary brackets]', {}); + assert.deepEqual(spans.map(span => span.raw), ['[text][missing]', '[missing][]']); + assert.ok(spans.every(span => span.unresolved)); +}); + +test('nested image labels cannot leak offsets into the surrounding source', () => { + const context = parse('[x]: /x\n[image]: /image.png').references; + assert.deepEqual(inlineReferences('![alt [x]](image.png)', context), []); + assert.deepEqual(inlineReferences('[note [^x]](/link)', context), []); + assert.deepEqual(inlineReferences('[![alt][image]][x]', context).map(span => span.raw), ['[![alt][image]][x]']); +}); + +test('note markers inside equations do not create used notes or backlink counts', () => { + const parsed = parse('Math $[x] + [^n]$.\n\n[x]: /x\n[^n]: Unused.\n'); + assert.equal(parsed.references.notes.N.meta.number, undefined); + assert.equal(parsed.references.notes.N.meta.count, 0); +});