summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
committerAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
commit48cdc3d19a319981a8a4128be98db3c283f81790 (patch)
tree0fa6531f3b05c81348f9d3427e9021dbb4832b5d /src/features/markdown/logic
parentdaefad66bdf8dee132d701a3b43ce0d227dde241 (diff)
downloaddispatch-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.ts117
-rw-r--r--src/features/markdown/logic/text-file.ts116
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)",
+ "![image](text-file:notes/a.md)",
+ "[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,
+ };
+}