diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 18:34:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 18:34:12 +0900 |
| commit | 48cdc3d19a319981a8a4128be98db3c283f81790 (patch) | |
| tree | 0fa6531f3b05c81348f9d3427e9021dbb4832b5d /src/features/markdown/logic | |
| parent | daefad66bdf8dee132d701a3b43ce0d227dde241 (diff) | |
| download | dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.tar.gz dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.zip | |
feat: render live text-file embeds in expandable Markdown blocks
Show rendered first/last six-line previews with expand, refresh and retry; bound and deduplicate context-guarded fetches. Verify 2586 tests twice with Node Web Storage compatibility flag, 2 live integration tests, typecheck, Biome and production build.
Diffstat (limited to 'src/features/markdown/logic')
| -rw-r--r-- | src/features/markdown/logic/text-file.test.ts | 117 | ||||
| -rw-r--r-- | src/features/markdown/logic/text-file.ts | 116 |
2 files changed, 233 insertions, 0 deletions
diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "<div data-text-file='a.md'>Plan</div>", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} |
