diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891326..54c87af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Keep the Markdown an image's description was written with, so `![Alt with *emphasis*](leaf.svg)` keeps its emphasis and `![Outer ![inner](inner.svg)](leaf.svg)` keeps the image inside it, instead of flattening the description to its text on open and losing the inner image's destination from the file on the first save. The image is still named by the text its description spells, and a description edited in the raw image Markdown is written as the text typed there. - Read a typed `*` or `_` run the way Markdown reads the same characters in a file, so `***text*` gives two literal asterisks before italic text, `_**text**` a literal underscore before bold text, and `_**text**_` italic bold, instead of leaving every marker as text that saved with backslashes and reopened without the formatting. A run whose closing marker is shorter than its opening one is read once the caret leaves it, because another marker typed there would spell something else. - Pair a `*`, `_`, or `~` typed against bold, italic, or strikethrough with the matching literal marker already on the other side of it, so closing `_**text**` with a `_` gives italic bold and saves `_**text**_`, instead of leaving both markers as text that saved as `\_**text**\_` and reopened without the italic. A marker a file keeps literal by escaping it stays literal. - Leave a `*`, `_`, or `~` bare on save wherever nothing else on its line could pair with it, so text such as `[a](b)*` keeps its marker as written instead of collecting a backslash merely because a link, an image, or a bold span shares the line with it. A marker that could still pair, including one that could pair with the markers of a span beside it, keeps its backslash. diff --git a/docs/decisions.md b/docs/decisions.md index d5a639f..c42ca2d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -205,6 +205,20 @@ - The literal-commit path is deliberately given no definitions, so text typed this session that spells a reference stays literal. A definition an author has not looked at should not capture a bracket run they were still writing, and the file keeps that run literal either way. - A definition's destination form and the blank lines between adjacent definitions are not preserved. Both are classes [issue #251](https://github.com/Azganoth/leafdown/issues/251) tracks for blocks generally, and a definition is now subject to them for the first time because it now survives to be written at all. +### Carry an image description as the source it was written with + +**Decision:** An image description holds inline content, and the image node carries it as the source it was written with rather than as content the document holds. The node keeps the alt text the parser derived, which is what the image is named by, and carries the description's source beside it wherever that source says more than the text: emphasis, strong, inline code, strikethrough, a link, or a nested image. The description reaches the file as it stands, and the raw image Markdown a focused image exposes is that same source. Decided in [issue #259](https://github.com/Azganoth/leafdown/issues/259). + +**Rationale:** The parser keeps only the text a description spells, so everything else in it was gone from the document on open and gone from the file after one save, with the destination of a nested image unrecoverable. Holding the description as document content would mean giving the image node inline children, which nothing delivers: the mdast image node carries no children to build them from, and the node view's whole surface is a raw Markdown input, so a description rich in the schema would still be edited as text. Carrying the source keeps what the author wrote and leaves the editing surface the one the image already had. + +**Consequences:** + +- Formatting and a nested image inside a description round-trip byte-identically, and the rendered image is still named by the text the description spells, which is the alt text an `img` element carries. +- A nested image is not a second image the editor renders, resolves, or blocks. It is source text on the image that holds it. +- A description spelling only escapes or character references carries no source of its own. Those differences are answered by the alt text and belong to the issues that settled them. +- Editing the description in the raw image Markdown replaces it with the text typed there, which the file escapes, because reading its markers back as inline content is the parse that input does not run. Editing the destination or the title leaves the description as written, and a copy through the DOM, which carries no authored attributes, falls back to the text as an edited description does. +- A description whose brackets a code span interrupts is left to its text. The source is read against the destination or the reference label the node holds, and a reading those refuse is declined rather than guessed. + ## Technical Decisions ### Use Tauri diff --git a/docs/specification.md b/docs/specification.md index 43fde39..a282107 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -333,7 +333,9 @@ Confirmations, warnings, and security blocks affect editor rendering only; sourc - Missing local images show a clear placeholder. - Remote image Markdown is preserved, but network images are currently blocked completely; loading them is deferred. - Local images that resolve outside the current folder context require explicit confirmation before rendering. Instead of a blocking modal, the editor displays an inline placeholder in place of the image, prompting the user to click to load/render it. -- Selecting a rendered or placeholder image exposes the raw image Markdown for editing the alt text and target path. +- Selecting a rendered or placeholder image exposes the raw image Markdown for editing the description and target path. +- An image description keeps the Markdown it was written with, including emphasis, strong, inline code, strikethrough, a link, and a nested image. It is source the image carries rather than content the document holds, so a nested image is written back as it was authored rather than rendered as a second image, and the image is named by the text its description spells. +- The raw image Markdown of a focused image is the source the file holds. Editing the destination or the title leaves the description as it was written; editing the description replaces it with the text typed there, which is written back escaped. For local-path resolution and asset-protocol handling, see [Architecture](./architecture.md#frontend-responsibilities). diff --git a/src/features/editor/plugins/characterReference.ts b/src/features/editor/plugins/characterReference.ts index c8f7502..b13b525 100644 --- a/src/features/editor/plugins/characterReference.ts +++ b/src/features/editor/plugins/characterReference.ts @@ -4,20 +4,36 @@ import { $markSchema, $remark } from "@milkdown/kit/utils"; import { CHARACTER_REFERENCE_MARK_NAME, characterReferenceMarkSchema, + findAuthoredDescription, findAuthoredDestination, + findAuthoredReferenceDescription, splitCharacterReferences, } from "../utils/characterReferenceMarkdown"; import { findTitleMarker, type TitleMarker } from "../utils/markdownTitle"; +import { IMAGE_REFERENCE_MARKDOWN_TYPE } from "../utils/referenceLinkMarkdown"; export const leafdownCharacterReferenceSchema = $markSchema( CHARACTER_REFERENCE_MARK_NAME, () => characterReferenceMarkSchema, ); -// A reference is gone from the value by the time the tree exists, and a title keeps its text -// without its markers, so both are recovered by walking the tree against the slice of the file -// each node was built from. A node the parser gave no position, or one another transformer has -// already rebuilt, is left alone. +const readNodeString = (node: MarkdownNode, key: string) => { + const value = (node as Record)[key]; + + return typeof value === "string" ? value : null; +}; + +const markAuthoredDescription = (node: MarkdownNode, description: string | null) => { + if (description !== null) { + (node as { authoredDescription?: string }).authoredDescription = description; + } +}; + +// A reference is gone from the value by the time the tree exists, a title keeps its text without +// its markers, and an image description keeps only the text its inline content spells, so all +// three are recovered by walking the tree against the slice of the file each node was built from. +// A node the parser gave no position, or one another transformer has already rebuilt, is left +// alone. const markAuthoredSource = (node: MarkdownNode, source: string) => { const children = node.children; @@ -56,9 +72,29 @@ const markAuthoredSource = (node: MarkdownNode, source: string) => { (child as { authoredUrl?: string }).authoredUrl = authored; } + if (child.type === "image") { + markAuthoredDescription( + child, + findAuthoredDescription(raw, readNodeString(child, "alt") ?? "", child.url), + ); + } + if (child.title) { (child as { titleMarker?: TitleMarker }).titleMarker = findTitleMarker(raw); } + } else if (child.type === IMAGE_REFERENCE_MARKDOWN_TYPE) { + const label = readNodeString(child, "label"); + + if (label !== null) { + markAuthoredDescription( + child, + findAuthoredReferenceDescription( + source.slice(start.offset, end), + readNodeString(child, "alt") ?? "", + label, + ), + ); + } } } diff --git a/src/features/editor/plugins/imageView.test.ts b/src/features/editor/plugins/imageView.test.ts index 3b609cd..f6dc8b3 100644 --- a/src/features/editor/plugins/imageView.test.ts +++ b/src/features/editor/plugins/imageView.test.ts @@ -378,6 +378,58 @@ describe("Markdown images", () => { expect(latestInput.selectionStart).toBe(5); expect(mounted.getMarkdown()).toBe("![Alpt](./assets/icon.png)\n"); }); + + const mountDescribedImage = async () => { + mockTauriApiCommand("resolveMarkdownImageTarget", () => ({ + kind: "renderable", + path: "C:\\Notes\\assets\\icon.png", + })); + + const mounted = await mountImageEditor("![Alt with *emphasis*](./assets/icon.png)"); + + await waitFor(() => { + expect( + within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }), + ).toBeInTheDocument(); + }); + + dispatchMouseDown(within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" })); + + return mounted; + }; + + // The input holds the source the file was written with rather than the text the description + // spells, so what it writes back is what the author is editing. + it("keeps the description the file holds while the rest of the image is edited", async () => { + const mounted = await mountDescribedImage(); + const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); + + expect(input).toHaveValue("![Alt with *emphasis*](./assets/icon.png)"); + dispatchInput(input, "![Alt with *emphasis*](./assets/updated.png)"); + + await waitFor(() => { + expect(mounted.getMarkdown()).toBe("![Alt with *emphasis*](./assets/updated.png)\n"); + }); + expect( + within(mounted.view.dom).getByRole("img", { name: "Alt with emphasis" }), + ).toBeInTheDocument(); + }); + + // Reading the markers a typed description spells back as inline content is the parse this + // input does not run, so an edited description is the text it holds and the file escapes it. + it("writes an edited description as the text it spells", async () => { + const mounted = await mountDescribedImage(); + const input = within(mounted.view.dom).getByRole("textbox", { name: "Image Markdown" }); + + dispatchInput(input, "![Alt with *markers*](./assets/icon.png)"); + + await waitFor(() => { + expect(mounted.getMarkdown()).toBe("![Alt with \\*markers\\*](./assets/icon.png)\n"); + }); + expect( + within(mounted.view.dom).getByRole("img", { name: "Alt with *markers*" }), + ).toBeInTheDocument(); + }); }); describe("reference images", () => { diff --git a/src/features/editor/plugins/imageView.ts b/src/features/editor/plugins/imageView.ts index 550eced..8457167 100644 --- a/src/features/editor/plugins/imageView.ts +++ b/src/features/editor/plugins/imageView.ts @@ -9,8 +9,13 @@ import { getErrorDescription, handleUnexpectedError } from "@/lib/errors"; import { MutableDisposable } from "@/lib/lifecycle"; import { isSameNullablePath } from "@/lib/path"; +import { + AUTHORED_DESCRIPTION_ATTRIBUTE_NAME, + readAuthoredDescription, +} from "../utils/characterReferenceMarkdown"; import { parseImageMarkdown, + readImageDescription, serializeImageMarkdown, type ImageDefinitionResolver, type ImageMarkdownAttrs, @@ -160,10 +165,16 @@ class LeafdownImageNodeView implements NodeView { } const currentAttrs = this.getImageAttrs(); - const nextAttrs = toNodeAttrs({ - ...currentAttrs, - ...attrs, - }); + const editedAttrs = { ...currentAttrs, ...attrs }; + // The input holds the description the file was written with, so an edit to the destination or + // the title leaves that description standing. A description the author did change is the text + // they typed, because reading its markers back as inline content is the parse this input does + // not run, and the file escapes them for it. + const keepsDescription = editedAttrs.description === currentAttrs.description; + const nextAttrs = toNodeAttrs( + keepsDescription ? { ...editedAttrs, alt: currentAttrs.alt } : editedAttrs, + keepsDescription ? readAuthoredDescription(this.node.attrs) : null, + ); if (attrs.src !== undefined && attrs.src !== currentAttrs.src) { this.allowOutsideFolder = false; @@ -289,6 +300,10 @@ const readNodeString = (node: ProseMirrorNode, key: string) => { const imageAttrsFromNode = (node: ProseMirrorNode): ImageMarkdownAttrs => ({ alt: readNodeString(node, "alt"), + description: readImageDescription( + readAuthoredDescription(node.attrs), + readNodeString(node, "alt"), + ), referenceLabel: readNodeString(node, REFERENCE_LABEL_ATTRIBUTE_NAME), referenceType: readReferenceType(node.attrs), src: readNodeString(node, "src"), @@ -331,17 +346,14 @@ const isSameImageResolutionInput = ( isSameNullablePath(currentInput.folderContextPath, nextInput.folderContextPath) && currentInput.target === nextInput.target; -const toNodeAttrs = ({ - alt, - referenceLabel, - referenceType, - src, - title, - titleMarker, -}: ImageMarkdownAttrs) => ({ +const toNodeAttrs = ( + { alt, referenceLabel, referenceType, src, title, titleMarker }: ImageMarkdownAttrs, + authoredDescription: string | null, +) => ({ alt, src, title, + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: authoredDescription, [TITLE_MARKER_ATTRIBUTE_NAME]: titleMarker, [REFERENCE_LABEL_ATTRIBUTE_NAME]: referenceLabel, [REFERENCE_TYPE_ATTRIBUTE_NAME]: referenceType, diff --git a/src/features/editor/tests/imageDescription.test.ts b/src/features/editor/tests/imageDescription.test.ts new file mode 100644 index 0000000..4c2457c --- /dev/null +++ b/src/features/editor/tests/imageDescription.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import type { EditorView } from "@milkdown/kit/prose/view"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { createMarkdownReferenceContext } from "@/test/factories/editor"; +import { setupMilkdownEditorMount } from "@/test/utils/milkdown"; +import { mockTauriApiCommand } from "@/test/utils/tauriApi"; + +const mountEditor = setupMilkdownEditorMount(createMarkdownReferenceContext()); + +const LEAF_DEFINITION = '[leaf]: ../assets/leaf.svg "Leaf"'; + +const readImageAttrs = (view: EditorView) => { + const attrs: Record[] = []; + + view.state.doc.descendants((node) => { + if (node.type.name === "image") { + attrs.push(node.attrs); + } + + return true; + }); + + return attrs; +}; + +// The corpus guard sees only a serialization that stops changing, and a flattened description +// converges perfectly, so these read the document a save and a reopen produce as well as the bytes +// the save wrote. +const saveAndReopen = async (source: string) => { + const before = await mountEditor(source); + const saved = before.getMarkdown(); + const after = await mountEditor(saved); + + return { + imageAttrs: readImageAttrs(before.view), + reopened: after.view.state.doc.toJSON() as unknown, + saved, + written: before.view.state.doc.toJSON() as unknown, + }; +}; + +describe("Image descriptions", () => { + beforeEach(() => { + mockTauriApiCommand("resolveMarkdownImageTarget", ({ target }) => ({ + kind: "renderable", + path: `C:/Notes/${target}`, + })); + }); + + it.each([ + String.raw`![Alt with *emphasis* and ` + "`code`" + String.raw`](../assets/leaf.svg)`, + String.raw`![**strong** and ~~strike~~](../assets/leaf.svg)`, + String.raw`![Alt with [a link](./blocks.md) inside](../assets/leaf.svg)`, + String.raw`![*emphasis*](../assets/leaf.svg "Inline")`, + String.raw`[![*Linked* leaf](../assets/leaf.svg)](https://example.com)`, + ])("keeps the inline content an image description holds: %s", async (source) => { + const { reopened, saved, written } = await saveAndReopen(source); + + expect(saved).toBe(`${source}\n`); + expect(reopened).toEqual(written); + }); + + it.each([ + `${LEAF_DEFINITION}\n\n![Alt with *emphasis*][leaf]`, + `${LEAF_DEFINITION}\n\n![*emphasis*][]`, + `${LEAF_DEFINITION}\n\n![*emphasis*]`, + ])("keeps the inline content a reference image description holds: %s", async (source) => { + const { reopened, saved, written } = await saveAndReopen(source); + + expect(saved).toBe(`${source}\n`); + expect(reopened).toEqual(written); + }); + + // The description is source the image carries rather than a document of its own, so the nested + // image reaches the file as it was written without being an image the editor renders. + it("keeps an image nested in another image's description", async () => { + const source = String.raw`![Outer ![inner](../assets/inner.svg)](../assets/leaf.svg)`; + const { imageAttrs, reopened, saved, written } = await saveAndReopen(source); + + expect(saved).toBe(`${source}\n`); + expect(reopened).toEqual(written); + expect(imageAttrs).toEqual([ + expect.objectContaining({ + alt: "Outer inner", + authoredDescription: "Outer ![inner](../assets/inner.svg)", + src: "../assets/leaf.svg", + }), + ]); + }); + + // The rendered image is named by the text its description spells, which is the alt text + // CommonMark derives rather than the source the file holds. + it("names the rendered image by the text its description spells", async () => { + const source = + String.raw`![Alt with *emphasis* and ` + "`code`" + String.raw`](../assets/leaf.svg)`; + const { imageAttrs } = await saveAndReopen(source); + + expect(imageAttrs).toEqual([ + expect.objectContaining({ + alt: "Alt with emphasis and code", + authoredDescription: String.raw`Alt with *emphasis* and ` + "`code`", + }), + ]); + }); + + // Escapes and character references are differences the alt text answers for on its own, so a + // description spelling only those carries no source of its own. + it.each([ + String.raw`![escaped \*not emphasis\*](../assets/leaf.svg)`, + String.raw`![a \[bracket\]](../assets/leaf.svg)`, + String.raw`![plain](../assets/leaf.svg)`, + ])("carries no description source where the alt text spells it: %s", async (source) => { + const { imageAttrs, saved } = await saveAndReopen(source); + + expect(saved).toBe(`${source}\n`); + expect(imageAttrs).toEqual([expect.objectContaining({ authoredDescription: null })]); + }); + + // A code span binds more tightly than the brackets around a description, so a bracket inside one + // ends the run this reading walks before the description ends. The reading is confirmed against + // the destination the node holds, which is what leaves such a description to its text. + it("leaves a description a code span interrupts to the text it spells", async () => { + const source = String.raw`![a ` + "`](x.png)`" + String.raw` b](../assets/leaf.svg)`; + const { imageAttrs, reopened, written } = await saveAndReopen(source); + + expect(imageAttrs).toEqual([ + expect.objectContaining({ authoredDescription: null, src: "../assets/leaf.svg" }), + ]); + expect(reopened).toEqual(written); + }); +}); diff --git a/src/features/editor/tests/sourceProjectionImageLink.test.ts b/src/features/editor/tests/sourceProjectionImageLink.test.ts index c49a86f..ec2518d 100644 --- a/src/features/editor/tests/sourceProjectionImageLink.test.ts +++ b/src/features/editor/tests/sourceProjectionImageLink.test.ts @@ -151,6 +151,24 @@ describe("image link label source projection", () => { expect(mounted.view.state.doc.eq(originalDocument)).toBe(true); }); + // An image in a projected label becomes its own Markdown source, and an image carries the + // description the file holds rather than the text that description spells. + it("projects the description a linked image was written with", async () => { + const source = "[![alt with *emphasis*](./pic.png)](./doc.md)"; + const mounted = await mountProjectionEditor(`${source} tail`); + const originalDocument = mounted.view.state.doc; + + setTextSelection(mounted.view, getEditorNodePosition(mounted, "image")); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe(`${source} tail`); + + setSelectionAtDocumentEnd(mounted.view); + + expect(mounted.view.state.doc.eq(originalDocument)).toBe(true); + expect(mounted.getMarkdown()).toBe(`${source} tail\n`); + }); + it.each([ { committed: "[![altered](./pic.png)](./doc.md) tail\n", diff --git a/src/features/editor/utils/characterReferenceMarkdown.ts b/src/features/editor/utils/characterReferenceMarkdown.ts index 313f89a..449dcf9 100644 --- a/src/features/editor/utils/characterReferenceMarkdown.ts +++ b/src/features/editor/utils/characterReferenceMarkdown.ts @@ -15,6 +15,9 @@ export const CHARACTER_REFERENCE_MARK_NAME = "leafdownCharacterReference"; // A link and an image both carry the destination the author wrote where it differs from the one // the parser decoded, so the mark and the node name it the same way. export const AUTHORED_URL_ATTRIBUTE_NAME = "authoredUrl"; +// An image description holds inline content, and the parser keeps only the text it spells, so the +// node carries the source the description was written with wherever that source says more. +export const AUTHORED_DESCRIPTION_ATTRIBUTE_NAME = "authoredDescription"; export const CHARACTER_REFERENCE_SOURCE_ATTRIBUTE_NAME = "source"; const SOURCE_DOM_ATTRIBUTE_NAME = "data-character-reference"; @@ -314,19 +317,116 @@ export const readAuthoredUrl = (node: object) => { return typeof authored === "string" ? authored : null; }; +interface DescriptionSource { + description: string; + tail: string; +} + +// The description of an image is the run its outer brackets hold, and it is bracket-balanced, so +// its own brackets and a nested image cannot end it early. A code span holding a bracket the +// grammar does not count can end it early here, which is what the tail is returned for. +const findDescriptionSource = (raw: string): DescriptionSource | null => { + if (!raw.startsWith("![")) { + return null; + } + + let depth = 0; + let index = 1; + + for (; index < raw.length; index += 1) { + const character = raw[index]; + + if (character === "\\") { + index += 1; + } else if (character === "[") { + depth += 1; + } else if (character === "]") { + depth -= 1; + + if (depth === 0) { + break; + } + } + } + + return raw[index] === "]" + ? { description: raw.slice(2, index), tail: raw.slice(index + 1) } + : null; +}; + +// The label a reference tail names: the one it holds for a full reference, and the description +// itself for the collapsed and shortcut forms, which spell their label once. +const findReferenceLabelSource = ({ description, tail }: DescriptionSource) => { + if (tail === "" || tail === "[]") { + return description; + } + + return tail.startsWith("[") && tail.endsWith("]") ? tail.slice(1, -1) : null; +}; + +// A description is worth carrying only where it says more than the text the parser kept from it: +// emphasis, inline code, a nested image, or anything else whose markers the alt text drops. +// Escapes and character references are differences the alt text does answer for, and both belong +// to the issues that settled them, so a description spelling only those is left as it is. +const saysMoreThanAlt = (description: string, alt: string) => + decodeCharacterReferences(resolveEscapes(description)) !== alt; + +// The description an inline image was written with, or null where the slice does not spell the +// image the node was built from. The destination the slice names is what confirms the description +// ended where this reading has it end. +export const findAuthoredDescription = (raw: string, alt: string, url: string) => { + const source = findDescriptionSource(raw); + const destination = findDestinationSource(raw); + + if ( + source === null || + destination === null || + decodeCharacterReferences(resolveEscapes(destination)) !== url + ) { + return null; + } + + return saysMoreThanAlt(source.description, alt) ? source.description : null; +}; + +// The description a reference image was written with. A reference names no destination, so the +// label its tail spells confirms the reading instead. +export const findAuthoredReferenceDescription = (raw: string, alt: string, label: string) => { + const source = findDescriptionSource(raw); + + if (source === null) { + return null; + } + + const reference = findReferenceLabelSource(source); + + if (reference === null || decodeCharacterReferences(resolveEscapes(reference)) !== label) { + return null; + } + + return saysMoreThanAlt(source.description, alt) ? source.description : null; +}; + +export const readAuthoredDescription = (node: object) => { + const authored = (node as { authoredDescription?: unknown }).authoredDescription; + + return typeof authored === "string" ? authored : null; +}; + const omitAuthoredAttributes = (attributes: Record) => { const rendered = { ...attributes }; delete rendered[AUTHORED_URL_ATTRIBUTE_NAME]; + delete rendered[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]; delete rendered[TITLE_MARKER_ATTRIBUTE_NAME]; return rendered; }; // An image is a node rather than a mark, so the form it was authored in travels in node attributes. -// The rendered `img` carries neither and no parse rule reads them back, which leaves a copy through -// the DOM holding the decoded destination and a double-quoted title — the same fallback an edit -// inside a reference takes. +// The rendered `img` carries none of them and no parse rule reads them back, which leaves a copy +// through the DOM holding the decoded destination, a double-quoted title, and a description flat +// to its text — the same fallback an edit inside a reference takes. export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { const { toDOM } = schema; @@ -335,6 +435,7 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { attrs: { ...schema.attrs, [AUTHORED_URL_ATTRIBUTE_NAME]: { default: null, validate: "string|null" }, + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: { default: null, validate: "string|null" }, [TITLE_MARKER_ATTRIBUTE_NAME]: { default: '"', validate: "string" }, }, toDOM: @@ -356,6 +457,7 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { alt: (node as { alt?: unknown }).alt, title: node.title, [AUTHORED_URL_ATTRIBUTE_NAME]: readAuthoredUrl(node), + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: readAuthoredDescription(node), [TITLE_MARKER_ATTRIBUTE_NAME]: readTitleMarker(node), }); }, @@ -368,6 +470,7 @@ export const withAuthoredDestination = (schema: NodeSchema): NodeSchema => { url: node.attrs.src, alt: node.attrs.alt, [AUTHORED_URL_ATTRIBUTE_NAME]: node.attrs[AUTHORED_URL_ATTRIBUTE_NAME], + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: node.attrs[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME], [TITLE_MARKER_ATTRIBUTE_NAME]: node.attrs[TITLE_MARKER_ATTRIBUTE_NAME], }); }, diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 8545603..0c14f2d 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -105,6 +105,7 @@ import type { MarkdownLinkContext } from "./linkActivation"; import { serializeMarkdownDefinition, serializeMarkdownImage, + serializeMarkdownImageReference, serializeMarkdownLink, } from "./markdownDestination"; import { @@ -291,6 +292,7 @@ export const createMilkdownEditor = async ({ [RAW_HTML_MARKDOWN_TYPE]: serializeRawHtml, definition: serializeMarkdownDefinition, image: serializeMarkdownImage, + imageReference: serializeMarkdownImageReference, link: serializeMarkdownLink, root: serializeMarkdownRoot, text: serializeMarkdownText, diff --git a/src/features/editor/utils/imageMarkdown.test.ts b/src/features/editor/utils/imageMarkdown.test.ts index ae0edb2..03d60c3 100644 --- a/src/features/editor/utils/imageMarkdown.test.ts +++ b/src/features/editor/utils/imageMarkdown.test.ts @@ -2,16 +2,22 @@ import { describe, expect, it } from "vitest"; import { parseImageMarkdown, + readImageDescription, serializeImageMarkdown, type ImageDefinition, type ImageMarkdownAttrs, } from "./imageMarkdown"; -type InlineImageAttrs = Omit; +type InlineImageAttrs = Omit< + ImageMarkdownAttrs, + "description" | "referenceLabel" | "referenceType" +> & + Partial>; const inlineImageAttrs = (attrs: InlineImageAttrs): ImageMarkdownAttrs => ({ referenceLabel: "", referenceType: null, + description: readImageDescription(null, attrs.alt), ...attrs, }); @@ -161,6 +167,7 @@ describe("imageMarkdown", () => { expect( serializeImageMarkdown({ alt, + description: readImageDescription(null, alt), referenceLabel: "leaf", referenceType, src: LEAF_DEFINITION.src, @@ -181,6 +188,7 @@ describe("imageMarkdown", () => { (markdown, referenceType, alt, referenceLabel) => { expect(parseImageMarkdown(markdown, resolveLeaf)).toEqual({ alt, + description: readImageDescription(null, alt), referenceLabel, referenceType, ...LEAF_DEFINITION, @@ -201,4 +209,50 @@ describe("imageMarkdown", () => { expect(parseImageMarkdown(markdown)).toBeNull(); }, ); + + it.each([ + ["![Alt with *emphasis*](./assets/icon.png)", "Alt with *emphasis*"], + [ + "![Outer ![inner](./assets/inner.png)](./assets/icon.png)", + "Outer ![inner](./assets/inner.png)", + ], + ])("writes the description the document kept as it was written: %s", (expected, description) => { + expect( + serializeImageMarkdown( + inlineImageAttrs({ + alt: "Alt", + description, + src: "./assets/icon.png", + title: "", + titleMarker: '"', + }), + ), + ).toBe(expected); + }); + + // The input holds one image's source rather than a document, so a description typed into it is + // the text it spells and the reference forms compare against that text. + it("reads a typed description as the source it spells", () => { + expect(parseImageMarkdown("![Outer ![inner](./inner.png)](./assets/icon.png)")).toEqual( + inlineImageAttrs({ + alt: "Outer ![inner](./inner.png)", + description: "Outer ![inner](./inner.png)", + src: "./assets/icon.png", + title: "", + titleMarker: '"', + }), + ); + }); + + it("keeps a reference whose description spells its label in the form it was written", () => { + expect( + serializeImageMarkdown({ + alt: "em", + description: "*em*", + referenceLabel: "*em*", + referenceType: "shortcut", + ...LEAF_DEFINITION, + }), + ).toBe("![*em*]"); + }); }); diff --git a/src/features/editor/utils/imageMarkdown.ts b/src/features/editor/utils/imageMarkdown.ts index bafddf5..d63c120 100644 --- a/src/features/editor/utils/imageMarkdown.ts +++ b/src/features/editor/utils/imageMarkdown.ts @@ -3,6 +3,7 @@ import { normalizeReferenceLabel, type ReferenceType } from "./referenceLinkMark export interface ImageMarkdownAttrs { alt: string; + description: string; referenceLabel: string; referenceType: ReferenceType | null; src: string; @@ -18,20 +19,27 @@ export interface ImageDefinition { export type ImageDefinitionResolver = (label: string) => ImageDefinition | null; -// The reference forms collapse to the full one where the alt text no longer spells the label, which -// is the rule the serializer applies to the node itself. -const serializeImageReference = (alt: string, label: string, referenceType: ReferenceType) => { - const serializedAlt = escapeImageAlt(alt); - - if (referenceType === "full" || alt !== label) { - return `![${serializedAlt}][${label}]`; +// The source a description is written with: the one the document kept from the file, and the alt +// text escaped to sit inside the brackets for a description holding nothing the text cannot carry. +export const readImageDescription = (authored: string | null, alt: string) => + authored ?? escapeImageAlt(alt); + +// The reference forms collapse to the full one where the description no longer spells the label, +// which is the rule the serializer applies to the node itself. +const serializeImageReference = ( + description: string, + label: string, + referenceType: ReferenceType, +) => { + if (referenceType === "full" || description !== label) { + return `![${description}][${label}]`; } - return referenceType === "shortcut" ? `![${serializedAlt}]` : `![${serializedAlt}][]`; + return referenceType === "shortcut" ? `![${description}]` : `![${description}][]`; }; export const serializeImageMarkdown = ({ - alt, + description, referenceLabel, referenceType, src, @@ -39,21 +47,20 @@ export const serializeImageMarkdown = ({ titleMarker, }: ImageMarkdownAttrs) => { if (referenceType) { - return serializeImageReference(alt, referenceLabel, referenceType); + return serializeImageReference(description, referenceLabel, referenceType); } - const serializedAlt = escapeImageAlt(alt); const serializedSrc = serializeImageSource(src); if (!title) { - return `![${serializedAlt}](${serializedSrc})`; + return `![${description}](${serializedSrc})`; } const marker = chooseTitleMarker(title, titleMarker); const [opening, closing] = TITLE_MARKER_PAIRS[marker]; const serializedTitle = escapeImageTitle(title, marker); - return `![${serializedAlt}](${serializedSrc} ${opening}${serializedTitle}${closing})`; + return `![${description}](${serializedSrc} ${opening}${serializedTitle}${closing})`; }; export const parseImageMarkdown = ( @@ -72,25 +79,28 @@ export const parseImageMarkdown = ( return null; } - const alt = unescapeMarkdownText(source.slice(2, altEnd)); + const description = source.slice(2, altEnd); + const alt = unescapeMarkdownText(description); const tail = source.slice(altEnd + 1); if (tail.startsWith("(") && source.endsWith(")")) { return { alt, + description, referenceLabel: "", referenceType: null, ...parseImageBody(source.slice(altEnd + 2, -1)), }; } - return parseImageReference(alt, tail, resolveDefinition); + return parseImageReference(alt, description, tail, resolveDefinition); }; // A reference names a destination its definition holds, so a label that resolves to nothing is not // an image and leaves the node as it was. const parseImageReference = ( alt: string, + description: string, tail: string, resolveDefinition: ImageDefinitionResolver, ): ImageMarkdownAttrs | null => { @@ -100,6 +110,7 @@ const parseImageReference = ( return definition ? { alt, + description, referenceLabel: reference.label, referenceType: reference.referenceType, ...definition, @@ -129,7 +140,7 @@ const readImageReferenceTail = (alt: string, tail: string) => { const parseImageBody = ( body: string, -): Omit => { +): Omit => { const trimmedBody = body.trim(); const titleMatch = getTrailingTitle(trimmedBody); diff --git a/src/features/editor/utils/markdownDestination.ts b/src/features/editor/utils/markdownDestination.ts index 8cce425..50dc612 100644 --- a/src/features/editor/utils/markdownDestination.ts +++ b/src/features/editor/utils/markdownDestination.ts @@ -4,6 +4,7 @@ import { defaultHandlers } from "mdast-util-to-markdown"; import { decodeCharacterReferences, findCharacterReferenceSources, + readAuthoredDescription, } from "./characterReferenceMarkdown"; import { withAuthoredTitle } from "./markdownTitle"; @@ -105,6 +106,33 @@ const scopeDestination = ( }; }; +// A description the document carries is inline source rather than text, so it reaches the file as +// it stands instead of through the escaping that would turn its markers into characters. Both +// image handlers write the label before they open the destination or the reference tail, so the +// first value the handler makes safe is the description and every value after it is not. +const scopeDescription = (state: StringifyState, description: string | null) => { + if (description === null) { + return () => {}; + } + + const enclosing = state.safe; + let pending = true; + + state.safe = (value, config) => { + if (!pending) { + return enclosing.call(state, value, config); + } + + pending = false; + + return description; + }; + + return () => { + state.safe = enclosing; + }; +}; + // The authored destination is written where its references still decode to the target the document // holds. Every ampersand in it belongs to a reference the author wrote, so the run reaches the file // as it was authored and reads back as the destination the document carries. @@ -154,6 +182,7 @@ export const serializeMarkdownDefinition: NonNullable = Object.assign( (...[node, parent, state, info]: Parameters) => { const { authored, node: destination } = withAuthoredUrl(node); + const restoreDescription = scopeDescription(state, readAuthoredDescription(node)); const restore = scopeDestination(state, destination.url, authored); try { @@ -162,7 +191,23 @@ export const serializeMarkdownImage: NonNullable = Object.assign( + (...[node, parent, state, info]: Parameters) => { + const restore = scopeDescription(state, readAuthoredDescription(node)); + + try { + return defaultHandlers.imageReference(node, parent, state, info); + } finally { + restore(); + } + }, + { peek: defaultHandlers.imageReference.peek }, +); diff --git a/src/features/editor/utils/referenceLinkMarkdown.ts b/src/features/editor/utils/referenceLinkMarkdown.ts index 22bc05a..80c1641 100644 --- a/src/features/editor/utils/referenceLinkMarkdown.ts +++ b/src/features/editor/utils/referenceLinkMarkdown.ts @@ -1,6 +1,10 @@ import type { MarkdownNode, MarkSchema, NodeSchema } from "@milkdown/kit/transformer"; -import { AUTHORED_URL_ATTRIBUTE_NAME } from "./characterReferenceMarkdown"; +import { + AUTHORED_DESCRIPTION_ATTRIBUTE_NAME, + AUTHORED_URL_ATTRIBUTE_NAME, + readAuthoredDescription, +} from "./characterReferenceMarkdown"; import { chooseTitleMarker, readTitleMarker, @@ -289,6 +293,7 @@ export const withImageReferenceForm = (schema: NodeSchema): NodeSchema => { alt: readString(node, "alt"), title: node.title ?? "", [AUTHORED_URL_ATTRIBUTE_NAME]: null, + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: readAuthoredDescription(node), [TITLE_MARKER_ATTRIBUTE_NAME]: readTitleMarker(node), ...readReferenceAttrs(node), }); @@ -304,6 +309,7 @@ export const withImageReferenceForm = (schema: NodeSchema): NodeSchema => { state.addNode(IMAGE_REFERENCE_MARKDOWN_TYPE, undefined, undefined, { alt: node.attrs.alt, + [AUTHORED_DESCRIPTION_ATTRIBUTE_NAME]: node.attrs[AUTHORED_DESCRIPTION_ATTRIBUTE_NAME], ...getReferenceProps(node.attrs), }); },