@skitterbyte/skitterspec-jira 0.0.0-stage → 1.0.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/MIGRATION.md +1089 -0
- package/README.md +42 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +554 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +61 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2577 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +306 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +989 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimal line diff — just enough for `update` to say what it skipped.
|
|
5
|
+
*
|
|
6
|
+
* `update` reports a file it kept as `customized (kept)` and nothing else, so
|
|
7
|
+
* there is no way to learn WHICH upstream changes you declined without diffing
|
|
8
|
+
* against `node_modules` by hand. That is how a real behavioural change (the
|
|
9
|
+
* lifecycle skills learning to commit their own edits) went unnoticed through an
|
|
10
|
+
* upgrade in the field.
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies on purpose: this package ships with none, and `diff(1)` is
|
|
13
|
+
* not a portable guarantee. An LCS over lines is a few dozen lines of code and
|
|
14
|
+
* the inputs are markdown files of a few hundred lines.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Longest-common-subsequence walk over two line arrays, as a flat op list.
|
|
18
|
+
// `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
|
|
19
|
+
function diffOps(a, b) {
|
|
20
|
+
const n = a.length
|
|
21
|
+
const m = b.length
|
|
22
|
+
// dp[i][j] = LCS length of a[i..] and b[j..], flattened.
|
|
23
|
+
const dp = new Int32Array((n + 1) * (m + 1))
|
|
24
|
+
const at = (i, j) => i * (m + 1) + j
|
|
25
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
26
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ops = []
|
|
32
|
+
let i = 0
|
|
33
|
+
let j = 0
|
|
34
|
+
while (i < n && j < m) {
|
|
35
|
+
if (a[i] === b[j]) {
|
|
36
|
+
ops.push({ t: ' ', line: a[i], a: i, b: j })
|
|
37
|
+
i++
|
|
38
|
+
j++
|
|
39
|
+
} else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
|
|
40
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
41
|
+
i++
|
|
42
|
+
} else {
|
|
43
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
44
|
+
j++
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
while (i < n) {
|
|
48
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
49
|
+
i++
|
|
50
|
+
}
|
|
51
|
+
while (j < m) {
|
|
52
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
53
|
+
j++
|
|
54
|
+
}
|
|
55
|
+
return ops
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Group the ops into unified-diff hunks, each carrying `context` unchanged lines
|
|
59
|
+
// either side of a run of changes. Runs closer together than 2×context merge, as
|
|
60
|
+
// `diff -u` does, so a cluster of edits reads as one hunk.
|
|
61
|
+
function toHunks(ops, context) {
|
|
62
|
+
const changed = ops.map((o) => o.t !== ' ')
|
|
63
|
+
const hunks = []
|
|
64
|
+
let k = 0
|
|
65
|
+
while (k < ops.length) {
|
|
66
|
+
if (!changed[k]) {
|
|
67
|
+
k++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
let start = Math.max(0, k - context)
|
|
71
|
+
let end = k
|
|
72
|
+
// Extend while the next change is near enough to keep in the same hunk.
|
|
73
|
+
for (let p = k; p < ops.length; p++) {
|
|
74
|
+
if (changed[p]) end = p
|
|
75
|
+
else if (p - end > context * 2) break
|
|
76
|
+
}
|
|
77
|
+
end = Math.min(ops.length - 1, end + context)
|
|
78
|
+
|
|
79
|
+
const body = ops.slice(start, end + 1)
|
|
80
|
+
const aStart = body[0].a + 1
|
|
81
|
+
const bStart = body[0].b + 1
|
|
82
|
+
const aLen = body.filter((o) => o.t !== '+').length
|
|
83
|
+
const bLen = body.filter((o) => o.t !== '-').length
|
|
84
|
+
hunks.push(
|
|
85
|
+
[`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
k = end + 1
|
|
88
|
+
}
|
|
89
|
+
return hunks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Diff `a` (what is on disk) against `b` (what the package ships).
|
|
94
|
+
*
|
|
95
|
+
* `added`/`removed` count the lines an update WOULD add and remove — the summary
|
|
96
|
+
* printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string|string[]} a
|
|
99
|
+
* @param {string|string[]} b
|
|
100
|
+
* @param {{context?:number}} [opts]
|
|
101
|
+
* @returns {{added:number, removed:number, hunks:string[]}}
|
|
102
|
+
*/
|
|
103
|
+
function linesDiff(a, b, { context = 3 } = {}) {
|
|
104
|
+
const A = Array.isArray(a) ? a : String(a).split('\n')
|
|
105
|
+
const B = Array.isArray(b) ? b : String(b).split('\n')
|
|
106
|
+
const ops = diffOps(A, B)
|
|
107
|
+
return {
|
|
108
|
+
added: ops.filter((o) => o.t === '+').length,
|
|
109
|
+
removed: ops.filter((o) => o.t === '-').length,
|
|
110
|
+
hunks: toHunks(ops, context),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { linesDiff }
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interactive setup flow for `skitterspec init`, built on the `prompts`
|
|
5
|
+
* (terkelg) library. Only required from the TTY branch of the CLI — the
|
|
6
|
+
* non-interactive path (flags / --yes / CI) never loads this module, so the
|
|
7
|
+
* test suite never imports the interactive UI.
|
|
8
|
+
*
|
|
9
|
+
* `isolationSeed` pre-fills the per-spec isolation question. Returns
|
|
10
|
+
* `{ isolation, mode }` — `mode` is only asked when isolation is enabled, and
|
|
11
|
+
* is `'worktree'` otherwise (the value the config defaults to anyway).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
|
|
15
|
+
const prompts = require('prompts')
|
|
16
|
+
|
|
17
|
+
let cancelled = false
|
|
18
|
+
const onCancel = () => {
|
|
19
|
+
cancelled = true
|
|
20
|
+
return false // stop the prompt chain
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const questions = [
|
|
24
|
+
{
|
|
25
|
+
type: 'confirm',
|
|
26
|
+
name: 'isolation',
|
|
27
|
+
message: 'Enable per-spec isolation — build each spec on its own branch?',
|
|
28
|
+
initial: isolationSeed,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
// Only reachable when isolation was accepted: `prev` is the previous
|
|
32
|
+
// answer, and returning null skips the question entirely.
|
|
33
|
+
type: (prev) => (prev ? 'select' : null),
|
|
34
|
+
name: 'mode',
|
|
35
|
+
message: 'Where should a spec be built?',
|
|
36
|
+
hint: '- this is the trade, not a preference',
|
|
37
|
+
initial: 0,
|
|
38
|
+
choices: [
|
|
39
|
+
{
|
|
40
|
+
title: 'Its own worktree (default)',
|
|
41
|
+
value: 'worktree',
|
|
42
|
+
description: 'several specs at once, main left free — one terminal session per spec',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: 'The checkout you are in',
|
|
46
|
+
value: 'checkout',
|
|
47
|
+
description: 'one spec at a time, no second session — your terminal follows the work',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// Orthogonal to isolation, so it is asked unconditionally rather than
|
|
53
|
+
// nested under it — a project can adopt either, both, or neither.
|
|
54
|
+
type: 'confirm',
|
|
55
|
+
name: 'gating',
|
|
56
|
+
message: 'Record a release-gating decision on each spec — flag, or land live?',
|
|
57
|
+
initial: gatingSeed,
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const ans = await prompts(questions, { onCancel })
|
|
62
|
+
if (cancelled) throw new Error('Setup cancelled')
|
|
63
|
+
|
|
64
|
+
// Anything other than an explicit 'checkout' resolves to the default, so a
|
|
65
|
+
// skipped or cancelled-into-default answer can never select the mode that
|
|
66
|
+
// puts a spec's work in the primary checkout.
|
|
67
|
+
return {
|
|
68
|
+
isolation: Boolean(ans.isolation),
|
|
69
|
+
mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
|
|
70
|
+
gating: Boolean(ans.gating),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Interactive confirm for removing leftover release tooling on `update`. Returns
|
|
76
|
+
* true only on an explicit yes; a cancel (Ctrl-C / Esc) resolves to false so the
|
|
77
|
+
* default is always to keep the files.
|
|
78
|
+
*/
|
|
79
|
+
async function confirmRemoveReleaseTooling() {
|
|
80
|
+
const prompts = require('prompts')
|
|
81
|
+
const ans = await prompts(
|
|
82
|
+
{
|
|
83
|
+
type: 'confirm',
|
|
84
|
+
name: 'remove',
|
|
85
|
+
message: 'Found release tooling (now in @skitterbyte/skittership). Remove it here?',
|
|
86
|
+
initial: false,
|
|
87
|
+
},
|
|
88
|
+
{ onCancel: () => false },
|
|
89
|
+
)
|
|
90
|
+
return Boolean(ans.remove)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Interactive choice when `init` finds an already-set-up repo. Returns one of
|
|
95
|
+
* `leave` | `resync` | `reset`. Defaults to (and cancels to) `leave` — the safe
|
|
96
|
+
* no-op — and asks a second confirm before `reset` (destructive to managed files).
|
|
97
|
+
*/
|
|
98
|
+
async function promptExistingSetup() {
|
|
99
|
+
const prompts = require('prompts')
|
|
100
|
+
const { action } = await prompts(
|
|
101
|
+
{
|
|
102
|
+
type: 'select',
|
|
103
|
+
name: 'action',
|
|
104
|
+
message: 'This project already has skitterspec set up. What would you like to do?',
|
|
105
|
+
initial: 0,
|
|
106
|
+
choices: [
|
|
107
|
+
{ title: 'Leave alone — make no changes', value: 'leave' },
|
|
108
|
+
{ title: 'Resync — update managed files to the latest, keep my edits', value: 'resync' },
|
|
109
|
+
{ title: 'Start again — reset the scaffolding (your specs & config are kept)', value: 'reset' },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{ onCancel: () => {} },
|
|
113
|
+
)
|
|
114
|
+
if (action === 'reset') {
|
|
115
|
+
const { confirm } = await prompts(
|
|
116
|
+
{
|
|
117
|
+
type: 'confirm',
|
|
118
|
+
name: 'confirm',
|
|
119
|
+
message: 'Start again overwrites managed skills/rules. Your specs and config are untouched. Continue?',
|
|
120
|
+
initial: false,
|
|
121
|
+
},
|
|
122
|
+
{ onCancel: () => false },
|
|
123
|
+
)
|
|
124
|
+
if (!confirm) return 'leave'
|
|
125
|
+
}
|
|
126
|
+
return action || 'leave'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { promptSetup, confirmRemoveReleaseTooling, promptExistingSetup }
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown ⇄ ADF — the two halves of Jira Cloud's description dialect.
|
|
5
|
+
*
|
|
6
|
+
* Jira Cloud's REST v3 stores rich text as the Atlassian Document Format, a
|
|
7
|
+
* JSON tree — there is no markdown field. The engine's projections are
|
|
8
|
+
* markdown, so the adapter renders outbound text with `mdToAdf` and reduces
|
|
9
|
+
* every read-back with `adfToText`; ADF never escapes the adapter
|
|
10
|
+
* (`api.js` normalises at the boundary).
|
|
11
|
+
*
|
|
12
|
+
* FIDELITY IS WORD-LEVEL, BY DESIGN. Every comparison downstream — verify's
|
|
13
|
+
* `compareStored`, the snapshot's `descriptionStream`, remote-edit detection —
|
|
14
|
+
* reduces text through the dialect's `canonicalForCompare` and then strips to
|
|
15
|
+
* word characters (sync-core `src/verify.js`). So the round-trip contract this
|
|
16
|
+
* module owes is: `stream(adfToText(mdToAdf(md))) === stream(md)` — no word
|
|
17
|
+
* gained, none lost, in order. Byte-level markdown fidelity is deliberately
|
|
18
|
+
* NOT promised: Jira normalises its own tree on save anyway.
|
|
19
|
+
*
|
|
20
|
+
* The subset is what spec projections actually contain: headings, paragraphs,
|
|
21
|
+
* bullet/ordered lists (nested), task checklists, inline strong/em/code/links,
|
|
22
|
+
* fenced code, blockquotes, and top-level tables. Checklists render as ADF
|
|
23
|
+
* task lists (`taskList`/`taskItem`) so they are live checkboxes in Jira; the
|
|
24
|
+
* mirror is still read-only — a box ticked in Jira is overwritten on the next
|
|
25
|
+
* push, exactly like every other mirrored field.
|
|
26
|
+
*
|
|
27
|
+
* Pure string/JSON transforms: no I/O, no clock, no randomness (task-item
|
|
28
|
+
* localIds are sequential — Jira rewrites them on save and nothing here reads
|
|
29
|
+
* them back).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// --- inline: markdown text → ADF text nodes ---------------------------------
|
|
33
|
+
|
|
34
|
+
const text = (value, marks) => {
|
|
35
|
+
const node = { type: 'text', text: value }
|
|
36
|
+
if (marks && marks.length) node.marks = marks
|
|
37
|
+
return node
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Earliest match wins among: `code`, **strong**, *em*, [text](url). A `*`
|
|
41
|
+
// needs a non-space neighbour to open (so `2 * 3` stays literal), mirroring
|
|
42
|
+
// the flanking rule the rest of the toolchain uses.
|
|
43
|
+
function inlineNodes(src, marks = []) {
|
|
44
|
+
const out = []
|
|
45
|
+
let rest = String(src == null ? '' : src)
|
|
46
|
+
while (rest.length) {
|
|
47
|
+
const candidates = [
|
|
48
|
+
{ at: rest.indexOf('`'), kind: 'code' },
|
|
49
|
+
{ at: rest.indexOf('**'), kind: 'strong' },
|
|
50
|
+
{ at: rest.search(/\*(?!\*)(?=\S)/), kind: 'em' },
|
|
51
|
+
{ at: rest.search(/\[[^\]]*\]\([^)]*\)/), kind: 'link' },
|
|
52
|
+
]
|
|
53
|
+
.filter((c) => c.at >= 0)
|
|
54
|
+
.sort((a, b) => a.at - b.at)
|
|
55
|
+
if (!candidates.length) {
|
|
56
|
+
out.push(text(rest, marks))
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
const { at, kind } = candidates[0]
|
|
60
|
+
if (at > 0) out.push(text(rest.slice(0, at), marks))
|
|
61
|
+
rest = rest.slice(at)
|
|
62
|
+
if (kind === 'code') {
|
|
63
|
+
const close = rest.indexOf('`', 1)
|
|
64
|
+
if (close < 0) {
|
|
65
|
+
out.push(text(rest, marks))
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
const body = rest.slice(1, close)
|
|
69
|
+
// ADF puts `code` in an exclusion group: Jira rejects the WHOLE document
|
|
70
|
+
// when a text node carries `code` beside strong/em (or any mark other
|
|
71
|
+
// than `link`, the one companion the schema allows). So inherited
|
|
72
|
+
// emphasis is DROPPED here rather than merged — `**x `+'`'+`y`+'`'+` z**`
|
|
73
|
+
// renders y as plain code, exactly as Jira's own editor treats the same
|
|
74
|
+
// markdown — which loses emphasis, never words (fidelity is word-level
|
|
75
|
+
// by design; see the module header).
|
|
76
|
+
const kept = marks.filter((m) => m.type === 'link')
|
|
77
|
+
if (body) out.push(text(body, [...kept, { type: 'code' }]))
|
|
78
|
+
rest = rest.slice(close + 1)
|
|
79
|
+
} else if (kind === 'strong') {
|
|
80
|
+
const close = rest.indexOf('**', 2)
|
|
81
|
+
if (close < 0) {
|
|
82
|
+
out.push(text(rest, marks))
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
out.push(...inlineNodes(rest.slice(2, close), [...marks, { type: 'strong' }]))
|
|
86
|
+
rest = rest.slice(close + 2)
|
|
87
|
+
} else if (kind === 'em') {
|
|
88
|
+
const m = /^\*(?!\*)([^*]+)\*/.exec(rest)
|
|
89
|
+
if (!m) {
|
|
90
|
+
out.push(text(rest.slice(0, 1), marks))
|
|
91
|
+
rest = rest.slice(1)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
out.push(...inlineNodes(m[1], [...marks, { type: 'em' }]))
|
|
95
|
+
rest = rest.slice(m[0].length)
|
|
96
|
+
} else {
|
|
97
|
+
const m = /^\[([^\]]*)\]\(([^)]*)\)/.exec(rest)
|
|
98
|
+
out.push(...inlineNodes(m[1], [...marks, { type: 'link', attrs: { href: m[2] } }]))
|
|
99
|
+
rest = rest.slice(m[0].length)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out.length ? out : [text('', marks)]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const paragraph = (md) => ({ type: 'paragraph', content: inlineNodes(md) })
|
|
106
|
+
|
|
107
|
+
// --- blocks: markdown lines → ADF nodes -------------------------------------
|
|
108
|
+
|
|
109
|
+
const LIST_RE = /^(\s*)([-*+]|\d+\.)\s+(?:\[([ xX])\]\s+)?(.*)$/
|
|
110
|
+
const FENCE_RE = /^\s*```(.*)$/
|
|
111
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/
|
|
112
|
+
const TABLE_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/
|
|
113
|
+
|
|
114
|
+
// One list ITEM's parsed shape, before nesting is resolved.
|
|
115
|
+
function parseListLines(lines) {
|
|
116
|
+
const items = []
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
const m = LIST_RE.exec(line)
|
|
119
|
+
if (m) {
|
|
120
|
+
items.push({
|
|
121
|
+
indent: m[1].length,
|
|
122
|
+
ordered: /^\d+\.$/.test(m[2]),
|
|
123
|
+
check: m[3] === undefined ? null : m[3] !== ' ',
|
|
124
|
+
text: m[4],
|
|
125
|
+
})
|
|
126
|
+
} else if (items.length) {
|
|
127
|
+
// Continuation line: part of the previous item's text (soft wrap).
|
|
128
|
+
items[items.length - 1].text += ' ' + line.trim()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return items
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fold flat items into nested ADF lists by indent. Task items become an ADF
|
|
135
|
+
// taskList (live checkboxes); plain items a bulletList/orderedList.
|
|
136
|
+
function buildList(items, taskIds, options) {
|
|
137
|
+
const root = { children: [] }
|
|
138
|
+
const stack = [{ indent: -1, node: root }]
|
|
139
|
+
for (const item of items) {
|
|
140
|
+
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) stack.pop()
|
|
141
|
+
const parent = stack[stack.length - 1].node
|
|
142
|
+
const entry = { ...item, children: [] }
|
|
143
|
+
parent.children.push(entry)
|
|
144
|
+
stack.push({ indent: item.indent, node: entry })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function render(children) {
|
|
148
|
+
const groups = []
|
|
149
|
+
for (const child of children) {
|
|
150
|
+
const kind = child.check !== null ? 'task' : child.ordered ? 'ordered' : 'bullet'
|
|
151
|
+
const last = groups[groups.length - 1]
|
|
152
|
+
if (last && last.kind === kind) last.items.push(child)
|
|
153
|
+
else groups.push({ kind, items: [child] })
|
|
154
|
+
}
|
|
155
|
+
return groups.map((group) => {
|
|
156
|
+
if (group.kind === 'task' && options && options.taskLists === false) {
|
|
157
|
+
// The fallback switch: should the live smoke find a surface where Jira
|
|
158
|
+
// drops `taskList` nodes, checklists render as plain bullets carrying
|
|
159
|
+
// their checkbox as text — words preserved, interactivity forfeited.
|
|
160
|
+
return {
|
|
161
|
+
type: 'bulletList',
|
|
162
|
+
content: group.items.map((item) => ({
|
|
163
|
+
type: 'listItem',
|
|
164
|
+
content: [paragraph(`[${item.check ? 'x' : ' '}] ${item.text}`), ...render(item.children)],
|
|
165
|
+
})),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (group.kind === 'task') {
|
|
169
|
+
return {
|
|
170
|
+
type: 'taskList',
|
|
171
|
+
attrs: { localId: `tasks-${++taskIds.n}` },
|
|
172
|
+
content: group.items.map((item) => ({
|
|
173
|
+
type: 'taskItem',
|
|
174
|
+
attrs: { localId: `task-${++taskIds.n}`, state: item.check ? 'DONE' : 'TODO' },
|
|
175
|
+
content: inlineNodes(item.text),
|
|
176
|
+
// ADF task items cannot nest lists; a nested child is rare enough in
|
|
177
|
+
// a projection that flattening it to a following sibling is fine.
|
|
178
|
+
})).concat(group.items.flatMap((item) => render(item.children)).flat()),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const type = group.kind === 'ordered' ? 'orderedList' : 'bulletList'
|
|
182
|
+
return {
|
|
183
|
+
type,
|
|
184
|
+
content: group.items.map((item) => ({
|
|
185
|
+
type: 'listItem',
|
|
186
|
+
content: [paragraph(item.text), ...render(item.children)],
|
|
187
|
+
})),
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return render(root.children)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildTable(rows) {
|
|
195
|
+
const split = (line) => {
|
|
196
|
+
const cells = []
|
|
197
|
+
let cur = ''
|
|
198
|
+
let code = false
|
|
199
|
+
for (const ch of line.replace(/^\s*\|/, '').replace(/\|\s*$/, '')) {
|
|
200
|
+
if (ch === '`') code = !code
|
|
201
|
+
if (ch === '|' && !code) {
|
|
202
|
+
cells.push(cur.trim())
|
|
203
|
+
cur = ''
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
cur += ch
|
|
207
|
+
}
|
|
208
|
+
cells.push(cur.trim())
|
|
209
|
+
return cells
|
|
210
|
+
}
|
|
211
|
+
const bodyRows = rows.filter((r) => !TABLE_SEP_RE.test(r))
|
|
212
|
+
return {
|
|
213
|
+
type: 'table',
|
|
214
|
+
content: bodyRows.map((row, i) => ({
|
|
215
|
+
type: 'tableRow',
|
|
216
|
+
content: split(row).map((cell) => ({
|
|
217
|
+
type: i === 0 ? 'tableHeader' : 'tableCell',
|
|
218
|
+
content: [paragraph(cell)],
|
|
219
|
+
})),
|
|
220
|
+
})),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Markdown → an ADF document (`{ version: 1, type: 'doc', content: [...] }`).
|
|
226
|
+
*/
|
|
227
|
+
function mdToAdf(md, options) {
|
|
228
|
+
const lines = String(md == null ? '' : md).replace(/\r\n/g, '\n').split('\n')
|
|
229
|
+
const content = []
|
|
230
|
+
const taskIds = { n: 0 }
|
|
231
|
+
let i = 0
|
|
232
|
+
while (i < lines.length) {
|
|
233
|
+
const line = lines[i]
|
|
234
|
+
if (!line.trim()) {
|
|
235
|
+
i++
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
const fence = FENCE_RE.exec(line)
|
|
239
|
+
if (fence) {
|
|
240
|
+
const body = []
|
|
241
|
+
i++
|
|
242
|
+
while (i < lines.length && !FENCE_RE.test(lines[i])) body.push(lines[i++])
|
|
243
|
+
if (i < lines.length) i++
|
|
244
|
+
const node = { type: 'codeBlock', content: body.length ? [text(body.join('\n'))] : [] }
|
|
245
|
+
if (fence[1].trim()) node.attrs = { language: fence[1].trim() }
|
|
246
|
+
content.push(node)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const heading = HEADING_RE.exec(line)
|
|
250
|
+
if (heading) {
|
|
251
|
+
content.push({ type: 'heading', attrs: { level: heading[1].length }, content: inlineNodes(heading[2]) })
|
|
252
|
+
i++
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (/^\s*>/.test(line)) {
|
|
256
|
+
const quoted = []
|
|
257
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) quoted.push(lines[i++].replace(/^\s*> ?/, ''))
|
|
258
|
+
content.push({ type: 'blockquote', content: mdToAdf(quoted.join('\n')).content })
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (/^\s*\|.*\|\s*$/.test(line) && TABLE_SEP_RE.test(lines[i + 1] || '')) {
|
|
262
|
+
const rows = []
|
|
263
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(lines[i++])
|
|
264
|
+
content.push(buildTable(rows))
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (LIST_RE.test(line)) {
|
|
268
|
+
const block = []
|
|
269
|
+
while (i < lines.length && lines[i].trim() && (LIST_RE.test(lines[i]) || /^\s{2,}/.test(lines[i]))) {
|
|
270
|
+
block.push(lines[i++])
|
|
271
|
+
}
|
|
272
|
+
content.push(...buildList(parseListLines(block), taskIds, options))
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) {
|
|
276
|
+
content.push({ type: 'rule' })
|
|
277
|
+
i++
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
// Paragraph: consecutive plain lines are one soft-wrapped paragraph.
|
|
281
|
+
const para = []
|
|
282
|
+
while (
|
|
283
|
+
i < lines.length &&
|
|
284
|
+
lines[i].trim() &&
|
|
285
|
+
!FENCE_RE.test(lines[i]) &&
|
|
286
|
+
!HEADING_RE.test(lines[i]) &&
|
|
287
|
+
!LIST_RE.test(lines[i]) &&
|
|
288
|
+
!/^\s*>/.test(lines[i]) &&
|
|
289
|
+
!/^\s*\|.*\|\s*$/.test(lines[i])
|
|
290
|
+
) {
|
|
291
|
+
para.push(lines[i++].trim())
|
|
292
|
+
}
|
|
293
|
+
content.push(paragraph(para.join(' ')))
|
|
294
|
+
}
|
|
295
|
+
return { version: 1, type: 'doc', content }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- ADF → text --------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
function inlineText(nodes) {
|
|
301
|
+
let out = ''
|
|
302
|
+
for (const node of nodes || []) {
|
|
303
|
+
if (node.type === 'text') {
|
|
304
|
+
let t = node.text || ''
|
|
305
|
+
for (const mark of node.marks || []) {
|
|
306
|
+
if (mark.type === 'strong') t = `**${t}**`
|
|
307
|
+
else if (mark.type === 'em') t = `*${t}*`
|
|
308
|
+
else if (mark.type === 'code') t = `\`${t}\``
|
|
309
|
+
else if (mark.type === 'link') t = `[${t}](${(mark.attrs && mark.attrs.href) || ''})`
|
|
310
|
+
}
|
|
311
|
+
out += t
|
|
312
|
+
} else if (node.type === 'hardBreak') {
|
|
313
|
+
out += '\n'
|
|
314
|
+
} else if (node.type === 'mention') {
|
|
315
|
+
out += (node.attrs && node.attrs.text) || ''
|
|
316
|
+
} else if (node.type === 'emoji') {
|
|
317
|
+
out += (node.attrs && node.attrs.text) || ''
|
|
318
|
+
} else if (node.content) {
|
|
319
|
+
out += inlineText(node.content)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function listToText(node, indent, ordered) {
|
|
326
|
+
const lines = []
|
|
327
|
+
let n = 0
|
|
328
|
+
for (const item of node.content || []) {
|
|
329
|
+
n++
|
|
330
|
+
const marker = ordered ? `${n}.` : '-'
|
|
331
|
+
const inner = []
|
|
332
|
+
const nested = []
|
|
333
|
+
for (const child of item.content || []) {
|
|
334
|
+
if (child.type === 'paragraph') inner.push(inlineText(child.content))
|
|
335
|
+
else nested.push(child)
|
|
336
|
+
}
|
|
337
|
+
lines.push(`${indent}${marker} ${inner.join(' ')}`)
|
|
338
|
+
for (const child of nested) lines.push(blockToText(child, indent + ' '))
|
|
339
|
+
}
|
|
340
|
+
return lines.join('\n')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function blockToText(node, indent = '') {
|
|
344
|
+
switch (node.type) {
|
|
345
|
+
case 'heading':
|
|
346
|
+
return `${indent}${'#'.repeat((node.attrs && node.attrs.level) || 1)} ${inlineText(node.content)}`
|
|
347
|
+
case 'paragraph':
|
|
348
|
+
return indent + inlineText(node.content)
|
|
349
|
+
case 'bulletList':
|
|
350
|
+
return listToText(node, indent, false)
|
|
351
|
+
case 'orderedList':
|
|
352
|
+
return listToText(node, indent, true)
|
|
353
|
+
case 'taskList': {
|
|
354
|
+
const lines = []
|
|
355
|
+
for (const item of node.content || []) {
|
|
356
|
+
if (item.type === 'taskItem') {
|
|
357
|
+
const mark = item.attrs && item.attrs.state === 'DONE' ? 'x' : ' '
|
|
358
|
+
lines.push(`${indent}- [${mark}] ${inlineText(item.content)}`)
|
|
359
|
+
} else {
|
|
360
|
+
lines.push(blockToText(item, indent + ' '))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return lines.join('\n')
|
|
364
|
+
}
|
|
365
|
+
case 'codeBlock': {
|
|
366
|
+
const lang = (node.attrs && node.attrs.language) || ''
|
|
367
|
+
return `${indent}\`\`\`${lang}\n${inlineText(node.content)}\n${indent}\`\`\``
|
|
368
|
+
}
|
|
369
|
+
case 'blockquote':
|
|
370
|
+
return (node.content || [])
|
|
371
|
+
.map((child) => blockToText(child, indent))
|
|
372
|
+
.join('\n\n')
|
|
373
|
+
.split('\n')
|
|
374
|
+
.map((l) => `${indent}> ${l}`)
|
|
375
|
+
.join('\n')
|
|
376
|
+
case 'table': {
|
|
377
|
+
const rows = []
|
|
378
|
+
for (const row of node.content || []) {
|
|
379
|
+
const cells = (row.content || []).map((cell) => inlineText((cell.content || [])).replace(/\n/g, ' '))
|
|
380
|
+
rows.push(`${indent}| ${cells.join(' | ')} |`)
|
|
381
|
+
if (!rows.seenSep && row.content && row.content[0] && row.content[0].type === 'tableHeader') {
|
|
382
|
+
rows.push(`${indent}|${' -- |'.repeat(row.content.length)}`)
|
|
383
|
+
rows.seenSep = true
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return rows.join('\n')
|
|
387
|
+
}
|
|
388
|
+
case 'rule':
|
|
389
|
+
return `${indent}---`
|
|
390
|
+
case 'mediaGroup':
|
|
391
|
+
case 'mediaSingle':
|
|
392
|
+
return ''
|
|
393
|
+
default:
|
|
394
|
+
// Unknown block: render its children rather than dropping words —
|
|
395
|
+
// losing content silently is the one failure verify exists to catch.
|
|
396
|
+
return node.content ? node.content.map((child) => blockToText(child, indent)).join('\n\n') : ''
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* An ADF document (or fragment) → markdown-ish text. Accepts anything
|
|
402
|
+
* plausible: a full doc, a content array, a string (returned as-is), or null.
|
|
403
|
+
*/
|
|
404
|
+
function adfToText(adf) {
|
|
405
|
+
if (adf == null) return ''
|
|
406
|
+
if (typeof adf === 'string') return adf
|
|
407
|
+
const content = Array.isArray(adf) ? adf : adf.content || []
|
|
408
|
+
return content
|
|
409
|
+
.map((node) => blockToText(node))
|
|
410
|
+
.filter((s) => s !== '')
|
|
411
|
+
.join('\n\n')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = { mdToAdf, adfToText }
|