fix(editor): support backslash-escaping literal #tags

Memos layers #tag syntax on top of CommonMark, but there was no way to
write a literal "#NAS" — it always became a clickable tag (issue #6035).

Handle escapes lexically, the way TipTap/prosemirror-markdown do, with no
"escaped tag" node in the document model:

- Parse: marked's built-in backslash escape already turns "\#NAS" into
  plain text; the read-only renderer (remark-tag) now honors the same
  escape by lexing from the original source slice and falling back to the
  prior value-based parse when it can't faithfully reconstruct the text.
- Serialize: a tag-aware Markdown extension (tagMarkdown.ts) backslash-
  escapes a "#" that would re-parse into a tag, skipping links and code
  where tags never form.
- The editor's #tag lexing moves to the canonical @tiptap/markdown
  markdownTokenizer; tag-in-link skipping moves to a tree pass so the
  editor and renderer agree.

The capped tag-run grammar is consolidated into utils/tag-grammar.ts
(TAG_RUN), shared by the tokenizer, the serialize-escape, and the
renderer so they can't drift. README documents why the markdown manager
is worked around in three places.
pull/6048/head
boojack 2 weeks ago
parent 20c19ef82d
commit 5f7e038aa7

@ -1,93 +1,19 @@
import type { MarkdownToken } from "@tiptap/core";
import { InputRule, Mark, mergeAttributes } from "@tiptap/core";
import type { TokenizerThis, Tokens } from "marked";
import { marked } from "marked";
import { tagStyles } from "@/lib/markdownStyles";
import { MAX_TAG_LENGTH, TAG_CHAR_CLASS } from "@/utils/tag-grammar";
import { TAG_RUN } from "@/utils/tag-grammar";
// Default tag pill, shared with the read-only view (MemoContent/Tag.tsx).
// Computed once — renderHTML runs on every view update.
const TAG_CLASS = `${tagStyles.base} ${tagStyles.defaultColor}`;
// Built from the shared tag grammar (@/utils/tag-grammar) so the editor's
// tokenizer/input rule can't drift from the read-only renderer's lexer
// (web/src/utils/remark-plugins/remark-tag.ts).
const TAG_INPUT_RULE = new RegExp(`(?:^|\\s)#(${TAG_CHAR_CLASS}{1,${MAX_TAG_LENGTH}})\\s$`, "u");
const TAG_TOKEN_RULE = new RegExp(`^#(${TAG_CHAR_CLASS}{1,${MAX_TAG_LENGTH}})`, "u");
// Tests the REMAINDER of the source (not a single code unit) so astral-plane
// tag characters (emoji et al.) are seen whole, not as lone surrogates.
const TAG_CHAR_AHEAD = new RegExp(`^${TAG_CHAR_CLASS}`, "u");
/**
* Tag tokenizer, registered DIRECTLY on the global marked singleton instead of
* through `markdownTokenizer`. Two reasons:
*
* 1. `@tiptap/markdown`'s MarkdownManager wraps `markdownTokenizer.tokenize`
* in `tokenizer(src, tokens) { ... tokenize(src, tokens, helper) }` the
* wrapper receives marked's TokenizerThis (with `lexer.state.inLink`) but
* does NOT forward it, so a manager-registered tokenizer can never know it
* is inside a link label. Registered natively, marked invokes us with
* `this.lexer` bound and we can decline inside `[label](url)` the same way
* remark-tag skips link nodes.
* 2. The manager defaults to the global `marked` export (`markedInstance =
* options?.marked ?? marked`) and `web` resolves the exact same marked
* module instance as `@tiptap/markdown` does, so this registration is
* visible to every lexer the manager creates. Module scope + idempotent:
* registered exactly once per page load (the manager's own per-Editor
* `marked.use` calls are the accumulation hazard documented in
* markdownCodec.ts; this adds a single registration, ever).
*
* The Tag mark below still declares `markdownTokenName: "memoTag"` +
* `parseMarkdown`, which is all the manager needs to route the token.
*/
function tokenizeTag(this: TokenizerThis, src: string): Tokens.Generic | undefined {
// remark-tag skips link nodes entirely; marked sets `state.inLink` while
// tokenizing link/reflink labels, so declining here keeps `[see #x](url)`
// a plain link label instead of tearing the tag out of it.
if (this.lexer?.state?.inLink) {
return undefined;
}
const match = TAG_TOKEN_RULE.exec(src);
if (!match) {
return undefined;
}
const rest = src.slice(match[0].length);
// `#a#b`: decline when the run is directly followed by another `#`, so the
// FIRST run stays plain text. NOT full remark parity — remark-tag treats
// `#a#b` as two tags and `##x` as all-text, while here `#a#b` becomes text
// "#a" + tag "b" and `##x` becomes text "#" + tag "x". The divergence is
// visual-only in the editor: both shapes serialize back byte-identically
// (the surrounding text nodes re-emit their literal characters).
if (rest.startsWith("#")) {
return undefined;
}
// Runs longer than 100 tag characters are not tags at all in remark-tag
// (the whole run stays plain text) — decline instead of splitting the run
// into a 100-char tag plus leftover text.
if (TAG_CHAR_AHEAD.test(rest)) {
return undefined;
}
return { type: "memoTag", raw: match[0], text: match[0], tag: match[1] };
}
let tagTokenizerRegistered = false;
function registerTagTokenizer() {
if (tagTokenizerRegistered) {
return;
}
tagTokenizerRegistered = true;
marked.use({
extensions: [
{
name: "memoTag",
level: "inline",
start: (src: string) => src.indexOf("#"),
tokenizer: tokenizeTag,
},
],
});
}
registerTagTokenizer();
// Built from the shared TAG_RUN (@/utils/tag-grammar) so the editor's input
// rule and tokenizer can't drift from the serialize-escape or the read-only
// renderer's lexer (web/src/utils/remark-plugins/remark-tag.ts). The capped-run
// lookahead in TAG_RUN also makes an over-long run decline to match — no
// separate length check needed.
const TAG_INPUT_RULE = new RegExp(`(?:^|\\s)#(${TAG_RUN})\\s$`, "u");
const TAG_TOKEN_RULE = new RegExp(`^#(${TAG_RUN})`, "u");
/**
* Mark for memos `#tags`: styled in the editor, serialized back to `#tag`
@ -99,8 +25,20 @@ registerTagTokenizer();
* `applyMarkToContent` only attaches enclosing marks to text nodes, so an
* atom inside bold silently drops the bold delimiters.
*
* Parsed live while typing (input rule) and from markdown (the native marked
* tokenizer above).
* Parsed live while typing (the input rule) and from markdown (the
* `markdownTokenizer` below the canonical @tiptap/markdown extension point,
* https://tiptap.dev/docs/editor/markdown/advanced-usage/custom-tokenizer).
*
* Two consequences of going through the manager (vs. registering on `marked`
* directly) are handled in tagMarkdown.ts:
* - The tokenizer can't see whether it sits inside a link label the manager
* forwards only `{ inlineTokens, blockTokens }`, not the lexer's `inLink`
* state so tag-in-link skipping is a tree pass there, mirroring how the
* read-only renderer (remark-tag) skips link nodes.
* - The manager re-registers this tokenizer onto the global `marked` on every
* Editor construction (the accumulation noted in markdownCodec.ts). The
* impact is sub-millisecond for memo-sized content and the test codec is a
* singleton; accepted as the cost of the canonical API.
*/
export const Tag = Mark.create({
name: "tag",
@ -154,6 +92,26 @@ export const Tag = Mark.create({
];
},
markdownTokenizer: {
name: "memoTag",
level: "inline",
start: (src: string) => src.indexOf("#"),
tokenize: (src: string) => {
const match = TAG_TOKEN_RULE.exec(src);
if (!match) {
return undefined;
}
// `#a#b`: decline when the run is directly followed by another `#`, so the
// FIRST run stays plain text "#a" + tag "b" (`##x` likewise → text "#" +
// tag "x"). The serialize-escape then escapes the tag-shaped literal "#a",
// so `#a#b` round-trips as `\#a#b` — doc-equivalent and stable thereafter.
// (An over-long run already declines: TAG_RUN's lookahead fails to match.)
if (src.slice(match[0].length).startsWith("#")) {
return undefined;
}
return { type: "memoTag", raw: match[0], tag: match[1] };
},
},
markdownTokenName: "memoTag",
parseMarkdown: (token, helpers) => {
const t = token as MarkdownToken & { tag?: string };

@ -1,11 +1,11 @@
import { type AnyExtension, mergeAttributes } from "@tiptap/core";
import { Heading } from "@tiptap/extension-heading";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Markdown } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { type HeadingLevel, headingClass, markdownStyles } from "@/lib/markdownStyles";
import { preservedExtensions } from "./PreservedBlock";
import { Tag } from "./Tag";
import { TagAwareMarkdown } from "./tagMarkdown";
/**
* StarterKit's Heading is bundled and cannot vary classes by level via static
@ -46,7 +46,7 @@ export function buildExtensions(): AnyExtension[] {
StyledHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
TaskList,
TaskItem.configure({ nested: true }),
Markdown,
TagAwareMarkdown,
...preservedExtensions,
Tag,
];

@ -0,0 +1,113 @@
import type { JSONContent } from "@tiptap/core";
import { Markdown } from "@tiptap/markdown";
import type { Schema } from "@tiptap/pm/model";
import { TAG_RUN } from "@/utils/tag-grammar";
// A `#` begins a memo tag when a capped TAG_RUN follows it — the same grammar
// the editor tokenizer (Tag.ts) matches, so escape and parse can't disagree.
// The lookahead matches only the `#`, leaving the run itself untouched.
const TAG_HASH = new RegExp(`#(?=${TAG_RUN})`, "gu");
/**
* Backslash-escape a `#` that would otherwise re-parse into a `#tag`.
*
* memos layers hashtag syntax on top of CommonMark, so exactly as
* prosemirror-markdown escapes inline syntax characters like `*` and `_`
* plain text that looks like a tag must be escaped on serialize, or it silently
* becomes a tag on the next parse.
*/
export function escapeTagHashes(text: string): string {
return text.replace(TAG_HASH, "\\#");
}
type JsonMark = string | { type?: string };
type JsonNode = { type?: string; marks?: JsonMark[] };
type PatchableManager = {
encodeTextForMarkdown?: (text: string, node: JsonNode, parentNode?: JsonNode) => string;
parse?: (markdown: string) => JSONContent;
};
const markName = (mark: JsonMark): string => (typeof mark === "string" ? mark : (mark.type ?? ""));
const hasMark = (node: JsonNode, name: string): boolean => (node.marks ?? []).some((mark) => markName(mark) === name);
/**
* A `#tag` only forms in plain inline prose. The tokenizer declines inside
* links, and `code: true` marks/nodes (the Tag mark itself, inline code,
* preserved spans/blocks, code blocks) serialize verbatim so escaping a `#`
* in any of those contexts would be wrong (and would corrupt the byte-identical
* round-trips those constructs rely on).
*/
function escapesTagsHere(schema: Schema, node: JsonNode, parentNode?: JsonNode): boolean {
for (const mark of node.marks ?? []) {
const name = markName(mark);
if (name === "link") {
return false;
}
if (schema.marks[name]?.spec.code) {
return false;
}
}
const parentType = parentNode?.type;
if (parentType && schema.nodes[parentType]?.spec.code) {
return false;
}
return true;
}
/**
* Strip the `tag` mark from any text that also carries a `link` mark.
*
* The `markdownTokenizer` in Tag.ts cannot tell it is inside a link label the
* manager forwards only `{ inlineTokens, blockTokens }`, never the lexer's
* `inLink` state so `[see #x](url)` parses with `#x` tagged. We undo that as a
* tree pass, the same way the read-only renderer (remark-tag) skips link nodes,
* keeping the editor and renderer in agreement: a `#` in a link label is link
* text, never a tag pill.
*/
function stripTagInsideLinks(node: JSONContent): JSONContent {
if (Array.isArray(node.marks) && hasMark(node, "link")) {
node.marks = node.marks.filter((mark) => markName(mark) !== "tag");
}
node.content?.forEach(stripTagInsideLinks);
return node;
}
/**
* The `markdownTokenizer` in Tag.ts handles `#tag` the canonical
* @tiptap/markdown way, but two memos-specific concerns sit above the tokenizer
* and have no public hook, so we compose them onto the manager here:
*
* - serialize: escape a tag-shaped `#` in plain prose ({@link escapeTagHashes})
* so a literal `#NAS` can't silently re-parse into a tag;
* - parse: skip tags inside link labels ({@link stripTagInsideLinks}).
*
* Escapes stay a purely lexical concern the document model carries no
* "escaped tag" node or mark. A literal `#NAS` is just text: parsing strips a
* leading `\` (marked's built-in escape) and serializing adds it back, the same
* way TipTap handles every other escapable character.
*/
export const TagAwareMarkdown = Markdown.extend({
onBeforeCreate(props) {
this.parent?.(props);
const { editor } = this;
const manager = editor.markdown as unknown as PatchableManager | undefined;
if (!manager) {
return;
}
const encode = manager.encodeTextForMarkdown;
if (typeof encode === "function") {
const bound = encode.bind(manager);
manager.encodeTextForMarkdown = (text, node, parentNode) => {
const encoded = bound(text, node, parentNode);
return escapesTagsHere(editor.schema, node, parentNode) ? escapeTagHashes(encoded) : encoded;
};
}
const parse = manager.parse;
if (typeof parse === "function") {
const bound = parse.bind(manager);
manager.parse = (markdown) => stripTagInsideLinks(bound(markdown));
}
},
});

@ -45,7 +45,8 @@ MemoEditor/
│ ├── extensions.ts # Canonical schema-relevant extension set (shared with codec)
│ ├── markdownCodec.ts # Headless parse/serialize helpers (singleton editor)
│ ├── PreservedBlock.ts # Byte-for-byte preservation of tables, math, raw HTML
│ ├── Tag.ts # Memos #tag mark
│ ├── Tag.ts # Memos #tag mark (markdownTokenizer + input rule)
│ ├── tagMarkdown.ts # Tag-aware Markdown: # escape on serialize, link-skip on parse
│ ├── TagSuggestion.ts # # popup for WYSIWYG mode
│ └── suggestionMenu.tsx # Shared suggestion popup renderer (used by TagSuggestion)
├── Toolbar/ # Toolbar sub-components (InsertMenu, VisibilitySelector)
@ -85,7 +86,17 @@ Mode switching is a markdown handoff: because both editors write into `state.con
`PreservedBlock.ts` handles syntax the WYSIWYG editor does not model richly: tables, `$$math$$`, and raw HTML are captured at parse time with their raw markdown source, shown as editable monospace literal text, and re-emitted byte-for-byte on serialize.
`Tag.ts` models memos `#tags` as a `code: true` text mark, letting tags round-trip byte-identically even inside bold or heading spans.
`Tag.ts` models memos `#tags` as a `code: true` text mark, letting tags round-trip byte-identically even inside bold or heading spans. Its `#tag` lexing is a `markdownTokenizer` (the canonical `@tiptap/markdown` extension point); `tagMarkdown.ts` adds the two things that tokenizer can't do — on serialize it backslash-escapes a literal `#` that would otherwise re-parse into a tag (escapes are lexical, so there is no "escaped tag" node), and on parse it strips the tag mark from text inside link labels. The `#tag` grammar itself lives once in `utils/tag-grammar.ts` (`TAG_RUN`), shared by the tokenizer, the serialize-escape, and the read-only `remark-tag` renderer so they can't drift.
### Why the markdown manager is worked around in several places
`@tiptap/markdown` (3.26.0) exposes no public, per-instance hook for custom tokenizers or for text escaping, and it registers each extension's tokenizer onto the **global** `marked` singleton on every `new Editor()` — registrations it never removes. That one limitation is the reason for three otherwise-surprising choices, each documented in detail at its call site:
- **`markdownCodec.ts` keeps a single editor instance** — re-creating editors would leak tokenizer registrations onto global `marked` and measurably degrade parse time.
- **`PreservedBlock.ts` registers its tokenizers on `marked` once at module scope** (idempotent) instead of via the per-extension `markdownTokenizer`, to avoid that per-construction accumulation.
- **`tagMarkdown.ts` composes onto the manager in `onBeforeCreate`** (escape, link-skip) because the relevant manager methods are `private` with no public seam.
`Tag.ts` deliberately uses the canonical `markdownTokenizer` API and accepts the per-construction re-registration as its cost; `PreservedBlock.ts` refuses it for its seven tokenizers. The asymmetry is intentional — collapse both onto one path if upstream ever ships a public per-instance tokenizer hook.
### Suggestions

@ -1,18 +1,64 @@
import type { Root, Text } from "mdast";
import type { Node as UnistNode } from "unist";
import type { Position, Node as UnistNode } from "unist";
import type { TagNode, TagNodeData } from "@/types/markdown";
import { isTagChar, MAX_TAG_LENGTH } from "@/utils/tag-grammar";
function parseTagsFromText(text: string): Array<{ type: "text"; value: string } | { type: "tag"; value: string }> {
const segments: Array<{ type: "text"; value: string } | { type: "tag"; value: string }> = [];
type Segment = { type: "text"; value: string } | { type: "tag"; value: string };
const chars = [...text];
// CommonMark "ASCII punctuation": the only characters a leading backslash can
// escape. A backslash before anything else is a literal backslash.
function isAsciiPunctuation(char: string): boolean {
if (char.length !== 1) {
return false;
}
const code = char.charCodeAt(0);
return (
(code >= 0x21 && code <= 0x2f) || // ! " # $ % & ' ( ) * + , - . /
(code >= 0x3a && code <= 0x40) || // : ; < = > ? @
(code >= 0x5b && code <= 0x60) || // [ \ ] ^ _ `
(code >= 0x7b && code <= 0x7e) // { | } ~
);
}
/**
* Apply CommonMark backslash-unescaping to a raw source slice, tracking which
* resulting characters came from an escape. `\#` yields a `#` flagged escaped,
* so the tag lexer can tell a deliberately-escaped hash from a real tag.
* Returns code points (not UTF-16 units) so astral characters stay intact.
*/
function unescapeBackslashes(source: string): { chars: string[]; escaped: boolean[] } {
const codePoints = [...source];
const chars: string[] = [];
const escaped: boolean[] = [];
for (let i = 0; i < codePoints.length; i++) {
if (codePoints[i] === "\\" && i + 1 < codePoints.length && isAsciiPunctuation(codePoints[i + 1])) {
chars.push(codePoints[i + 1]);
escaped.push(true);
i++;
continue;
}
chars.push(codePoints[i]);
escaped.push(false);
}
return { chars, escaped };
}
/**
* Split a run of characters into text/tag segments. `escaped[i]` marks a
* character that came from a backslash escape: an escaped `#` can never start a
* tag (so `\#NAS` stays literal text), matching the backend goldmark parser and
* the editor's marked tokenizer, both of which honor the escape natively.
*/
function parseSegments(chars: string[], escaped: boolean[]): Segment[] {
const segments: Segment[] = [];
let i = 0;
while (i < chars.length) {
if (chars[i] === "#" && i + 1 < chars.length && isTagChar(chars[i + 1])) {
if (chars[i] === "#" && !escaped[i] && i + 1 < chars.length && isTagChar(chars[i + 1])) {
const prevChar = i > 0 ? chars[i - 1] : "";
const nextChar = i + 1 < chars.length ? chars[i + 1] : "";
const nextChar = chars[i + 1];
if (prevChar === "#" || nextChar === "#" || nextChar === " ") {
segments.push({ type: "text", value: chars[i] });
@ -35,8 +81,9 @@ function parseTagsFromText(text: string): Array<{ type: "text"; value: string }
}
}
// Consume a plain-text run up to the next tag-eligible (non-escaped) hash.
let j = i + 1;
while (j < chars.length && chars[j] !== "#") {
while (j < chars.length && !(chars[j] === "#" && !escaped[j])) {
j++;
}
segments.push({ type: "text", value: chars.slice(i, j).join("") });
@ -46,6 +93,34 @@ function parseTagsFromText(text: string): Array<{ type: "text"; value: string }
return segments;
}
/**
* Segment a text node, preferring the original source slice (where escapes are
* still visible) over the post-escape `value`. The source-derived parse is only
* trusted when it reconstructs `value` byte-for-byte; otherwise entity
* references, missing positions, upstream rewrites we fall back to the
* value-based parse, which is exactly the pre-escape-support behavior. This
* makes escape support strictly additive: it never changes a node it can't
* faithfully account for.
*/
function segmentsForTextNode(value: string, position: Position | undefined, source: string): Segment[] {
const startOffset = position?.start?.offset;
const endOffset = position?.end?.offset;
if (source && startOffset != null && endOffset != null) {
const slice = source.slice(startOffset, endOffset);
const { chars, escaped } = unescapeBackslashes(slice);
if (chars.join("") === value) {
return parseSegments(chars, escaped);
}
}
const chars = [...value];
return parseSegments(
chars,
chars.map(() => false),
);
}
function createTagNode(tagValue: string): TagNode {
const data: TagNodeData = {
hName: "span",
@ -73,14 +148,14 @@ function isLinkNode(node: UnistNode): boolean {
return node.type === "link" || node.type === "linkReference";
}
function transformTagTextNodes(parent: ParentNode, insideLink: boolean): void {
function transformTagTextNodes(parent: ParentNode, insideLink: boolean, source: string): void {
for (let index = 0; index < parent.children.length; index++) {
const child = parent.children[index];
const childInsideLink = insideLink || isLinkNode(child);
if (child.type === "text" && !childInsideLink) {
const textNode = child as Text;
const segments = parseTagsFromText(textNode.value);
const segments = segmentsForTextNode(textNode.value, textNode.position, source);
if (segments.every((seg) => seg.type === "text")) {
continue;
@ -102,13 +177,16 @@ function transformTagTextNodes(parent: ParentNode, insideLink: boolean): void {
}
if (isParentNode(child)) {
transformTagTextNodes(child, childInsideLink);
transformTagTextNodes(child, childInsideLink, source);
}
}
}
type VFileLike = { value?: string | Uint8Array };
export const remarkTag = () => {
return (tree: Root) => {
transformTagTextNodes(tree as ParentNode, false);
return (tree: Root, file: VFileLike) => {
const source = typeof file?.value === "string" ? file.value : "";
transformTagTextNodes(tree as ParentNode, false, source);
};
};

@ -1,7 +1,8 @@
/**
* The single source of truth for memos' `#tag` lexing grammar, shared by the
* editor tokenizer (components/MemoEditor/Editor/Tag.ts) and the read-only
* renderer (utils/remark-plugins/remark-tag.ts) so the two can't drift.
* editor tokenizer + serialize-escape (components/MemoEditor/Editor/Tag.ts and
* tagMarkdown.ts) and the read-only renderer (utils/remark-plugins/remark-tag.ts)
* so they can't drift.
*
* A tag character is any Unicode letter, number, or symbol, plus `_ - / &`.
* A tag run is capped at MAX_TAG_LENGTH characters.
@ -10,6 +11,18 @@ export const TAG_CHAR_CLASS = "[\\p{L}\\p{N}\\p{S}_\\-/&]";
export const MAX_TAG_LENGTH = 100;
/**
* Regex source for a *capped* tag run: 1..MAX_TAG_LENGTH tag characters,
* refusing to match when a (MAX+1)-th tag character would follow (an over-long
* run is not a tag). Embed with the `u` flag.
*
* The two halves of the round-trip build on this same source so they can't
* disagree on what counts as a tag: the editor's input rule + tokenizer match
* `#(${TAG_RUN})`, and the serialize-escape (tagMarkdown.ts) escapes a `#`
* followed by `(?=${TAG_RUN})`.
*/
export const TAG_RUN = `${TAG_CHAR_CLASS}{1,${MAX_TAG_LENGTH}}(?!${TAG_CHAR_CLASS})`;
// Matches exactly one tag character. The `u` flag makes the class match whole
// code points, so astral-plane symbols (emoji et al.) are tested intact rather
// than as lone surrogates.

@ -105,6 +105,43 @@ describe("tag suggestion insertion", () => {
});
});
describe("tag escaping", () => {
// Drives the ProseMirror input-rules plugin the way real typing does:
// someProp("handleTextInput", …) is exactly what the view calls on keystroke.
function typeText(editor: EditorInstance, text: string) {
const { from } = editor.state.selection;
editor.view.someProp("handleTextInput", (handler) => handler(editor.view, from, from, text));
}
it("undoing the tag autoformat leaves a durable literal #tag, not a pill", () => {
const { ref, editor } = setup("");
act(() => {
editor.view.dispatch(editor.state.tr.insertText("#NAS", 1));
});
act(() => typeText(editor, " ")); // tag input rule fires → pill
act(() => {
editor.commands.undoInputRule(); // the standard "I didn't want that" gesture
});
// Back to plain text — and the serializer escapes it so it can't re-tag.
expect(ref.current?.getMarkdown()?.trim()).toBe("\\#NAS");
const para = editor.getJSON().content?.[0];
expect(para?.content?.some((n) => (n.marks ?? []).some((m) => m.type === "tag"))).toBeFalsy();
});
it("a plain #tag still becomes a tag", () => {
const { ref, editor } = setup("");
act(() => {
editor.view.dispatch(editor.state.tr.insertText("#work", 1));
});
act(() => typeText(editor, " "));
expect(ref.current?.getMarkdown()?.trim()).toBe("#work");
const para = editor.getJSON().content?.[0];
expect(para?.content?.[0]).toMatchObject({ text: "#work", marks: [{ type: "tag" }] });
});
});
describe("external content sync", () => {
it("a trim-equal echo of the editor's own output does not reset the document", () => {
const { ref, rerender } = setupRerenderable("hello");

@ -96,9 +96,13 @@ describe("Tag mark", () => {
expect(roundTripMarkdown(input).trim()).toBe(input);
});
it("round-trips ##x and #a#b byte-identically (visual divergence documented in Tag.ts)", () => {
it("round-trips ##x unchanged and normalizes #a#b to \\#a#b (conservative # escaping)", () => {
// "##x" → text "#" + tag "x"; the lone "#" is not tag-shaped, so untouched.
expect(roundTripMarkdown("##x").trim()).toBe("##x");
expect(roundTripMarkdown("#a#b").trim()).toBe("#a#b");
// "#a#b" → text "#a" + tag "b"; the plain "#a" is tag-shaped, so the
// serializer escapes it to keep it literal. Doc-equivalent, stable after.
expect(roundTripMarkdown("#a#b").trim()).toBe("\\#a#b");
expect(roundTripMarkdown("\\#a#b").trim()).toBe("\\#a#b");
});
it("treats a run longer than 100 tag characters as plain text", () => {
@ -109,9 +113,29 @@ describe("Tag mark", () => {
expect(textNodes.some((n) => hasTagMark(n))).toBe(false);
});
it("documents known gap: backslash-escaped \\# degrades after one cycle (upstream escape dropping)", () => {
const out = roundTripMarkdown("\\#escaped");
expect(out.trim()).toBe("#escaped"); // upstream drops the escape (pre-existing); re-parse then sees a tag
expect(roundTripMarkdown(out)).toBe(out); // at least stable from then on
it("keeps a backslash-escaped \\#tag literal and durable across round-trips", () => {
const input = "\\#NAS is my server";
// Escapes are lexical: `\#NAS` parses to ordinary text — no tag mark, no
// bespoke "escaped tag" node — the `\` is simply consumed.
const children = firstParagraphChildren(input);
expect(children.some(hasTagMark)).toBe(false);
expect(children.map((n) => n.text ?? "").join("")).toBe("#NAS is my server");
// The serializer re-escapes the tag-shaped `#`, so it never degrades into a
// tag...
const once = roundTripMarkdown(input).trim();
expect(once).toBe("\\#NAS is my server");
// ...and stays stable on every subsequent cycle.
expect(roundTripMarkdown(once).trim()).toBe(once);
});
it("escapes only the literal tag while still tagging a real one beside it", () => {
const input = "\\#NAS and #real";
expect(roundTripMarkdown(input).trim()).toBe("\\#NAS and #real");
const tagged = firstParagraphChildren(input).filter(hasTagMark);
expect(tagged).toHaveLength(1);
expect(tagged[0]).toMatchObject({ text: "#real", marks: [{ type: "tag", attrs: { tag: "real" } }] });
});
});

@ -51,4 +51,31 @@ describe("remarkTag", () => {
expect(html).toContain('data-tag="urgent"');
expect(html).toContain('data-tag="later"');
});
it("does not turn a backslash-escaped \\#tag into a tag, but still tags an unescaped one", () => {
const html = renderMarkdown("\\#NAS is my server and a #real tag");
// Escaped: rendered as the literal text "#NAS", never a tag pill.
expect(html).not.toContain('data-tag="NAS"');
expect(html).toContain("#NAS");
// Unescaped neighbour is unaffected.
expect(html).toContain('data-tag="real"');
});
it("escapes only the marked hash when escaped and unescaped tags share a node", () => {
const html = renderMarkdown("\\#first then #second");
expect(html).not.toContain('data-tag="first"');
expect(html).toContain("#first");
expect(html).toContain('data-tag="second"');
});
it("still tags a hash that shares a text node with an entity reference", () => {
// The source slice ("...&amp;...") differs from the decoded value, so the
// escape-aware path bows out and the tag is detected the original way.
const html = renderMarkdown("Tom &amp; Jerry #cartoon");
expect(html).toContain('data-tag="cartoon"');
expect(html).toContain("Tom &amp; Jerry");
});
});

Loading…
Cancel
Save