@gr8ful/spf 0.17.0 → 0.19.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/assets/skill/references/config.md +6 -3
- package/assets/skill/references/observability.md +115 -1
- package/dist/cli/index.js +6 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +39 -1
- package/dist/core/agent_flue.js +26 -0
- package/dist/core/agent_opencode.d.ts +105 -3
- package/dist/core/agent_opencode.js +169 -17
- package/dist/core/agents.d.ts +6 -0
- package/dist/core/agents.js +22 -0
- package/dist/core/data_types.d.ts +60 -0
- package/dist/core/data_types.js +69 -0
- package/dist/core/issues/jira_provider.d.ts +7 -2
- package/dist/core/issues/jira_provider.js +20 -17
- package/dist/core/issues/markdown_adf.d.ts +58 -0
- package/dist/core/issues/markdown_adf.js +705 -0
- package/dist/core/otel.d.ts +182 -48
- package/dist/core/otel.js +373 -158
- package/dist/core/otel_metrics.d.ts +127 -0
- package/dist/core/otel_metrics.js +221 -0
- package/dist/core/otel_propagation.d.ts +159 -0
- package/dist/core/otel_propagation.js +225 -0
- package/package.json +15 -2
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hand-rolled Markdown <-> Atlassian Document Format (ADF) converter for
|
|
3
|
+
* Jira comment/description bodies — deliberately NOT built on a markdown
|
|
4
|
+
* parsing library. This project has zero markdown dependencies today and
|
|
5
|
+
* that's a deliberate choice (see `package.json`): `jira_provider.ts`'s own
|
|
6
|
+
* `toAdf`/`adfToText` pair is the existing precedent (one paragraph of
|
|
7
|
+
* plain text, nothing richer), and this module is the same philosophy
|
|
8
|
+
* extended to a small, explicitly-bounded Markdown subset, not a general
|
|
9
|
+
* CommonMark implementation.
|
|
10
|
+
*
|
|
11
|
+
* SUPPORTED (confirmed scope, see the module's originating task — do not
|
|
12
|
+
* silently grow this list): ATX headings (`#`..`######`), bold
|
|
13
|
+
* (`**x**`/`__x__`), italic (`*x*`/`_x_`), strikethrough (`~~x~~`), inline
|
|
14
|
+
* code (`` `x` ``), fenced code blocks with optional language, bullet
|
|
15
|
+
* (`-`/`*`/`+`) and ordered (`1.`) lists with ONE level of nesting,
|
|
16
|
+
* blockquotes (consecutive `>` lines as one block), horizontal rules
|
|
17
|
+
* (`---`/`***`/`___` alone on a line), links (`[text](url)`), paragraphs
|
|
18
|
+
* separated by blank lines, and hard line breaks (a line ending in two-plus
|
|
19
|
+
* trailing spaces, or a lone trailing backslash).
|
|
20
|
+
*
|
|
21
|
+
* OUT OF SCOPE (tables, images, @mentions, raw HTML, nested blockquotes-in-
|
|
22
|
+
* lists, >1 level of list nesting): never crashes and never corrupts
|
|
23
|
+
* structure on these — `markdownToAdf` falls through to literal text in a
|
|
24
|
+
* plain paragraph (matching `toAdf`'s existing behavior for "everything"
|
|
25
|
+
* before this module existed), and `adfToMarkdown` degrades any node/mark
|
|
26
|
+
* type it doesn't recognize to its nested text content. `markdownToAdf`
|
|
27
|
+
* must NEVER throw on any input string — a malformed fence, an unmatched
|
|
28
|
+
* `**`, an empty link target, all degrade to literal text rather than
|
|
29
|
+
* erroring, because this runs unattended inside `spf watch`.
|
|
30
|
+
*
|
|
31
|
+
* ADF node/mark shapes below are taken from Atlassian's published document
|
|
32
|
+
* structure (developer.atlassian.com/cloud/jira/platform/apis/document/
|
|
33
|
+
* structure/), not guessed — in particular the strikethrough mark's real
|
|
34
|
+
* type name is `"strike"`, NOT `"strikethrough"` (an easy guess to get
|
|
35
|
+
* wrong), and ADF text nodes must never carry an empty `text` string (the
|
|
36
|
+
* schema requires non-empty), which is why every text-emitting path here
|
|
37
|
+
* checks for empty content before emitting a node instead of always
|
|
38
|
+
* emitting one unconditionally the way `jira_provider.ts`'s `toAdf` does
|
|
39
|
+
* for its single fixed paragraph.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Canonical mark order, used both when building a text node's `marks`
|
|
43
|
+
* array (so two equivalent inputs always produce byte-identical mark
|
|
44
|
+
* ordering — load-bearing for round-trip stability, since
|
|
45
|
+
* `assert.deepEqual`-style comparison and re-parsing both depend on a
|
|
46
|
+
* single canonical shape rather than "any order that happens to result
|
|
47
|
+
* from parse order") and when reading one back. `code` sits last because
|
|
48
|
+
* `renderTextNode` (below) treats it as exclusive of the others — see that
|
|
49
|
+
* function's own comment.
|
|
50
|
+
*/
|
|
51
|
+
const MARK_ORDER = ["link", "strong", "em", "strike", "code"];
|
|
52
|
+
function addMark(marks, mark) {
|
|
53
|
+
if (marks.some((m) => m.type === mark.type))
|
|
54
|
+
return [...marks]; // already applied (e.g. `**a**` nested inside another `**...**`) — don't duplicate
|
|
55
|
+
const next = [...marks, mark];
|
|
56
|
+
return next.sort((a, b) => MARK_ORDER.indexOf(a.type) - MARK_ORDER.indexOf(b.type));
|
|
57
|
+
}
|
|
58
|
+
function makeText(text, marks) {
|
|
59
|
+
if (text.length === 0)
|
|
60
|
+
return null; // ADF text nodes must be non-empty — silently drop, never emit `{type:"text",text:""}`
|
|
61
|
+
const node = { type: "text", text };
|
|
62
|
+
if (marks.length > 0)
|
|
63
|
+
node["marks"] = marks.map((m) => (m.attrs ? { type: m.type, attrs: m.attrs } : { type: m.type }));
|
|
64
|
+
return node;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* CommonMark-style "flanking delimiter run" rules for a single `*`/`_`
|
|
68
|
+
* character, applied at the character immediately before/after it. Without
|
|
69
|
+
* these, ANY `*`/`_` paired with the next occurrence of the same character
|
|
70
|
+
* regardless of context — which is what let a bare multiplication `*`
|
|
71
|
+
* between spaces (`2 * 3`) and a `snake_case`/`file_path.ts` identifier's
|
|
72
|
+
* underscores get silently paired up and italicized across unrelated words.
|
|
73
|
+
* `_` additionally can't open/close when it's flanking on BOTH sides
|
|
74
|
+
* without adjacent punctuation (the "intraword underscore" rule — real
|
|
75
|
+
* Markdown never treats `foo_bar_baz` as emphasis); `*` has no such
|
|
76
|
+
* restriction, matching CommonMark's own intraword-`*emphasis*` allowance.
|
|
77
|
+
*/
|
|
78
|
+
function isWsBoundary(ch) {
|
|
79
|
+
return ch === undefined || /\s/.test(ch);
|
|
80
|
+
}
|
|
81
|
+
function isPunctBoundary(ch) {
|
|
82
|
+
return ch !== undefined && /[\p{P}\p{S}]/u.test(ch);
|
|
83
|
+
}
|
|
84
|
+
function isLeftFlanking(before, after) {
|
|
85
|
+
if (isWsBoundary(after))
|
|
86
|
+
return false;
|
|
87
|
+
if (!isPunctBoundary(after))
|
|
88
|
+
return true;
|
|
89
|
+
return isWsBoundary(before) || isPunctBoundary(before);
|
|
90
|
+
}
|
|
91
|
+
function isRightFlanking(before, after) {
|
|
92
|
+
if (isWsBoundary(before))
|
|
93
|
+
return false;
|
|
94
|
+
if (!isPunctBoundary(before))
|
|
95
|
+
return true;
|
|
96
|
+
return isWsBoundary(after) || isPunctBoundary(after);
|
|
97
|
+
}
|
|
98
|
+
function canOpenEmphasis(ch, before, after) {
|
|
99
|
+
if (!isLeftFlanking(before, after))
|
|
100
|
+
return false;
|
|
101
|
+
if (ch === "*")
|
|
102
|
+
return true;
|
|
103
|
+
return !isRightFlanking(before, after) || isPunctBoundary(before);
|
|
104
|
+
}
|
|
105
|
+
function canCloseEmphasis(ch, before, after) {
|
|
106
|
+
if (!isRightFlanking(before, after))
|
|
107
|
+
return false;
|
|
108
|
+
if (ch === "*")
|
|
109
|
+
return true;
|
|
110
|
+
return !isLeftFlanking(before, after) || isPunctBoundary(after);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Characters a `\`-prefixed occurrence should parse as a plain literal
|
|
114
|
+
* character rather than markup (standard Markdown backslash-escaping —
|
|
115
|
+
* e.g. `\*not italic\*`). Two groups share this one set:
|
|
116
|
+
* - INLINE delimiters this module's tokenizer treats specially inside a
|
|
117
|
+
* line (`` ` * _ ~ [ ] ( ) ``), for input Markdown source that wants a
|
|
118
|
+
* literal one of these;
|
|
119
|
+
* - BLOCK-start markers (`- + # > .`) that only matter at column 0 of a
|
|
120
|
+
* line — `escapeParagraphLine` (this module's render side) uses these
|
|
121
|
+
* to stop a rendered paragraph line from being reparsed as a
|
|
122
|
+
* fence/rule/heading/blockquote/list; see that function's comment.
|
|
123
|
+
* `adfToMarkdown` deliberately does NOT escape the first (inline) group on
|
|
124
|
+
* the way out for plain text — see `renderTextNode`'s comment on why.
|
|
125
|
+
*/
|
|
126
|
+
const ESCAPABLE_INLINE_CHARS = new Set(["\\", "`", "*", "_", "~", "[", "]", "(", ")", "-", "+", "#", ">", "."]);
|
|
127
|
+
/**
|
|
128
|
+
* The inline tokenizer. Recursive-descent over the raw string: each
|
|
129
|
+
* delimiter (code span, link, bold, italic, strike) is resolved by
|
|
130
|
+
* scanning forward for its matching close and, if found, recursing on the
|
|
131
|
+
* inner text with the new mark added — which is what lets `**a *b* c**`
|
|
132
|
+
* nest italic inside bold correctly (the inner recursive call sees the
|
|
133
|
+
* accumulated `[strong]` marks list). If no matching close exists (an
|
|
134
|
+
* unterminated `**`, a `[` with no `](url)`), the opening characters fall
|
|
135
|
+
* through to plain buffered text — the same "degrade to literal, never
|
|
136
|
+
* throw" rule the module comment describes, applied at the character
|
|
137
|
+
* level instead of the block level.
|
|
138
|
+
*
|
|
139
|
+
* Bold-vs-italic precedence: `**`/`__` are checked (via `startsWith`)
|
|
140
|
+
* BEFORE the single-char `*`/`_` case, so `**x**` is never misread as two
|
|
141
|
+
* adjacent unmatched italics. Images (``) are deliberately
|
|
142
|
+
* excluded from link detection by checking the character before `[` isn't
|
|
143
|
+
* `!` — without that check an image would silently become a LINK (a
|
|
144
|
+
* structural corruption, not a safe literal fallback); with it, the whole
|
|
145
|
+
* `` run falls through untouched as literal text, per the
|
|
146
|
+
* out-of-scope contract above.
|
|
147
|
+
*
|
|
148
|
+
* `\u0000` is a private sentinel `buildParagraphSource` uses to mark a
|
|
149
|
+
* hard line break's position — chosen because real Markdown input can't
|
|
150
|
+
* contain a literal NUL, so it can never collide with real content.
|
|
151
|
+
*/
|
|
152
|
+
function parseInlineWithMarks(text, marks) {
|
|
153
|
+
const nodes = [];
|
|
154
|
+
let buf = "";
|
|
155
|
+
let i = 0;
|
|
156
|
+
const flush = () => {
|
|
157
|
+
const node = makeText(buf, marks);
|
|
158
|
+
if (node)
|
|
159
|
+
nodes.push(node);
|
|
160
|
+
buf = "";
|
|
161
|
+
};
|
|
162
|
+
while (i < text.length) {
|
|
163
|
+
const ch = text[i];
|
|
164
|
+
if (ch === "\u0000") {
|
|
165
|
+
flush();
|
|
166
|
+
nodes.push({ type: "hardBreak" });
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (ch === "\\" && i + 1 < text.length && ESCAPABLE_INLINE_CHARS.has(text[i + 1])) {
|
|
171
|
+
// A backslash-escaped metacharacter in the INPUT: consume both and
|
|
172
|
+
// emit the next char as plain literal content, never as a delimiter.
|
|
173
|
+
// Must be checked before every delimiter branch so e.g. `\*` can
|
|
174
|
+
// never be misread as an opening `*`.
|
|
175
|
+
buf += text[i + 1];
|
|
176
|
+
i += 2;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === "`") {
|
|
180
|
+
const close = text.indexOf("`", i + 1);
|
|
181
|
+
if (close !== -1) {
|
|
182
|
+
flush();
|
|
183
|
+
const code = text.slice(i + 1, close);
|
|
184
|
+
const node = makeText(code, addMark(marks, { type: "code" }));
|
|
185
|
+
if (node)
|
|
186
|
+
nodes.push(node);
|
|
187
|
+
i = close + 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (ch === "[" && text[i - 1] !== "!") {
|
|
192
|
+
const closeBracket = text.indexOf("]", i + 1);
|
|
193
|
+
if (closeBracket !== -1 && text[closeBracket + 1] === "(") {
|
|
194
|
+
const closeParen = text.indexOf(")", closeBracket + 2);
|
|
195
|
+
// An empty `href` (`[docs]()`) is NOT a valid ADF link mark — Jira's
|
|
196
|
+
// ADF validator requires a non-empty URL and would 400 the whole
|
|
197
|
+
// request, unlike every other malformed construct here, which
|
|
198
|
+
// degrades to literal text. So an empty (or whitespace-only) target
|
|
199
|
+
// falls through to literal text too, same as an unmatched bracket.
|
|
200
|
+
if (closeParen !== -1 && text.slice(closeBracket + 2, closeParen).trim().length > 0) {
|
|
201
|
+
flush();
|
|
202
|
+
const linkText = text.slice(i + 1, closeBracket);
|
|
203
|
+
const href = text.slice(closeBracket + 2, closeParen);
|
|
204
|
+
nodes.push(...parseInlineWithMarks(linkText, addMark(marks, { type: "link", attrs: { href } })));
|
|
205
|
+
i = closeParen + 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (text.startsWith("**", i) || text.startsWith("__", i)) {
|
|
211
|
+
const delim = text.slice(i, i + 2);
|
|
212
|
+
const close = text.indexOf(delim, i + 2);
|
|
213
|
+
if (close !== -1) {
|
|
214
|
+
flush();
|
|
215
|
+
nodes.push(...parseInlineWithMarks(text.slice(i + 2, close), addMark(marks, { type: "strong" })));
|
|
216
|
+
i = close + 2;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (text.startsWith("~~", i)) {
|
|
221
|
+
const close = text.indexOf("~~", i + 2);
|
|
222
|
+
if (close !== -1) {
|
|
223
|
+
flush();
|
|
224
|
+
nodes.push(...parseInlineWithMarks(text.slice(i + 2, close), addMark(marks, { type: "strike" })));
|
|
225
|
+
i = close + 2;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (ch === "*" || ch === "_") {
|
|
230
|
+
const openBefore = i > 0 ? text[i - 1] : undefined;
|
|
231
|
+
const openAfter = i + 1 < text.length ? text[i + 1] : undefined;
|
|
232
|
+
if (canOpenEmphasis(ch, openBefore, openAfter)) {
|
|
233
|
+
// Scan forward for the NEAREST same-character delimiter that is
|
|
234
|
+
// itself flanking-valid to close (not just "the next occurrence of
|
|
235
|
+
// the char", which is what let `*`/`_` pair across unrelated words
|
|
236
|
+
// — see `canOpenEmphasis`/`canCloseEmphasis` above).
|
|
237
|
+
let close = -1;
|
|
238
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
239
|
+
if (text[j] !== ch)
|
|
240
|
+
continue;
|
|
241
|
+
if (j <= i + 1)
|
|
242
|
+
continue; // no empty-content emphasis
|
|
243
|
+
const closeBefore = text[j - 1];
|
|
244
|
+
const closeAfter = j + 1 < text.length ? text[j + 1] : undefined;
|
|
245
|
+
if (canCloseEmphasis(ch, closeBefore, closeAfter)) {
|
|
246
|
+
close = j;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (close !== -1) {
|
|
251
|
+
flush();
|
|
252
|
+
nodes.push(...parseInlineWithMarks(text.slice(i + 1, close), addMark(marks, { type: "em" })));
|
|
253
|
+
i = close + 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
buf += ch;
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
flush();
|
|
262
|
+
return nodes;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Strips a hard-break marker from one raw line — either a trailing
|
|
266
|
+
* backslash or two-or-more trailing spaces — and reports whether one was
|
|
267
|
+
* found. A trailing `\\` (escaped backslash, i.e. a literal backslash the
|
|
268
|
+
* author meant to keep) is deliberately NOT treated as a break: only an
|
|
269
|
+
* ODD-length run of trailing backslashes ends in an unescaped one.
|
|
270
|
+
*/
|
|
271
|
+
function stripHardBreakMarker(line) {
|
|
272
|
+
const backslashes = /\\+$/.exec(line);
|
|
273
|
+
if (backslashes && backslashes[0].length % 2 === 1) {
|
|
274
|
+
return { content: line.slice(0, -1), hardBreak: true };
|
|
275
|
+
}
|
|
276
|
+
const spacesMatch = / {2,}$/.exec(line);
|
|
277
|
+
if (spacesMatch) {
|
|
278
|
+
return { content: line.slice(0, line.length - spacesMatch[0].length), hardBreak: true };
|
|
279
|
+
}
|
|
280
|
+
return { content: line, hardBreak: false };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Two adjacent lines that both look like pipe-delimited table rows must
|
|
284
|
+
* NOT be soft-joined with a space — tables are out-of-scope syntax (module
|
|
285
|
+
* comment), and this module's contract for out-of-scope constructs is that
|
|
286
|
+
* they "never corrupt structure", not that they get flattened. A real
|
|
287
|
+
* (un-blank-line-separated) Markdown table fed through the old plain-space
|
|
288
|
+
* join collapsed `"| a | b |\n| - | - |\n| 1 | 2 |"` into one line —
|
|
289
|
+
* destroying it in both the Jira description AND the `Issue.body` later
|
|
290
|
+
* read back for the agent prompt — regressing the OLD `toAdf`/`adfToText`
|
|
291
|
+
* pair's verbatim round-trip for exactly this content. Preserving each row
|
|
292
|
+
* on its own visual line (via the same hardBreak sentinel a real hard
|
|
293
|
+
* break uses) is the best this constrained subset can do without adding a
|
|
294
|
+
* real ADF table node, but it keeps the rows intact and readable instead of
|
|
295
|
+
* mashing them together. Ordinary hard-wrapped prose (no `|`) is
|
|
296
|
+
* deliberately unaffected — soft-joining that IS correct Markdown
|
|
297
|
+
* behavior, tested elsewhere in this module.
|
|
298
|
+
*/
|
|
299
|
+
function looksLikeTableRow(line) {
|
|
300
|
+
return line.includes("|");
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Joins a paragraph's raw lines into one source string for
|
|
304
|
+
* `parseInlineWithMarks`, inserting the `\u0000` hard-break sentinel where
|
|
305
|
+
* a line ended with a break marker (or where both it and the next line look
|
|
306
|
+
* like table rows — see `looksLikeTableRow`), or a plain space otherwise —
|
|
307
|
+
* "otherwise consecutive non-blank lines join as one paragraph, matching
|
|
308
|
+
* how Markdown actually works" (a soft line break renders as a space, not
|
|
309
|
+
* a newline).
|
|
310
|
+
*/
|
|
311
|
+
function buildParagraphSource(lines) {
|
|
312
|
+
const parts = [];
|
|
313
|
+
lines.forEach((line, idx) => {
|
|
314
|
+
const { content, hardBreak } = stripHardBreakMarker(line);
|
|
315
|
+
parts.push(content);
|
|
316
|
+
if (idx < lines.length - 1) {
|
|
317
|
+
const preserveLine = hardBreak || (looksLikeTableRow(content) && looksLikeTableRow(lines[idx + 1]));
|
|
318
|
+
parts.push(preserveLine ? "\u0000" : " ");
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
return parts.join("");
|
|
322
|
+
}
|
|
323
|
+
function makeParagraph(lines) {
|
|
324
|
+
return { type: "paragraph", content: parseInlineWithMarks(buildParagraphSource(lines), []) };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* A paragraph whose text is NOT run through `parseInlineWithMarks` at
|
|
328
|
+
* all — for fallback cases where a line must render as truly literal text
|
|
329
|
+
* rather than "safe but still markdown-interpreted" text. Needed because a
|
|
330
|
+
* malformed construct can itself contain characters (odd backtick counts,
|
|
331
|
+
* in particular — an unterminated ` ``` ` fence line has three backticks,
|
|
332
|
+
* an odd number that the inline code-span scanner would otherwise pair up
|
|
333
|
+
* wrongly, e.g. into an empty code span plus a stray literal backtick)
|
|
334
|
+
* that the normal inline scanner would reinterpret rather than preserve.
|
|
335
|
+
*/
|
|
336
|
+
function literalParagraph(line) {
|
|
337
|
+
const node = makeText(line, []);
|
|
338
|
+
return { type: "paragraph", content: node ? [node] : [] };
|
|
339
|
+
}
|
|
340
|
+
function makeHeading(level, text) {
|
|
341
|
+
return { type: "heading", attrs: { level }, content: parseInlineWithMarks(text, []) };
|
|
342
|
+
}
|
|
343
|
+
function makeCodeBlock(code, language) {
|
|
344
|
+
const node = { type: "codeBlock" };
|
|
345
|
+
if (language)
|
|
346
|
+
node["attrs"] = { language };
|
|
347
|
+
if (code.length > 0)
|
|
348
|
+
node["content"] = [{ type: "text", text: code }];
|
|
349
|
+
return node;
|
|
350
|
+
}
|
|
351
|
+
function makeBlockquote(lines) {
|
|
352
|
+
return { type: "blockquote", content: [makeParagraph(lines)] };
|
|
353
|
+
}
|
|
354
|
+
function matchBullet(line) {
|
|
355
|
+
const m = /^( *)([-*+]) +(.*)$/.exec(line);
|
|
356
|
+
return m ? { indent: m[1].length, text: m[3] } : null;
|
|
357
|
+
}
|
|
358
|
+
function matchOrdered(line) {
|
|
359
|
+
const m = /^( *)(\d+)\. +(.*)$/.exec(line);
|
|
360
|
+
return m ? { indent: m[1].length, text: m[3], start: parseInt(m[2], 10) } : null;
|
|
361
|
+
}
|
|
362
|
+
function isFenceOpen(line) {
|
|
363
|
+
return /^```(\S*)\s*$/.test(line);
|
|
364
|
+
}
|
|
365
|
+
function isRule(line) {
|
|
366
|
+
return /^(-{3,}|\*{3,}|_{3,})$/.test(line.trim());
|
|
367
|
+
}
|
|
368
|
+
function isHeading(line) {
|
|
369
|
+
return /^#{1,6}(\s|$)/.test(line);
|
|
370
|
+
}
|
|
371
|
+
/** Whether `line` starts a new (non-paragraph) block — used to stop paragraph-line accumulation before it swallows the next heading/list/etc. */
|
|
372
|
+
function isBlockStart(line) {
|
|
373
|
+
if (isFenceOpen(line))
|
|
374
|
+
return true;
|
|
375
|
+
if (isRule(line))
|
|
376
|
+
return true;
|
|
377
|
+
if (isHeading(line))
|
|
378
|
+
return true;
|
|
379
|
+
if (/^>/.test(line))
|
|
380
|
+
return true;
|
|
381
|
+
const b = matchBullet(line);
|
|
382
|
+
if (b && b.indent === 0)
|
|
383
|
+
return true;
|
|
384
|
+
const o = matchOrdered(line);
|
|
385
|
+
if (o && o.indent === 0)
|
|
386
|
+
return true;
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
/** Finds the closing ` ``` ` line at or after `from`; returns -1 (never throws/loops) if the fence is never closed. */
|
|
390
|
+
function findFenceClose(lines, from) {
|
|
391
|
+
for (let i = from; i < lines.length; i++) {
|
|
392
|
+
if (lines[i].trimEnd() === "```")
|
|
393
|
+
return i;
|
|
394
|
+
}
|
|
395
|
+
return -1;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Parses one list (bullet or ordered) starting at `lines[start]`, whose
|
|
399
|
+
* marker sits at `indent` columns. Supports exactly ONE level of nesting:
|
|
400
|
+
* immediately after an item's own line, if the next line is a list marker
|
|
401
|
+
* (either kind) indented deeper than `indent`, it's parsed as one nested
|
|
402
|
+
* list and attached as the item's second content block — a further-nested
|
|
403
|
+
* marker inside THAT recursive call would need an even deeper indent than
|
|
404
|
+
* the nested list's own base, which nothing here ever requests, so nesting
|
|
405
|
+
* naturally bottoms out at one level rather than needing an explicit depth
|
|
406
|
+
* check.
|
|
407
|
+
*/
|
|
408
|
+
function parseList(lines, start, indent) {
|
|
409
|
+
const ordered = matchOrdered(lines[start]) !== null && matchBullet(lines[start]) === null;
|
|
410
|
+
const items = [];
|
|
411
|
+
let i = start;
|
|
412
|
+
let orderStart;
|
|
413
|
+
while (i < lines.length) {
|
|
414
|
+
const line = lines[i];
|
|
415
|
+
if (line.trim() === "")
|
|
416
|
+
break; // v1 simplification: a blank line ends the list rather than starting a "loose" list
|
|
417
|
+
const match = ordered ? matchOrdered(line) : matchBullet(line);
|
|
418
|
+
if (!match || match.indent !== indent)
|
|
419
|
+
break;
|
|
420
|
+
if (ordered && orderStart === undefined)
|
|
421
|
+
orderStart = matchOrdered(line)?.start;
|
|
422
|
+
i++;
|
|
423
|
+
const itemContent = [makeParagraph([match.text])];
|
|
424
|
+
if (i < lines.length) {
|
|
425
|
+
const nestedBullet = matchBullet(lines[i]);
|
|
426
|
+
const nestedOrdered = matchOrdered(lines[i]);
|
|
427
|
+
const nested = nestedBullet ?? nestedOrdered;
|
|
428
|
+
if (nested && nested.indent > indent) {
|
|
429
|
+
const sub = parseList(lines, i, nested.indent);
|
|
430
|
+
itemContent.push(sub.node);
|
|
431
|
+
i = sub.nextIndex;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
items.push({ type: "listItem", content: itemContent });
|
|
435
|
+
}
|
|
436
|
+
const node = ordered
|
|
437
|
+
? { type: "orderedList", ...(orderStart !== undefined && orderStart !== 1 ? { attrs: { order: orderStart } } : {}), content: items }
|
|
438
|
+
: { type: "bulletList", content: items };
|
|
439
|
+
return { node, nextIndex: i };
|
|
440
|
+
}
|
|
441
|
+
function parseBlocks(lines) {
|
|
442
|
+
const nodes = [];
|
|
443
|
+
let i = 0;
|
|
444
|
+
while (i < lines.length) {
|
|
445
|
+
const line = lines[i];
|
|
446
|
+
if (line.trim() === "") {
|
|
447
|
+
i++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (isFenceOpen(line)) {
|
|
451
|
+
const closeIdx = findFenceClose(lines, i + 1);
|
|
452
|
+
if (closeIdx !== -1) {
|
|
453
|
+
const lang = /^```(\S*)/.exec(line)[1];
|
|
454
|
+
nodes.push(makeCodeBlock(lines.slice(i + 1, closeIdx).join("\n"), lang || undefined));
|
|
455
|
+
i = closeIdx + 1;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// Unterminated fence — fall back to literal text for just this line, not the whole rest of the document.
|
|
459
|
+
nodes.push(literalParagraph(line));
|
|
460
|
+
i++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (isRule(line)) {
|
|
464
|
+
nodes.push({ type: "rule" });
|
|
465
|
+
i++;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const headingMatch = /^(#{1,6})(?:\s+(.*))?$/.exec(line);
|
|
469
|
+
if (headingMatch) {
|
|
470
|
+
nodes.push(makeHeading(headingMatch[1].length, (headingMatch[2] ?? "").trim()));
|
|
471
|
+
i++;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (/^>/.test(line)) {
|
|
475
|
+
const quoteLines = [];
|
|
476
|
+
while (i < lines.length && /^>/.test(lines[i])) {
|
|
477
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
|
478
|
+
i++;
|
|
479
|
+
}
|
|
480
|
+
nodes.push(makeBlockquote(quoteLines));
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const bullet = matchBullet(line);
|
|
484
|
+
const ordered = matchOrdered(line);
|
|
485
|
+
if ((bullet && bullet.indent === 0) || (ordered && ordered.indent === 0)) {
|
|
486
|
+
const { node, nextIndex } = parseList(lines, i, 0);
|
|
487
|
+
nodes.push(node);
|
|
488
|
+
i = nextIndex;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const paraLines = [line];
|
|
492
|
+
i++;
|
|
493
|
+
while (i < lines.length && lines[i].trim() !== "" && !isBlockStart(lines[i])) {
|
|
494
|
+
paraLines.push(lines[i]);
|
|
495
|
+
i++;
|
|
496
|
+
}
|
|
497
|
+
nodes.push(makeParagraph(paraLines));
|
|
498
|
+
}
|
|
499
|
+
return nodes;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Parses the constrained Markdown subset (module comment) into an ADF
|
|
503
|
+
* document node. Never throws: every unrecognized or malformed construct
|
|
504
|
+
* degrades to literal text inside a plain paragraph rather than raising,
|
|
505
|
+
* because this feeds `spf watch`'s unattended comment/description writes.
|
|
506
|
+
*/
|
|
507
|
+
export function markdownToAdf(text) {
|
|
508
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
509
|
+
const content = parseBlocks(lines);
|
|
510
|
+
return { type: "doc", version: 1, content: content.length > 0 ? content : [{ type: "paragraph", content: [] }] };
|
|
511
|
+
}
|
|
512
|
+
function renderFallbackText(node) {
|
|
513
|
+
if (!node || typeof node !== "object")
|
|
514
|
+
return "";
|
|
515
|
+
const n = node;
|
|
516
|
+
if (n.type === "text" && typeof n.text === "string")
|
|
517
|
+
return n.text;
|
|
518
|
+
if (Array.isArray(n.content))
|
|
519
|
+
return n.content.map(renderFallbackText).join("");
|
|
520
|
+
return "";
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Renders one text node with its marks back to Markdown. `code` is treated
|
|
524
|
+
* as exclusive of the other marks — real Jira/CommonMark editors don't let
|
|
525
|
+
* you combine inline code with bold/italic/strike/link either, so a text
|
|
526
|
+
* node built with `code` alongside another mark (reachable only via a hand-
|
|
527
|
+
* built ADF doc, never via this module's own `markdownToAdf`) renders as
|
|
528
|
+
* just the code span; that's a deliberate, documented lossy edge, not a
|
|
529
|
+
* bug — `adfToMarkdown(markdownToAdf(x))` re-parsed a second time still
|
|
530
|
+
* converges, which is the only round-trip guarantee this module promises.
|
|
531
|
+
*
|
|
532
|
+
* `em` renders as `_..._` instead of `*...*` specifically when `strong` is
|
|
533
|
+
* ALSO present: rendering both with `*` would emit an ambiguous triple-run
|
|
534
|
+
* (`***text***`), which the recursive-descent parser above cannot reliably
|
|
535
|
+
* re-split back into nested strong+em (a classic CommonMark delimiter-
|
|
536
|
+
* ambiguity case) — breaking the round-trip convergence guarantee. `_` is
|
|
537
|
+
* safe here specifically because it's nested inside strong's own `**`
|
|
538
|
+
* delimiters, so there's no surrounding word-adjacency for the intraword-
|
|
539
|
+
* underscore rule to reject; a standalone `em` (no `strong`) keeps using
|
|
540
|
+
* `*` so intraword emphasis (`foo*bar*baz`) still round-trips.
|
|
541
|
+
*
|
|
542
|
+
* Deliberately does NOT backslash-escape stray `*`/`_`/`` ` ``/`~`/`[`
|
|
543
|
+
* bytes in plain (unmarked) text: `adfToMarkdown` is this codebase's ONE
|
|
544
|
+
* ADF-to-text implementation (see `jira_provider.ts`'s module comment),
|
|
545
|
+
* shared with `findMarkerComment`/`readMarker`'s `[spf-watch-marker]` JSON
|
|
546
|
+
* payload, which is read back via a direct regex + `JSON.parse` on this
|
|
547
|
+
* function's output — never re-fed through `markdownToAdf`. Escaping here
|
|
548
|
+
* would inject literal backslashes into that JSON's underscores/brackets
|
|
549
|
+
* and corrupt it. The flanking-delimiter rules in `parseInlineWithMarks`
|
|
550
|
+
* already keep ordinary stray metacharacters in prose from being misread
|
|
551
|
+
* as markup on re-parse, without needing that.
|
|
552
|
+
*/
|
|
553
|
+
function renderTextNode(text, marksRaw) {
|
|
554
|
+
const marks = marksRaw.filter((m) => Boolean(m) && typeof m === "object" && typeof m.type === "string");
|
|
555
|
+
const has = (t) => marks.some((m) => m.type === t);
|
|
556
|
+
if (has("code"))
|
|
557
|
+
return `\`${text}\``;
|
|
558
|
+
let out = text;
|
|
559
|
+
if (has("strike"))
|
|
560
|
+
out = `~~${out}~~`;
|
|
561
|
+
if (has("em"))
|
|
562
|
+
out = has("strong") ? `_${out}_` : `*${out}*`;
|
|
563
|
+
if (has("strong"))
|
|
564
|
+
out = `**${out}**`;
|
|
565
|
+
const link = marks.find((m) => m.type === "link");
|
|
566
|
+
if (link) {
|
|
567
|
+
const href = typeof link.attrs?.["href"] === "string" ? link.attrs["href"] : "";
|
|
568
|
+
out = `[${out}](${href})`;
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
function renderInline(nodes) {
|
|
573
|
+
let out = "";
|
|
574
|
+
for (const raw of nodes) {
|
|
575
|
+
if (!raw || typeof raw !== "object")
|
|
576
|
+
continue;
|
|
577
|
+
const node = raw;
|
|
578
|
+
if (node.type === "text" && typeof node.text === "string") {
|
|
579
|
+
out += renderTextNode(node.text, Array.isArray(node.marks) ? node.marks : []);
|
|
580
|
+
}
|
|
581
|
+
else if (node.type === "hardBreak") {
|
|
582
|
+
out += " \n"; // trailing two spaces + newline — what `stripHardBreakMarker` recognizes on re-parse
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
out += renderFallbackText(node);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
function renderCodeBlock(node) {
|
|
591
|
+
const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
|
|
592
|
+
const codeNodes = Array.isArray(node.content) ? node.content : [];
|
|
593
|
+
const code = codeNodes.map((n) => (n && typeof n === "object" && typeof n.text === "string" ? n.text : "")).join("");
|
|
594
|
+
return "```" + language + "\n" + code + "\n```";
|
|
595
|
+
}
|
|
596
|
+
function renderList(node, indent) {
|
|
597
|
+
const ordered = node.type === "orderedList";
|
|
598
|
+
const items = Array.isArray(node.content) ? node.content : [];
|
|
599
|
+
const orderStart = ordered && typeof node.attrs?.order === "number" ? node.attrs.order : 1;
|
|
600
|
+
return items
|
|
601
|
+
.map((rawItem, idx) => {
|
|
602
|
+
if (!rawItem || typeof rawItem !== "object")
|
|
603
|
+
return "";
|
|
604
|
+
const item = rawItem;
|
|
605
|
+
const blocks = Array.isArray(item.content) ? item.content : [];
|
|
606
|
+
const paragraph = blocks.find((b) => Boolean(b) && typeof b === "object" && b.type === "paragraph");
|
|
607
|
+
const nestedList = blocks.find((b) => Boolean(b) && typeof b === "object" && (b.type === "bulletList" || b.type === "orderedList"));
|
|
608
|
+
const text = paragraph ? renderInline(Array.isArray(paragraph.content) ? paragraph.content : []) : renderFallbackText(item);
|
|
609
|
+
const marker = ordered ? `${orderStart + idx}. ` : "- ";
|
|
610
|
+
let line = indent + marker + text;
|
|
611
|
+
if (nestedList)
|
|
612
|
+
line += "\n" + renderList(nestedList, indent + " ");
|
|
613
|
+
return line;
|
|
614
|
+
})
|
|
615
|
+
.join("\n");
|
|
616
|
+
}
|
|
617
|
+
function renderBlockquote(node) {
|
|
618
|
+
const blocks = Array.isArray(node.content) ? node.content : [];
|
|
619
|
+
const lines = [];
|
|
620
|
+
for (const raw of blocks) {
|
|
621
|
+
const text = raw && typeof raw === "object" && raw.type === "paragraph"
|
|
622
|
+
? renderInline(Array.isArray(raw.content) ? raw.content : [])
|
|
623
|
+
: renderFallbackText(raw);
|
|
624
|
+
for (const l of text.split("\n"))
|
|
625
|
+
lines.push(`> ${l}`);
|
|
626
|
+
}
|
|
627
|
+
return lines.join("\n");
|
|
628
|
+
}
|
|
629
|
+
function clampHeadingLevel(level) {
|
|
630
|
+
const n = typeof level === "number" ? Math.trunc(level) : 1;
|
|
631
|
+
return Math.min(6, Math.max(1, n || 1));
|
|
632
|
+
}
|
|
633
|
+
/** Whether `line`, if `parseBlocks` scanned it fresh, would start a fence/rule/heading/blockquote/list rather than plain paragraph text — see `escapeParagraphLine`, the only caller. */
|
|
634
|
+
function looksLikeBlockStart(line) {
|
|
635
|
+
return isFenceOpen(line) || isRule(line) || isHeading(line) || /^>/.test(line) || matchBullet(line) !== null || matchOrdered(line) !== null;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Guards one line of a RENDERED paragraph against being misread as a
|
|
639
|
+
* different block type on the next `markdownToAdf` pass. `parseBlocks`
|
|
640
|
+
* treats ANY line of an in-progress paragraph — not just a block's first —
|
|
641
|
+
* that matches a fence/rule/heading/blockquote/list pattern as ending
|
|
642
|
+
* paragraph accumulation right there (see its continuation-line `while`
|
|
643
|
+
* loop). That means two lines that were never a list in the source — a
|
|
644
|
+
* bare `-` alone on one line, soft-joined with the next line's text into
|
|
645
|
+
* one rendered `"- word"` line — silently become a real bulletList the
|
|
646
|
+
* next time this text is parsed, which is exactly the kind of "out-of-
|
|
647
|
+
* scope constructs never corrupt structure" violation this module's own
|
|
648
|
+
* doc comment rules out. Escaping just the line's first character is
|
|
649
|
+
* enough to defeat every one of those patterns, all of which anchor a
|
|
650
|
+
* specific character at column 0; `ESCAPABLE_INLINE_CHARS` is what lets
|
|
651
|
+
* `markdownToAdf` unescape it back to the original literal character.
|
|
652
|
+
*/
|
|
653
|
+
function escapeParagraphLine(line) {
|
|
654
|
+
return looksLikeBlockStart(line) ? `\\${line}` : line;
|
|
655
|
+
}
|
|
656
|
+
function renderBlock(raw) {
|
|
657
|
+
if (!raw || typeof raw !== "object")
|
|
658
|
+
return undefined;
|
|
659
|
+
const node = raw;
|
|
660
|
+
switch (node.type) {
|
|
661
|
+
case "paragraph":
|
|
662
|
+
return renderInline(Array.isArray(node.content) ? node.content : [])
|
|
663
|
+
.split("\n")
|
|
664
|
+
.map(escapeParagraphLine)
|
|
665
|
+
.join("\n");
|
|
666
|
+
case "heading": {
|
|
667
|
+
const level = clampHeadingLevel(node.attrs?.level);
|
|
668
|
+
const text = renderInline(Array.isArray(node.content) ? node.content : []);
|
|
669
|
+
return text ? `${"#".repeat(level)} ${text}` : "#".repeat(level);
|
|
670
|
+
}
|
|
671
|
+
case "codeBlock":
|
|
672
|
+
return renderCodeBlock(node);
|
|
673
|
+
case "bulletList":
|
|
674
|
+
case "orderedList":
|
|
675
|
+
return renderList(node, "");
|
|
676
|
+
case "blockquote":
|
|
677
|
+
return renderBlockquote(node);
|
|
678
|
+
case "rule":
|
|
679
|
+
return "---";
|
|
680
|
+
default: {
|
|
681
|
+
const fallback = renderFallbackText(node);
|
|
682
|
+
return fallback.length > 0 ? fallback : undefined;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The inverse of `markdownToAdf`: renders an ADF document back into the
|
|
688
|
+
* same Markdown subset. NOT required to byte-match the original input
|
|
689
|
+
* (canonical forms are fine — e.g. always `-` for bullets even if the
|
|
690
|
+
* source used `*`), only to be semantically recognizable and to converge
|
|
691
|
+
* after one more round trip through `markdownToAdf`. Handles ADF built by
|
|
692
|
+
* this module OR hand-built elsewhere (e.g. Jira's own rich-text editor):
|
|
693
|
+
* any node or mark type it doesn't recognize degrades to its nested text
|
|
694
|
+
* content via `renderFallbackText` rather than throwing or dropping it.
|
|
695
|
+
*/
|
|
696
|
+
export function adfToMarkdown(adf) {
|
|
697
|
+
if (!adf || typeof adf !== "object")
|
|
698
|
+
return "";
|
|
699
|
+
const doc = adf;
|
|
700
|
+
const content = Array.isArray(doc.content) ? doc.content : [];
|
|
701
|
+
return content
|
|
702
|
+
.map((n) => renderBlock(n))
|
|
703
|
+
.filter((s) => s !== undefined)
|
|
704
|
+
.join("\n\n");
|
|
705
|
+
}
|