@pmelab/gtd 24.0.0 → 24.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import type { Paragraph } from "mdast"
|
|
3
|
+
import { parseMarkdown } from "./Inline.fixture.js"
|
|
4
|
+
import { collapseImages, definitionsOf, joinInlineRuns, projectInline } from "./Inline.js"
|
|
5
|
+
|
|
6
|
+
const paragraphOf = (content: string): Paragraph => {
|
|
7
|
+
const tree = parseMarkdown(content)
|
|
8
|
+
return tree.children[0] as Paragraph
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("projectInline", () => {
|
|
12
|
+
it("projects strong, emphasis, delete, code, link and image", () => {
|
|
13
|
+
const paragraph = paragraphOf(
|
|
14
|
+
"**bold** _em_ ~~gone~~ `code` [label](https://x.example/a) \n",
|
|
15
|
+
)
|
|
16
|
+
expect(projectInline(paragraph.children)).toEqual([
|
|
17
|
+
{ kind: "strong", children: [{ kind: "text", value: "bold" }] },
|
|
18
|
+
{ kind: "text", value: " " },
|
|
19
|
+
{ kind: "emphasis", children: [{ kind: "text", value: "em" }] },
|
|
20
|
+
{ kind: "text", value: " " },
|
|
21
|
+
{ kind: "delete", children: [{ kind: "text", value: "gone" }] },
|
|
22
|
+
{ kind: "text", value: " " },
|
|
23
|
+
{ kind: "code", value: "code" },
|
|
24
|
+
{ kind: "text", value: " " },
|
|
25
|
+
{
|
|
26
|
+
kind: "link",
|
|
27
|
+
href: "https://x.example/a",
|
|
28
|
+
children: [{ kind: "text", value: "label" }],
|
|
29
|
+
},
|
|
30
|
+
{ kind: "text", value: " " },
|
|
31
|
+
{ kind: "image", src: "https://x.example/b.png", alt: "alt" },
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("drops a footnote reference structurally", () => {
|
|
36
|
+
const paragraph = paragraphOf("See it[^note].\n\n[^note]: body\n")
|
|
37
|
+
const nodes = projectInline(paragraph.children)
|
|
38
|
+
expect(nodes).toEqual([
|
|
39
|
+
{ kind: "text", value: "See it" },
|
|
40
|
+
{ kind: "text", value: "." },
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("projects a hard break as a single space", () => {
|
|
45
|
+
const paragraph = paragraphOf("one\\\ntwo\n")
|
|
46
|
+
expect(projectInline(paragraph.children)).toEqual([
|
|
47
|
+
{ kind: "text", value: "one" },
|
|
48
|
+
{ kind: "text", value: " " },
|
|
49
|
+
{ kind: "text", value: "two" },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("pins 'markdown is never raw HTML': a real <script> tag projects as one text node holding the raw tag, and no inline kind can ever carry HTML", () => {
|
|
54
|
+
const paragraph = paragraphOf("before <script>window.__xss = true</script> after\n")
|
|
55
|
+
const nodes = projectInline(paragraph.children)
|
|
56
|
+
expect(nodes.every((n) => n.kind === "text")).toBe(true)
|
|
57
|
+
const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
|
|
58
|
+
expect(joined).toBe("before <script>window.__xss = true</script> after")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("projects inline HTML as inert text, never a carrying kind", () => {
|
|
62
|
+
const paragraph = paragraphOf("before <script>window.__xss = true</script> after\n")
|
|
63
|
+
const nodes = projectInline(paragraph.children)
|
|
64
|
+
expect(nodes.some((n) => n.kind !== "text")).toBe(false)
|
|
65
|
+
const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
|
|
66
|
+
expect(joined).toContain("<script>window.__xss = true</script>")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("rejects a javascript: link href, keeping the label's own styled children", () => {
|
|
70
|
+
const paragraph = paragraphOf("[click](javascript:alert(1))\n")
|
|
71
|
+
expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "click" }])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("rejects an entity-decoded javascript: link href too — mdast decodes link.url before this ever sees it", () => {
|
|
75
|
+
const paragraph = paragraphOf("[click](javascript:alert(1))\n")
|
|
76
|
+
const nodes = projectInline(paragraph.children)
|
|
77
|
+
expect(nodes.some((n) => n.kind === "link")).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("degrades a rejected image to a text node carrying its alt", () => {
|
|
81
|
+
const paragraph = paragraphOf(")\n")
|
|
82
|
+
expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "alt text" }])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("allows a scheme-less (relative) href/src, and mailto:", () => {
|
|
86
|
+
const paragraph = paragraphOf("[a](./src/x.ts#42) [b](mailto:x@example.com)\n")
|
|
87
|
+
const nodes = projectInline(paragraph.children)
|
|
88
|
+
expect(nodes.filter((n) => n.kind === "link")).toHaveLength(2)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Spec feedback: the WHATWG URL parser strips every ASCII tab/CR/LF from
|
|
92
|
+
// the WHOLE input before it ever looks for a scheme, so a `javascript:`
|
|
93
|
+
// href broken up by one of those characters still executes on tap even
|
|
94
|
+
// though the raw string doesn't match `SCHEME_RE` at all.
|
|
95
|
+
it("rejects a javascript: href broken by an entity-decoded tab", () => {
|
|
96
|
+
const paragraph = paragraphOf("[a](java	script:alert(1))\n")
|
|
97
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("rejects a javascript: href broken by a LEADING entity-decoded tab", () => {
|
|
101
|
+
const paragraph = paragraphOf("[a](	javascript:alert(1))\n")
|
|
102
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("rejects a javascript: href broken by an entity-decoded newline", () => {
|
|
106
|
+
const paragraph = paragraphOf("[a](java
script:alert(1))\n")
|
|
107
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Spec feedback: the WHATWG URL parser ALSO trims every leading (and
|
|
111
|
+
// trailing) C0 control or space off the whole input before it looks for a
|
|
112
|
+
// scheme — a second pass beyond the tab/CR/LF removal above. Missing it
|
|
113
|
+
// leaves a `javascript:` reachable via a leading space, a leading C0
|
|
114
|
+
// control other than tab/CR/LF, or an angle-bracket destination's own
|
|
115
|
+
// literal leading spaces.
|
|
116
|
+
it("rejects a javascript: href prefixed by an entity-decoded leading space", () => {
|
|
117
|
+
const paragraph = paragraphOf("[click]( javascript:alert(1))\n")
|
|
118
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("rejects a javascript: href prefixed by a literal C0 control (U+0001) inside an angle-bracket destination", () => {
|
|
122
|
+
// `` itself decodes to U+FFFD (the replacement character) in this
|
|
123
|
+
// parser, never surviving as literal U+0001 — a real C0 control reaches
|
|
124
|
+
// `link.url` intact only through an angle-bracket destination, which
|
|
125
|
+
// tolerates raw control bytes an entity reference here does not.
|
|
126
|
+
const paragraph = paragraphOf(`[click](<${String.fromCharCode(1)}javascript:alert(1)>)\n`)
|
|
127
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("rejects a javascript: href prefixed by an entity-decoded form feed (U+000C)", () => {
|
|
131
|
+
const paragraph = paragraphOf("[click](javascript:alert(1))\n")
|
|
132
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("rejects a javascript: href prefixed by literal leading spaces inside an angle-bracket destination", () => {
|
|
136
|
+
const paragraph = paragraphOf("[click](< javascript:alert(1)>)\n")
|
|
137
|
+
expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("the same leading-space/C0 hole is closed on image src too — one allowlist, both attributes", () => {
|
|
141
|
+
const paragraph = paragraphOf(")\n")
|
|
142
|
+
expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "alt" }])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Spec feedback: R1 scopes in links/images with no reference-style
|
|
146
|
+
// exclusion, and a rejected/unresolved reference must degrade (keep its
|
|
147
|
+
// own label/alt) exactly like a rejected inline link/image — never vanish.
|
|
148
|
+
it("resolves a linkReference through its own definition, exactly like a bare link", () => {
|
|
149
|
+
const content = "See [the docs][1] here.\n\n[1]: https://example.com\n"
|
|
150
|
+
const paragraph = paragraphOf(content)
|
|
151
|
+
const nodes = projectInline(paragraph.children, definitionsOf(content))
|
|
152
|
+
expect(nodes).toContainEqual({
|
|
153
|
+
kind: "link",
|
|
154
|
+
href: "https://example.com",
|
|
155
|
+
children: [{ kind: "text", value: "the docs" }],
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("degrades an UNRESOLVED linkReference (a real node whose identifier the given definitions map doesn't carry) to its own label text, never dropping it", () => {
|
|
160
|
+
const content = "See [the docs][1] here.\n\n[1]: https://example.com\n"
|
|
161
|
+
const paragraph = paragraphOf(content)
|
|
162
|
+
// No `definitions` argument at all — the default empty map, mirroring a
|
|
163
|
+
// caller that forgot to thread `definitionsOf` through.
|
|
164
|
+
const nodes = projectInline(paragraph.children)
|
|
165
|
+
const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
|
|
166
|
+
expect(joined).toBe("See the docs here.")
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("resolves an imageReference through its own definition, exactly like a bare image", () => {
|
|
170
|
+
const content = "An ![pic][2] there.\n\n[2]: https://example.com/p.png\n"
|
|
171
|
+
const paragraph = paragraphOf(content)
|
|
172
|
+
const nodes = projectInline(paragraph.children, definitionsOf(content))
|
|
173
|
+
expect(nodes).toContainEqual({ kind: "image", src: "https://example.com/p.png", alt: "pic" })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("degrades an UNRESOLVED imageReference (a real node whose identifier the given definitions map doesn't carry) to its own alt text, never dropping it", () => {
|
|
177
|
+
const content = "An ![pic][2] there.\n\n[2]: https://example.com/p.png\n"
|
|
178
|
+
const paragraph = paragraphOf(content)
|
|
179
|
+
const nodes = projectInline(paragraph.children)
|
|
180
|
+
const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
|
|
181
|
+
expect(joined).toBe("An pic there.")
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("rejects a linkReference resolving to a javascript: url, degrading to its own label", () => {
|
|
185
|
+
const content = "See [click][x] here.\n\n[x]: javascript:alert(1)\n"
|
|
186
|
+
const paragraph = paragraphOf(content)
|
|
187
|
+
const nodes = projectInline(paragraph.children, definitionsOf(content))
|
|
188
|
+
expect(nodes.some((n) => n.kind === "link")).toBe(false)
|
|
189
|
+
expect(nodes.map((n) => ("value" in n ? n.value : "")).join("")).toBe("See click here.")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Spec feedback: `stripMarkerText` (the string-flattening path's own
|
|
193
|
+
// regex) strips EVERY `[^name]`-shaped run, defined or not — a dangling
|
|
194
|
+
// marker with no matching definition never becomes a real
|
|
195
|
+
// `footnoteReference` node, so it must be stripped here too, or it leaks
|
|
196
|
+
// into `inline`/`detailInline` for the first time.
|
|
197
|
+
it("strips a DANGLING [^name] marker (no matching definition) from plain text, matching stripMarkerText's outcome", () => {
|
|
198
|
+
const paragraph = paragraphOf("Dangling marker[^n1] here.\n")
|
|
199
|
+
expect(projectInline(paragraph.children)).toEqual([
|
|
200
|
+
{ kind: "text", value: "Dangling marker here." },
|
|
201
|
+
])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe("collapseImages", () => {
|
|
206
|
+
it("replaces an image, at any depth, with a text node carrying its alt", () => {
|
|
207
|
+
const nested = [
|
|
208
|
+
{ kind: "strong" as const, children: [{ kind: "image" as const, src: "x", alt: "pic" }] },
|
|
209
|
+
]
|
|
210
|
+
expect(collapseImages(nested)).toEqual([
|
|
211
|
+
{ kind: "strong", children: [{ kind: "text", value: "pic" }] },
|
|
212
|
+
])
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe("joinInlineRuns", () => {
|
|
217
|
+
it("joins non-empty runs with a single space text separator, dropping empty runs", () => {
|
|
218
|
+
const a = [{ kind: "text" as const, value: "a" }]
|
|
219
|
+
const b: readonly (typeof a)[number][] = []
|
|
220
|
+
const c = [{ kind: "text" as const, value: "c" }]
|
|
221
|
+
expect(joinInlineRuns([a, b, c])).toEqual([
|
|
222
|
+
{ kind: "text", value: "a" },
|
|
223
|
+
{ kind: "text", value: " " },
|
|
224
|
+
{ kind: "text", value: "c" },
|
|
225
|
+
])
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { Definition, Node, PhrasingContent } from "mdast"
|
|
2
|
+
import { parseMarkdown } from "./MarkdownTree.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The server-side projection of a run of mdast inline (phrasing) content —
|
|
6
|
+
* the shape the client's shared renderer (`src/web/screens/InlineRun.tsx`)
|
|
7
|
+
* walks instead of a string. Deliberately closed to these seven kinds: a
|
|
8
|
+
* `footnoteReference` is dropped (see `projectInline`), a hard `break`
|
|
9
|
+
* collapses to a single space `text` node, and an inline `html` node
|
|
10
|
+
* projects as `text` carrying its raw source verbatim — no kind here can
|
|
11
|
+
* ever carry raw HTML, which is what pins "markdown is never raw HTML" at
|
|
12
|
+
* the type level as well as by test.
|
|
13
|
+
*/
|
|
14
|
+
export type InlineNode =
|
|
15
|
+
| { readonly kind: "text"; readonly value: string }
|
|
16
|
+
| { readonly kind: "strong"; readonly children: readonly InlineNode[] }
|
|
17
|
+
| { readonly kind: "emphasis"; readonly children: readonly InlineNode[] }
|
|
18
|
+
| { readonly kind: "delete"; readonly children: readonly InlineNode[] }
|
|
19
|
+
| { readonly kind: "code"; readonly value: string }
|
|
20
|
+
| { readonly kind: "link"; readonly href: string; readonly children: readonly InlineNode[] }
|
|
21
|
+
| { readonly kind: "image"; readonly src: string; readonly alt: string }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `link`'s `href`/an `image`'s `src` passes only when it carries NO scheme
|
|
25
|
+
* at all (relative, `./src/x.ts#42`, a bare fragment) or a scheme matching
|
|
26
|
+
* `https?`/`mailto` case-insensitively — one allowlist for both attributes,
|
|
27
|
+
* no per-attribute branch. `url` here is already entity-decoded by mdast
|
|
28
|
+
* (`link.url`/`image.url`), which is exactly what makes this check catch
|
|
29
|
+
* `javascript:` too — a client-side regex over raw source never sees
|
|
30
|
+
* the decoded form at all.
|
|
31
|
+
*/
|
|
32
|
+
const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/
|
|
33
|
+
const ALLOWED_SCHEME_RE = /^(https?|mailto)$/i
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mirrors the two whitespace-removal passes the WHATWG URL parser — what a
|
|
37
|
+
* real `<a href>`/`<img src>` actually runs through — applies to the WHOLE
|
|
38
|
+
* input before it ever looks for a scheme: every ASCII tab/LF/CR is removed
|
|
39
|
+
* from anywhere in the string, and every LEADING (and trailing) C0 control
|
|
40
|
+
* (`U+0000`–`U+001F`) or space is trimmed off the ends. Skipping either pass
|
|
41
|
+
* leaves a `javascript:` reachable here: `java	script:alert(1)` needs the
|
|
42
|
+
* first, a leading ` `/``/form-feed-prefixed `javascript:` (or an
|
|
43
|
+
* angle-bracket destination's own literal leading spaces) needs the second —
|
|
44
|
+
* both read as "no scheme, so allowed" here while the browser reads the
|
|
45
|
+
* stripped `javascript:alert(1)` and executes it.
|
|
46
|
+
*/
|
|
47
|
+
const stripUrlWhitespace = (url: string): string =>
|
|
48
|
+
url
|
|
49
|
+
.replace(/[\t\n\r]/g, "")
|
|
50
|
+
// eslint-disable-next-line no-control-regex -- the C0 control range IS the point: mirrors the WHATWG URL parser's own leading/trailing trim.
|
|
51
|
+
.replace(/^[\x00-\x1f ]+|[\x00-\x1f ]+$/g, "")
|
|
52
|
+
|
|
53
|
+
const isAllowedUrl = (url: string): boolean => {
|
|
54
|
+
const match = SCHEME_RE.exec(stripUrlWhitespace(url))
|
|
55
|
+
return match === undefined || match === null || ALLOWED_SCHEME_RE.test(match[1]!)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A marker's shape once it's plain text — `[^name]`, no whitespace, no `]` —
|
|
60
|
+
* mirrors `Blocks.ts`'s own `MARKER_TEXT_RE` exactly (duplicated, not
|
|
61
|
+
* imported: that one is private to the string-flattening path this module
|
|
62
|
+
* doesn't otherwise touch). GFM only turns `[^name]` into a real
|
|
63
|
+
* `footnoteReference` NODE when a matching definition exists; a dangling
|
|
64
|
+
* marker with no definition never becomes one, so it survives as plain
|
|
65
|
+
* `text` — this strips it there too, so `inline`/`detailInline` drop a
|
|
66
|
+
* dangling marker the same way `stripMarkerText` already drops it from
|
|
67
|
+
* `title`/`detail`, instead of leaking it into the UI for the first time.
|
|
68
|
+
*/
|
|
69
|
+
const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One `identifier` (mdast's own normalized form: markdown whitespace
|
|
73
|
+
* collapsed, trimmed, case-folded) → the `definition` node it resolves to —
|
|
74
|
+
* what a `linkReference`/`imageReference` needs to become a real `link`/
|
|
75
|
+
* `image` instead of silently losing its own visible label/alt. Walks the
|
|
76
|
+
* WHOLE tree, not just `tree.children`: a definition can sit anywhere
|
|
77
|
+
* CommonMark allows one, not only at the document's top level.
|
|
78
|
+
*/
|
|
79
|
+
const collectDefinitions = (node: Node, into: Map<string, Definition>): void => {
|
|
80
|
+
if (node.type === "definition") into.set((node as Definition).identifier, node as Definition)
|
|
81
|
+
const children = (node as { children?: readonly Node[] }).children
|
|
82
|
+
if (children) children.forEach((child) => collectDefinitions(child, into))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every link/image reference definition in `content`, by normalized identifier — built off `parseMarkdown`'s own memoized tree, so calling this alongside another same-content call in the same pass never re-parses. */
|
|
86
|
+
export const definitionsOf = (content: string): ReadonlyMap<string, Definition> => {
|
|
87
|
+
const map = new Map<string, Definition>()
|
|
88
|
+
collectDefinitions(parseMarkdown(content), map)
|
|
89
|
+
return map
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Definitions = ReadonlyMap<string, Definition>
|
|
93
|
+
|
|
94
|
+
/** A resolved `link`/`linkReference` — real `href` (already allowlist-checked) plus its own label children — or `undefined` when there is none to resolve to (no url at all, or an unresolved reference). */
|
|
95
|
+
const resolveLink = (
|
|
96
|
+
url: string | undefined,
|
|
97
|
+
labelChildren: readonly PhrasingContent[],
|
|
98
|
+
definitions: Definitions,
|
|
99
|
+
): readonly InlineNode[] => {
|
|
100
|
+
const children = projectInline(labelChildren, definitions)
|
|
101
|
+
if (url !== undefined && isAllowedUrl(url)) return [{ kind: "link", href: url, children }]
|
|
102
|
+
return children
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A resolved `image`/`imageReference` — real `src` (already allowlist-checked) plus `alt` — or a degrade to plain `alt` text when there is nothing to resolve to. */
|
|
106
|
+
const resolveImage = (url: string | undefined, alt: string): readonly InlineNode[] =>
|
|
107
|
+
url !== undefined && isAllowedUrl(url)
|
|
108
|
+
? [{ kind: "image", src: url, alt }]
|
|
109
|
+
: [{ kind: "text", value: alt }]
|
|
110
|
+
|
|
111
|
+
/** One recursive `children: readonly InlineNode[]` wrap — `strong`/`emphasis`/`delete` share this exact shape. */
|
|
112
|
+
const projectWrapped = (
|
|
113
|
+
kind: "strong" | "emphasis" | "delete",
|
|
114
|
+
children: readonly PhrasingContent[],
|
|
115
|
+
definitions: Definitions,
|
|
116
|
+
): readonly InlineNode[] => [{ kind, children: projectInline(children, definitions) }]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One `PhrasingContent["type"]` → the `InlineNode`s it projects to (0, 1, or
|
|
120
|
+
* — for a rejected/unresolved link — its own several label children) — a
|
|
121
|
+
* lookup table, not a `switch`, so `projectInline` itself stays a single
|
|
122
|
+
* dispatch with no per-case branch of its own (mirrors `ProseBlock.tsx`'s
|
|
123
|
+
* own `BLOCK_RENDERERS`/`InlineRun.tsx`'s own `INLINE_RENDERERS`). A kind
|
|
124
|
+
* with no entry (there is none left; every `PhrasingContent` member is
|
|
125
|
+
* covered) projects to nothing.
|
|
126
|
+
*/
|
|
127
|
+
const NODE_PROJECTORS: {
|
|
128
|
+
readonly [K in PhrasingContent["type"]]: (
|
|
129
|
+
child: Extract<PhrasingContent, { readonly type: K }>,
|
|
130
|
+
definitions: Definitions,
|
|
131
|
+
) => readonly InlineNode[]
|
|
132
|
+
} = {
|
|
133
|
+
text: (child) => [{ kind: "text", value: child.value.replace(MARKER_TEXT_RE, "") }],
|
|
134
|
+
strong: (child, definitions) => projectWrapped("strong", child.children, definitions),
|
|
135
|
+
emphasis: (child, definitions) => projectWrapped("emphasis", child.children, definitions),
|
|
136
|
+
delete: (child, definitions) => projectWrapped("delete", child.children, definitions),
|
|
137
|
+
inlineCode: (child) => [{ kind: "code", value: child.value }],
|
|
138
|
+
break: () => [{ kind: "text", value: " " }],
|
|
139
|
+
html: (child) => [{ kind: "text", value: child.value }],
|
|
140
|
+
footnoteReference: () => [],
|
|
141
|
+
link: (child, definitions) => resolveLink(child.url, child.children, definitions),
|
|
142
|
+
image: (child) => resolveImage(child.url, child.alt ?? ""),
|
|
143
|
+
linkReference: (child, definitions) =>
|
|
144
|
+
resolveLink(definitions.get(child.identifier)?.url, child.children, definitions),
|
|
145
|
+
imageReference: (child, definitions) =>
|
|
146
|
+
resolveImage(definitions.get(child.identifier)?.url, child.alt ?? ""),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Projects one run of mdast `PhrasingContent` (a paragraph's/heading's own
|
|
151
|
+
* `children`, or any other phrasing-content array) into `InlineNode`s.
|
|
152
|
+
* `footnoteReference` is dropped outright — gtd's own review-note protocol,
|
|
153
|
+
* never document content — and a rejected `link`/`image` degrades rather
|
|
154
|
+
* than disappearing: a rejected link keeps its own (still-styled) label
|
|
155
|
+
* children, a rejected image keeps only its alt text. An inline `html` node
|
|
156
|
+
* (a real tag typed into prose) becomes a `text` node carrying its raw
|
|
157
|
+
* source verbatim — never parsed, never a carrier for markup in the model.
|
|
158
|
+
*
|
|
159
|
+
* `definitions` (`definitionsOf`) resolves a `linkReference`/`imageReference`
|
|
160
|
+
* (`[label][id]`/`![alt][id]`) to its own `link`/`image` the same way a
|
|
161
|
+
* bare `[label](url)` resolves — an UNRESOLVED reference (no matching
|
|
162
|
+
* definition, or the caller passed none) degrades exactly like a
|
|
163
|
+
* scheme-rejected one: the label's own children for a link, bare alt text
|
|
164
|
+
* for an image — never silently dropped.
|
|
165
|
+
*/
|
|
166
|
+
export const projectInline = (
|
|
167
|
+
children: readonly PhrasingContent[],
|
|
168
|
+
definitions: Definitions = new Map(),
|
|
169
|
+
): readonly InlineNode[] =>
|
|
170
|
+
children.flatMap((child) => {
|
|
171
|
+
const project = NODE_PROJECTORS[child.type] as (
|
|
172
|
+
child: PhrasingContent,
|
|
173
|
+
definitions: Definitions,
|
|
174
|
+
) => readonly InlineNode[]
|
|
175
|
+
return project(child, definitions)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
/** `nodes`, recursively, with every `image` replaced by a `text` node carrying its alt — the compact-row rule (R2): an image collapses to alt text server-side so a compact row can never fire a remote fetch. */
|
|
179
|
+
export const collapseImages = (nodes: readonly InlineNode[]): readonly InlineNode[] =>
|
|
180
|
+
nodes.map((node) => {
|
|
181
|
+
if (node.kind === "image") return { kind: "text" as const, value: node.alt }
|
|
182
|
+
if ("children" in node) return { ...node, children: collapseImages(node.children) }
|
|
183
|
+
return node
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/** Joins several already-projected inline runs with a single `text: " "` separator, dropping empty runs entirely — mirrors `Blocks.ts`'s own `.join(" ")` flattening, structurally rather than on strings. */
|
|
187
|
+
export const joinInlineRuns = (runs: readonly (readonly InlineNode[])[]): readonly InlineNode[] => {
|
|
188
|
+
const nonEmpty = runs.filter((run) => run.length > 0)
|
|
189
|
+
const result: InlineNode[] = []
|
|
190
|
+
nonEmpty.forEach((run, index) => {
|
|
191
|
+
if (index > 0) result.push({ kind: "text", value: " " })
|
|
192
|
+
result.push(...run)
|
|
193
|
+
})
|
|
194
|
+
return result
|
|
195
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import fc from "fast-check"
|
|
3
|
+
import {
|
|
4
|
+
blockNodeAt,
|
|
5
|
+
getParseCount,
|
|
6
|
+
headingSectionBodies,
|
|
7
|
+
headingSections,
|
|
8
|
+
headingText,
|
|
9
|
+
parseMarkdown,
|
|
10
|
+
sourceText,
|
|
11
|
+
taskItems,
|
|
12
|
+
toLspPosition,
|
|
13
|
+
toLspPositionFromOffset,
|
|
14
|
+
} from "./MarkdownTree.js"
|
|
15
|
+
import { steeringFormatFor } from "./index.js"
|
|
16
|
+
import type { Heading, List, ListItem, Paragraph } from "mdast"
|
|
17
|
+
|
|
18
|
+
describe("parseMarkdown", () => {
|
|
19
|
+
it("calls both GFM extensions: a task checkbox parses to checked === true, not null", () => {
|
|
20
|
+
const tree = parseMarkdown("- [x] a\n")
|
|
21
|
+
const list = tree.children[0] as List
|
|
22
|
+
const item = list.children[0] as ListItem
|
|
23
|
+
expect(item.checked).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("parses strikethrough and a bare-URL autolink into real nodes", () => {
|
|
27
|
+
const tree = parseMarkdown("~~gone~~ and https://example.com/x\n")
|
|
28
|
+
const paragraph = tree.children[0] as Paragraph
|
|
29
|
+
expect(paragraph.children.some((c) => c.type === "delete")).toBe(true)
|
|
30
|
+
expect(
|
|
31
|
+
paragraph.children.some((c) => c.type === "link" && c.url === "https://example.com/x"),
|
|
32
|
+
).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("wiring the two new extensions leaves every flattened string byte-identical — sourceText and headingText slice raw source, not the parsed tree", () => {
|
|
36
|
+
const content =
|
|
37
|
+
"# A ~~gone~~ heading https://example.com/x\n\nSome ~~gone~~ text https://example.com/x here.\n"
|
|
38
|
+
const tree = parseMarkdown(content)
|
|
39
|
+
const heading = tree.children[0] as Heading
|
|
40
|
+
const paragraph = tree.children[1]!
|
|
41
|
+
expect(headingText(content, heading)).toBe("A ~~gone~~ heading https://example.com/x")
|
|
42
|
+
expect(sourceText(content, paragraph)).toBe("Some ~~gone~~ text https://example.com/x here.")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("never throws and always returns a root, for arbitrary strings", () => {
|
|
46
|
+
fc.assert(
|
|
47
|
+
fc.property(fc.string(), (content) => {
|
|
48
|
+
const tree = parseMarkdown(content)
|
|
49
|
+
expect(tree.type).toBe("root")
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("converts a node starting at source line 1 column 1 to LSP line 0 character 0", () => {
|
|
55
|
+
const tree = parseMarkdown("hello\n")
|
|
56
|
+
const paragraph = tree.children[0]!
|
|
57
|
+
// Sanity: mdast itself is 1-based — if this ever isn't true, the real
|
|
58
|
+
// assertion below (going through `toLspPosition`) would trivially pass
|
|
59
|
+
// for the wrong reason.
|
|
60
|
+
expect(paragraph.position!.start).toEqual({ line: 1, column: 1, offset: 0 })
|
|
61
|
+
expect(toLspPosition(paragraph.position!.start)).toEqual({ line: 0, character: 0 })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("memo", () => {
|
|
65
|
+
it("returns the identical tree object for two calls with the same string, advancing the counter once", () => {
|
|
66
|
+
const content = `unique content ${Math.PI}`
|
|
67
|
+
const before = getParseCount()
|
|
68
|
+
const first = parseMarkdown(content)
|
|
69
|
+
const second = parseMarkdown(content)
|
|
70
|
+
expect(second).toBe(first)
|
|
71
|
+
expect(getParseCount()).toBe(before + 1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("evicts the previous entry for a different string, advancing the counter again", () => {
|
|
75
|
+
const a = `content a ${Math.PI}`
|
|
76
|
+
const b = `content b ${Math.PI}`
|
|
77
|
+
const treeA = parseMarkdown(a)
|
|
78
|
+
const before = getParseCount()
|
|
79
|
+
const treeB = parseMarkdown(b)
|
|
80
|
+
expect(treeB).not.toBe(treeA)
|
|
81
|
+
expect(getParseCount()).toBe(before + 1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("holds one entry, not two: re-parsing the evicted string reparses rather than serving a stale cache hit", () => {
|
|
85
|
+
const a = `content c ${Math.PI}`
|
|
86
|
+
const b = `content d ${Math.PI}`
|
|
87
|
+
const firstA = parseMarkdown(a)
|
|
88
|
+
parseMarkdown(b) // evicts `a`
|
|
89
|
+
const before = getParseCount()
|
|
90
|
+
const secondA = parseMarkdown(a)
|
|
91
|
+
expect(getParseCount()).toBe(before + 1) // reparsed, not a cache hit
|
|
92
|
+
expect(secondA).not.toBe(firstA) // a fresh tree object, not the old one
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("performs one parse for a 2000-line document, not one per line", () => {
|
|
96
|
+
const content = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join("\n")
|
|
97
|
+
const before = getParseCount()
|
|
98
|
+
parseMarkdown(content)
|
|
99
|
+
expect(getParseCount()).toBe(before + 1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("performs exactly one parse across one qa validate() call — the structural pass and the footnote pass share it", () => {
|
|
103
|
+
const content = [
|
|
104
|
+
"Build a thing.",
|
|
105
|
+
...Array.from({ length: 1990 }, (_, i) => `Line ${i} of padding prose.`),
|
|
106
|
+
"",
|
|
107
|
+
"## Open Questions",
|
|
108
|
+
"",
|
|
109
|
+
"### Which backend?",
|
|
110
|
+
"",
|
|
111
|
+
"- [x] SQLite",
|
|
112
|
+
"- [ ] Postgres",
|
|
113
|
+
"",
|
|
114
|
+
].join("\n")
|
|
115
|
+
const before = getParseCount()
|
|
116
|
+
steeringFormatFor("qa")!.validate(content)
|
|
117
|
+
expect(getParseCount()).toBe(before + 1)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("toLspPositionFromOffset", () => {
|
|
123
|
+
it("is line 0 character 0 for offset 0", () => {
|
|
124
|
+
expect(toLspPositionFromOffset("abc\ndef", 0)).toEqual({ line: 0, character: 0 })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("lands on the second line after a newline", () => {
|
|
128
|
+
const content = "abc\ndef"
|
|
129
|
+
expect(toLspPositionFromOffset(content, content.indexOf("def"))).toEqual({
|
|
130
|
+
line: 1,
|
|
131
|
+
character: 0,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("counts characters within a later line correctly", () => {
|
|
136
|
+
const content = "abc\ndefgh"
|
|
137
|
+
expect(toLspPositionFromOffset(content, content.indexOf("gh"))).toEqual({
|
|
138
|
+
line: 1,
|
|
139
|
+
character: 3,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe("sourceText", () => {
|
|
145
|
+
it("keeps inline code verbatim and drops only a real footnote reference", () => {
|
|
146
|
+
const content = "A claim `a[^fn1]b` and a real [^fn2] ref.\n\n[^fn2]: def\n"
|
|
147
|
+
const tree = parseMarkdown(content)
|
|
148
|
+
const paragraph = tree.children[0] as Paragraph
|
|
149
|
+
expect(sourceText(content, paragraph)).toBe("A claim `a[^fn1]b` and a real ref.")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("collapses a multi-line paragraph's whitespace to single spaces", () => {
|
|
153
|
+
const content = "one\ntwo\nthree\n"
|
|
154
|
+
const tree = parseMarkdown(content)
|
|
155
|
+
expect(sourceText(content, tree.children[0]!)).toBe("one two three")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("keeps a link and emphasis verbatim, alongside a dropped footnote reference", () => {
|
|
159
|
+
const content = "See [the docs](https://example.com) for *why*[^fn1].\n\n[^fn1]: def\n"
|
|
160
|
+
const tree = parseMarkdown(content)
|
|
161
|
+
const paragraph = tree.children[0] as Paragraph
|
|
162
|
+
expect(sourceText(content, paragraph)).toBe("See [the docs](https://example.com) for *why*.")
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe("blockNodeAt", () => {
|
|
167
|
+
it("finds the top-level block containing a 0-based line", () => {
|
|
168
|
+
const content = "# heading\n\nsome paragraph text\n"
|
|
169
|
+
const tree = parseMarkdown(content)
|
|
170
|
+
expect(blockNodeAt(tree, 0)?.type).toBe("heading")
|
|
171
|
+
expect(blockNodeAt(tree, 2)?.type).toBe("paragraph")
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("is undefined past the end of the document", () => {
|
|
175
|
+
const tree = parseMarkdown("one line\n")
|
|
176
|
+
expect(blockNodeAt(tree, 50)).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe("taskItems", () => {
|
|
181
|
+
it("returns both items for a nested two-space-indented task list item", () => {
|
|
182
|
+
const content = "- [x] a\n - [x] b\n"
|
|
183
|
+
const tree = parseMarkdown(content)
|
|
184
|
+
const items = taskItems(tree)
|
|
185
|
+
expect(items).toHaveLength(2)
|
|
186
|
+
expect(items.every((i) => i.checked === true)).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("excludes ordinary (non-task) list items", () => {
|
|
190
|
+
const content = "- a\n- [x] b\n"
|
|
191
|
+
const tree = parseMarkdown(content)
|
|
192
|
+
expect(taskItems(tree)).toHaveLength(1)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe("headingSections", () => {
|
|
197
|
+
it("returns every top-level `## ` heading's text, in document order", () => {
|
|
198
|
+
const content = "# Title\n\n## First\n\nbody\n\n## Second\n\nmore body\n"
|
|
199
|
+
expect(headingSections(content)).toEqual(["First", "Second"])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("excludes a `# ` (depth 1) and a `### ` (depth 3) heading — depth 2 only, by default", () => {
|
|
203
|
+
const content = "# Top\n\n## Section\n\n### Nested\n"
|
|
204
|
+
expect(headingSections(content)).toEqual(["Section"])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("is empty for a package file with no `## ` sections — the fail-open case a dynamic-count judge template must detect", () => {
|
|
208
|
+
expect(headingSections("# Title\n\njust prose, no sections\n")).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("takes an explicit depth", () => {
|
|
212
|
+
const content = "## Two\n\n### Three\n"
|
|
213
|
+
expect(headingSections(content, 3)).toEqual(["Three"])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe("headingSectionBodies", () => {
|
|
218
|
+
it("finds headings the way the parser does — doubled spaces, a closing run, indentation", () => {
|
|
219
|
+
const content =
|
|
220
|
+
"## Spaced Title\nspaced body\n\n## Closed ##\nclosed body\n\n ## Indented\nindented body\n"
|
|
221
|
+
expect(headingSectionBodies(content)).toEqual([
|
|
222
|
+
{ title: "Spaced Title", body: "## Spaced Title\nspaced body" },
|
|
223
|
+
{ title: "Closed", body: "## Closed ##\nclosed body" },
|
|
224
|
+
{ title: "Indented", body: " ## Indented\nindented body" },
|
|
225
|
+
])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it("never ends a section at a plain line that happens to equal a title", () => {
|
|
229
|
+
const content = "## Plan\nPlan\nstill the plan\n\n## Next\nnext body\n"
|
|
230
|
+
expect(headingSectionBodies(content)[0]!.body).toBe("## Plan\nPlan\nstill the plan")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("keeps two same-titled sections apart", () => {
|
|
234
|
+
const content = "## Note\nfirst\n\n## Note\nsecond\n"
|
|
235
|
+
expect(headingSectionBodies(content).map((s) => s.body)).toEqual([
|
|
236
|
+
"## Note\nfirst",
|
|
237
|
+
"## Note\nsecond",
|
|
238
|
+
])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("ends a section at a higher-level heading, and ignores a heading inside a code fence", () => {
|
|
242
|
+
const content = "## One\n```\n## not a heading\n```\n# Top\ntop body\n"
|
|
243
|
+
expect(headingSectionBodies(content)).toEqual([
|
|
244
|
+
{ title: "One", body: "## One\n```\n## not a heading\n```" },
|
|
245
|
+
])
|
|
246
|
+
})
|
|
247
|
+
})
|