@skitterbyte/skitterspec-linear 7.0.1 → 8.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/assets/core/SETUP.md +46 -51
- package/assets/core/linear.config.json.example +2 -2
- package/assets/core/linear.config.md +9 -7
- package/assets/rules/spec-planning.md +13 -7
- package/assets/skills/spec-go/SKILL.md +12 -13
- package/assets/skills/spec-push/SKILL.md +53 -47
- package/assets/skills/spec-status/SKILL.md +31 -26
- package/bin/skitterspec-linear.js +5 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sanitise.js +0 -0
- package/src/vendor/linear/cli-sync.js +123 -204
- package/src/vendor/linear/config.js +14 -6
- package/src/vendor/sync-core/index.js +25 -19
- package/src/vendor/sync-core/src/base.js +8 -10
- package/src/vendor/sync-core/src/compare.js +83 -174
- package/src/vendor/sync-core/src/normalize.js +186 -82
- package/src/vendor/sync-core/src/push.js +39 -133
- package/src/vendor/sync-core/src/sanitise.js +143 -0
- package/src/vendor/sync-core/src/task-block.js +104 -11
- package/src/vendor/sync-core/src/write.js +15 -205
- package/assets/skills/spec-pull/SKILL.md +0 -49
- package/src/vendor/sync-core/src/apply.js +0 -66
- package/src/vendor/sync-core/src/pull.js +0 -115
|
@@ -1,147 +1,53 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `push` — repo →
|
|
4
|
+
* `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
|
|
5
|
+
* generated mirror. We never read remote content: `push` builds the local
|
|
6
|
+
* projection, diffs it against the committed **last-pushed snapshot**, and
|
|
7
|
+
* returns a **create/update plan** the provider skill applies over MCP.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `--force`. It also **re-reads the remote immediately before writing** to catch a
|
|
11
|
-
* writer that raced in during the compare. `--force` makes local win after backing
|
|
12
|
-
* up the remote side. On success it rewrites the base and stamps `last_synced_at`.
|
|
9
|
+
* The skill: applies the plan → stamps each newly-created id back into the phase
|
|
10
|
+
* frontmatter / task line → calls `recordPush`, which re-reads the (now
|
|
11
|
+
* id-stamped) projection and writes the snapshot. That closes the loop: the next
|
|
12
|
+
* `push` sees an unchanged snapshot and produces an empty plan.
|
|
13
13
|
*
|
|
14
|
-
* Pure
|
|
15
|
-
*
|
|
14
|
+
* Pure aside from reading the snapshot; no adapter, no remote read, no
|
|
15
|
+
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const { normalizeLocal
|
|
19
|
-
const {
|
|
20
|
-
const { readBase, writeBase
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
18
|
+
const { normalizeLocal } = require('./normalize.js')
|
|
19
|
+
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
|
+
const { readBase, writeBase } = require('./base.js')
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
// Build the one-way projection from a local snapshot: the project prose + status,
|
|
23
|
+
// and the milestone/issue items. `status` is the local lifecycle bucket; the
|
|
24
|
+
// skill maps it to the Linear project-state NAME via config.states at apply time.
|
|
25
|
+
function projectionOf(snapshotDir, config) {
|
|
24
26
|
const local = normalizeLocal(snapshotDir, config)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Remote moved past base only if a *co-authored* (`both`) field diverged on the
|
|
34
|
-
// remote side — that's the case the repo can't safely overwrite without a pull.
|
|
35
|
-
// For a keyed field the equivalent is a same-item conflict (independent edits to
|
|
36
|
-
// different items don't collide, so they don't block). A `pull`-owned change
|
|
37
|
-
// (status/priority/labels) is Linear's to own and must NOT block a content push,
|
|
38
|
-
// and a bare `updatedAt` bump is too coarse to gate on — the pre-write re-read
|
|
39
|
-
// below still catches a racer that lands during the push itself.
|
|
40
|
-
const remoteDivergedFields = fields
|
|
41
|
-
.filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
42
|
-
.map((f) => f.field)
|
|
43
|
-
for (const f of fields) {
|
|
44
|
-
if (f.keyed) for (const it of f.items) if (it.status === 'conflict') remoteDivergedFields.push(`${f.field}#${it.id}`)
|
|
45
|
-
}
|
|
46
|
-
const moved = remoteDivergedFields.length > 0
|
|
47
|
-
|
|
48
|
-
if (moved && !force) {
|
|
49
|
-
return {
|
|
50
|
-
ok: false,
|
|
51
|
-
blocked: true,
|
|
52
|
-
reason: 'remote-moved',
|
|
53
|
-
movedFields: remoteDivergedFields,
|
|
54
|
-
message:
|
|
55
|
-
'push refused — remote moved since the last sync' +
|
|
56
|
-
(remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
|
|
57
|
-
'. Pull first, or re-run with --force (local wins).',
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Scalar push goes through the project adapter here. Keyed body fields
|
|
62
|
-
// (milestones) can't be written by the offline engine — the provider skill does
|
|
63
|
-
// the MCP create/update and stamps new ids — so the engine emits a *plan* the
|
|
64
|
-
// skill applies. The base still advances to local (below): a created milestone's
|
|
65
|
-
// id:null item is skipped by the keyed compare until the skill stamps it, then
|
|
66
|
-
// it converges on the next sync, so no special base handling is needed.
|
|
67
|
-
const pushFields = fields.filter((f) => f.pushable && !f.keyed)
|
|
68
|
-
// A per-field create/update plan for each keyed collection. The item content
|
|
69
|
-
// (minus its id) is exactly what the skill sends to the Linear save tool.
|
|
70
|
-
const keyedPush = {}
|
|
71
|
-
for (const f of fields) {
|
|
72
|
-
if (!f.keyed) continue
|
|
73
|
-
const strip = (obj) => {
|
|
74
|
-
const { [f.idKey]: _omit, ...rest } = obj
|
|
75
|
-
return rest
|
|
76
|
-
}
|
|
77
|
-
const plan = { create: [], update: [] }
|
|
78
|
-
// Edits to already-linked items (matched by id) → update.
|
|
79
|
-
for (const it of f.items) {
|
|
80
|
-
if (!it.pushable || !it.local) continue
|
|
81
|
-
if (it.status !== 'added') plan.update.push({ id: it.id, ...strip(it.local) })
|
|
82
|
-
}
|
|
83
|
-
// Unlinked local items (no id yet) are new content to create; the keyed
|
|
84
|
-
// compare skips them (nothing to key on), so collect them straight from local.
|
|
85
|
-
const localItems = Array.isArray(local[f.field]) ? local[f.field] : []
|
|
86
|
-
for (const li of localItems) if (li && li[f.idKey] == null) plan.create.push(strip(li))
|
|
87
|
-
if (plan.create.length || plan.update.length) keyedPush[f.field] = plan
|
|
88
|
-
}
|
|
89
|
-
const hasKeyedPush = Object.keys(keyedPush).length > 0
|
|
90
|
-
|
|
91
|
-
if (!pushFields.length && !hasKeyedPush && !force) {
|
|
92
|
-
return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Optimistic concurrency: re-read immediately before writing to catch a racer.
|
|
96
|
-
const remoteRaw2 = await adapter.readProject(projectId)
|
|
97
|
-
if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
|
|
98
|
-
return {
|
|
99
|
-
ok: false,
|
|
100
|
-
blocked: true,
|
|
101
|
-
reason: 'concurrent-write',
|
|
102
|
-
message: 'push refused — remote changed during the push. Pull first, or --force.',
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// --force clobbers the remote side — back it up first.
|
|
107
|
-
let backupPath = null
|
|
108
|
-
if (force) {
|
|
109
|
-
backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const updates = {}
|
|
113
|
-
for (const f of pushFields) updates[f.field] = local[f.field]
|
|
114
|
-
const updated = Object.keys(updates).length
|
|
115
|
-
? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
|
|
116
|
-
: remoteRaw2 || remoteRaw
|
|
117
|
-
const updatedRemote = normalizeRemote(updated, config)
|
|
118
|
-
|
|
119
|
-
// Reconciled base: local is the source of truth for the fields we pushed (and
|
|
120
|
-
// for unchanged/local-only fields); pull-owned fields keep remote's value so
|
|
121
|
-
// they don't read as pending next time.
|
|
122
|
-
const newBase = { ...local }
|
|
123
|
-
for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
|
|
124
|
-
if (own === 'pull') newBase[field] = updatedRemote[field]
|
|
27
|
+
return {
|
|
28
|
+
description: local.description ?? null,
|
|
29
|
+
status: local.workflowState ?? null,
|
|
30
|
+
priority: local.priority ?? null,
|
|
31
|
+
labels: Array.isArray(local.labels) ? local.labels : [],
|
|
32
|
+
milestones: Array.isArray(local.milestones) ? local.milestones : [],
|
|
33
|
+
issues: Array.isArray(local.tasks) ? local.tasks : [],
|
|
125
34
|
}
|
|
126
|
-
|
|
127
|
-
const basePath = writeBase(dir, identifier, config, newBase)
|
|
35
|
+
}
|
|
128
36
|
|
|
129
|
-
|
|
37
|
+
function push({ dir, snapshotDir, identifier, config }) {
|
|
38
|
+
const projection = projectionOf(snapshotDir, config)
|
|
39
|
+
const snapshot = readBase(dir, identifier, config)
|
|
40
|
+
const plan = planChanges(projection, snapshot)
|
|
41
|
+
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
42
|
+
}
|
|
130
43
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
...(keyedPush.tasks ? { issuesPush: keyedPush.tasks } : {}),
|
|
139
|
-
skipped: fields
|
|
140
|
-
.filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
|
|
141
|
-
.map((f) => f.field),
|
|
142
|
-
backupPath,
|
|
143
|
-
basePath,
|
|
144
|
-
}
|
|
44
|
+
/**
|
|
45
|
+
* Record the last-pushed snapshot from the CURRENT files — call after the skill
|
|
46
|
+
* has applied the plan and stamped new ids. Returns the snapshot path.
|
|
47
|
+
*/
|
|
48
|
+
function recordPush({ dir, snapshotDir, identifier, config }) {
|
|
49
|
+
const projection = projectionOf(snapshotDir, config)
|
|
50
|
+
return writeBase(dir, identifier, config, snapshotOf(projection))
|
|
145
51
|
}
|
|
146
52
|
|
|
147
|
-
module.exports = { push }
|
|
53
|
+
module.exports = { push, recordPush, projectionOf }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-time sanitiser: rewrite spec markdown so no inline emphasis or link span
|
|
5
|
+
* straddles a hard line break, and repair any Linear-mangled `****` artifacts
|
|
6
|
+
* already committed. This brings hand-wrapped spec files "inline" so they round-
|
|
7
|
+
* trip through Linear cleanly (Linear mangles a straddling `**`/`*`/link on save).
|
|
8
|
+
*
|
|
9
|
+
* Deliberately minimal-diff: only a block that actually contains a straddle or a
|
|
10
|
+
* mangle is reflowed — every other paragraph, table, code fence, and heading is
|
|
11
|
+
* left byte-for-byte untouched. A reflowed block is re-wrapped emphasis-aware to
|
|
12
|
+
* the file's own inferred width, so spans stay whole. Idempotent: a second run is
|
|
13
|
+
* a no-op.
|
|
14
|
+
*
|
|
15
|
+
* Pure string→string (`sanitizeSpecMarkdown`); the CLI wraps it with fs walking.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { wrapEmphasisAware, collapseHyphenAware, inferWidth, DEFAULT_WIDTH } = require('./task-block.js')
|
|
19
|
+
const { joinEmphasisAcrossBreaks } = require('./normalize.js')
|
|
20
|
+
|
|
21
|
+
// A run of non-blank lines contains an emphasis straddle / mangle iff joining
|
|
22
|
+
// spans across breaks changes it.
|
|
23
|
+
function hasStraddle(text) {
|
|
24
|
+
return joinEmphasisAcrossBreaks(text) !== text
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Turn a multi-line region into its clean single logical line: repair mangles and
|
|
28
|
+
// join straddles first (needs the newlines), then collapse whitespace — hyphen-
|
|
29
|
+
// aware, so a compound wrapped at the hyphen isn't spaced out.
|
|
30
|
+
function cleanLogicalLine(text) {
|
|
31
|
+
return collapseHyphenAware(joinEmphasisAcrossBreaks(text))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
|
|
35
|
+
// checkbox), then the text.
|
|
36
|
+
const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
|
|
37
|
+
// Structural blocks we never reflow.
|
|
38
|
+
const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
|
|
39
|
+
// A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
|
|
40
|
+
// dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
|
|
41
|
+
// inline `code|span` doesn't make us treat a whole list as an untouchable table.
|
|
42
|
+
const TABLE_SEP_RE = /^[\s|:-]*\|[\s|:-]*-[\s|:-]*$|^[\s|:-]*-[\s|:-]*\|[\s|:-]*$/
|
|
43
|
+
|
|
44
|
+
// Split a block (consecutive non-blank lines) into items when it's a list; each
|
|
45
|
+
// item is its bullet line plus more-indented continuation lines.
|
|
46
|
+
function splitListItems(block) {
|
|
47
|
+
const items = []
|
|
48
|
+
let cur = null
|
|
49
|
+
for (const line of block) {
|
|
50
|
+
if (BULLET_RE.test(line)) {
|
|
51
|
+
cur = [line]
|
|
52
|
+
items.push(cur)
|
|
53
|
+
} else if (cur) {
|
|
54
|
+
cur.push(line) // continuation of the current bullet
|
|
55
|
+
} else {
|
|
56
|
+
return null // leading non-bullet line — not a clean list block
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return items
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reflow one bullet item (raw lines) to width, emphasis-aware. Returns the item's
|
|
63
|
+
// lines unchanged when it carries no straddle.
|
|
64
|
+
function sanitizeItem(itemLines, width) {
|
|
65
|
+
const raw = itemLines.join('\n')
|
|
66
|
+
if (!hasStraddle(raw)) return { lines: itemLines, fixed: false }
|
|
67
|
+
const m = BULLET_RE.exec(itemLines[0])
|
|
68
|
+
const indent = m[1]
|
|
69
|
+
const marker = m[2]
|
|
70
|
+
const firstPrefix = indent + marker
|
|
71
|
+
const hang = ' '.repeat(firstPrefix.length)
|
|
72
|
+
// Body = the item's text (first line after the marker + continuations).
|
|
73
|
+
const bodyRaw = [m[3], ...itemLines.slice(1)].join('\n')
|
|
74
|
+
const body = cleanLogicalLine(bodyRaw)
|
|
75
|
+
return { lines: wrapEmphasisAware(body, { firstPrefix, hang, width }), fixed: true }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reflow a plain prose paragraph (raw lines) to width, preserving its leading
|
|
79
|
+
// indent. Unchanged when it carries no straddle.
|
|
80
|
+
function sanitizeProse(block, width) {
|
|
81
|
+
const raw = block.join('\n')
|
|
82
|
+
if (!hasStraddle(raw)) return { lines: block, fixed: false }
|
|
83
|
+
const indent = (/^(\s*)/.exec(block[0]) || ['', ''])[1]
|
|
84
|
+
const body = cleanLogicalLine(raw)
|
|
85
|
+
return { lines: wrapEmphasisAware(body, { firstPrefix: indent, hang: indent, width }), fixed: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sanitise one markdown document.
|
|
90
|
+
* @returns {{ text:string, changed:boolean, fixes:number }} fixes = blocks/items reflowed.
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeSpecMarkdown(text, { width } = {}) {
|
|
93
|
+
const src = String(text)
|
|
94
|
+
const lines = src.split('\n')
|
|
95
|
+
const w = width || inferWidth(lines) || DEFAULT_WIDTH
|
|
96
|
+
const out = []
|
|
97
|
+
let i = 0
|
|
98
|
+
let fixes = 0
|
|
99
|
+
while (i < lines.length) {
|
|
100
|
+
const line = lines[i]
|
|
101
|
+
// Fenced code — copy verbatim through the closing fence.
|
|
102
|
+
if (/^[ \t]*```/.test(line)) {
|
|
103
|
+
out.push(line)
|
|
104
|
+
i++
|
|
105
|
+
while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
|
|
106
|
+
if (i < lines.length) out.push(lines[i++])
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (!line.trim()) {
|
|
110
|
+
out.push(line)
|
|
111
|
+
i++
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
// Gather a block of consecutive non-blank, non-fence lines.
|
|
115
|
+
let j = i
|
|
116
|
+
while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
|
|
117
|
+
const block = lines.slice(i, j)
|
|
118
|
+
i = j
|
|
119
|
+
|
|
120
|
+
// Never reflow structural blocks (headings, tables, quotes, rules, HTML).
|
|
121
|
+
if (PASSTHROUGH_RE.test(block[0]) || block.some((l) => TABLE_SEP_RE.test(l))) {
|
|
122
|
+
out.push(...block)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const items = BULLET_RE.test(block[0]) ? splitListItems(block) : null
|
|
127
|
+
if (items) {
|
|
128
|
+
for (const it of items) {
|
|
129
|
+
const r = sanitizeItem(it, w)
|
|
130
|
+
out.push(...r.lines)
|
|
131
|
+
if (r.fixed) fixes++
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
const r = sanitizeProse(block, w)
|
|
135
|
+
out.push(...r.lines)
|
|
136
|
+
if (r.fixed) fixes++
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const result = out.join('\n')
|
|
140
|
+
return { text: result, changed: result !== src, fixes }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { sanitizeSpecMarkdown, hasStraddle }
|
|
@@ -26,6 +26,51 @@ function collapse(text) {
|
|
|
26
26
|
return String(text).replace(/\s+/g, ' ').trim()
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
|
|
30
|
+
// `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
|
|
31
|
+
// other break collapses to a single space. Use this wherever hand-wrapped prose
|
|
32
|
+
// is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
|
|
33
|
+
function collapseHyphenAware(text) {
|
|
34
|
+
return String(text)
|
|
35
|
+
.replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Mark every character of `body` that lies inside an inline emphasis or link
|
|
41
|
+
// span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
|
|
42
|
+
// one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
|
|
43
|
+
// spans are exempt (Linear only rejoins them, harmlessly) but their contents are
|
|
44
|
+
// neutralised first so a `*` inside code can't spoof an italic marker.
|
|
45
|
+
function spanMask(body) {
|
|
46
|
+
const mask = new Array(body.length).fill(false)
|
|
47
|
+
// Neutralise code-span contents to same-length filler (indices stay aligned).
|
|
48
|
+
const chars = body.split('')
|
|
49
|
+
let m
|
|
50
|
+
const codeRe = /`[^`]*`/g
|
|
51
|
+
while ((m = codeRe.exec(body)) !== null) {
|
|
52
|
+
for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
|
|
53
|
+
}
|
|
54
|
+
let masked = chars.join('')
|
|
55
|
+
const cover = (re) => {
|
|
56
|
+
re.lastIndex = 0
|
|
57
|
+
let mm
|
|
58
|
+
while ((mm = re.exec(masked)) !== null) {
|
|
59
|
+
for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
|
|
60
|
+
if (mm[0].length === 0) re.lastIndex++
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
cover(/\*\*.+?\*\*/g) // bold
|
|
64
|
+
cover(/\[[^\]]*\]\([^)]*\)/g) // link
|
|
65
|
+
// Neutralise the bold/link spans already found, so their asterisks aren't
|
|
66
|
+
// reused when scanning for single-`*` italics.
|
|
67
|
+
const m2 = masked.split('')
|
|
68
|
+
for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
|
|
69
|
+
masked = m2.join('')
|
|
70
|
+
cover(/\*[^*]+?\*/g) // italic
|
|
71
|
+
return mask
|
|
72
|
+
}
|
|
73
|
+
|
|
29
74
|
/**
|
|
30
75
|
* Find every task bullet in `lines` as a logical block.
|
|
31
76
|
* @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
|
|
@@ -50,7 +95,7 @@ function findTaskBlocks(lines) {
|
|
|
50
95
|
end: j,
|
|
51
96
|
indent: m[1],
|
|
52
97
|
mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
|
|
53
|
-
text:
|
|
98
|
+
text: collapseHyphenAware(parts.join('\n')),
|
|
54
99
|
})
|
|
55
100
|
i = j - 1
|
|
56
101
|
}
|
|
@@ -62,16 +107,34 @@ function findTaskBlocks(lines) {
|
|
|
62
107
|
* style: `- [x] ` opener, continuations aligned under the text.
|
|
63
108
|
* @returns {string[]}
|
|
64
109
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
110
|
+
// Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
|
|
111
|
+
// only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
|
|
112
|
+
// emphasis run never straddles a line (Linear mangles one that does). The first
|
|
113
|
+
// line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
|
|
114
|
+
// single span overflows rather than being split.
|
|
115
|
+
function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
|
|
116
|
+
const mask = spanMask(body)
|
|
117
|
+
const words = body.split(' ')
|
|
118
|
+
// The index in `body` of the space that precedes each word.
|
|
119
|
+
const spaceBefore = []
|
|
120
|
+
let pos = 0
|
|
121
|
+
for (let k = 0; k < words.length; k++) {
|
|
122
|
+
if (k > 0) {
|
|
123
|
+
spaceBefore[k] = pos
|
|
124
|
+
pos += 1
|
|
125
|
+
}
|
|
126
|
+
pos += words[k].length
|
|
127
|
+
}
|
|
69
128
|
|
|
70
129
|
const out = []
|
|
71
|
-
let line =
|
|
130
|
+
let line = firstPrefix
|
|
72
131
|
let first = true
|
|
73
|
-
for (
|
|
74
|
-
|
|
132
|
+
for (let k = 0; k < words.length; k++) {
|
|
133
|
+
const word = words[k]
|
|
134
|
+
// Break only at a safe gap; a gap inside a span overflows instead of
|
|
135
|
+
// splitting it (so an over-width single span stays whole on one line).
|
|
136
|
+
const canBreak = k > 0 && !mask[spaceBefore[k]]
|
|
137
|
+
if (!first && canBreak && line.length + 1 + word.length > width) {
|
|
75
138
|
out.push(line)
|
|
76
139
|
line = hang + word
|
|
77
140
|
} else {
|
|
@@ -83,13 +146,43 @@ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH)
|
|
|
83
146
|
return out
|
|
84
147
|
}
|
|
85
148
|
|
|
149
|
+
function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
|
|
150
|
+
const opener = `${indent}- [${done ? 'x' : ' '}] `
|
|
151
|
+
const hang = ' '.repeat(opener.length)
|
|
152
|
+
const body = collapse(text) + (id ? ` (${id})` : '')
|
|
153
|
+
return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
|
|
154
|
+
}
|
|
155
|
+
|
|
86
156
|
// Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
|
|
87
|
-
// different column.
|
|
157
|
+
// different column. Only *prose* lines count — a single wide table row or a long
|
|
158
|
+
// line of fenced code would otherwise pull the whole file's prose to a wider
|
|
159
|
+
// column than the author wrapped at. Falls back to the default when there's
|
|
160
|
+
// nothing prose-like to learn from.
|
|
88
161
|
function inferWidth(lines, fallback = DEFAULT_WIDTH) {
|
|
89
|
-
|
|
162
|
+
let inFence = false
|
|
163
|
+
const widths = []
|
|
164
|
+
for (const l of lines) {
|
|
165
|
+
if (/^[ \t]*```/.test(l)) {
|
|
166
|
+
inFence = !inFence
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (inFence) continue
|
|
170
|
+
if (!l.trim()) continue
|
|
171
|
+
if (l.includes('|')) continue // table row
|
|
172
|
+
widths.push(l.length)
|
|
173
|
+
}
|
|
90
174
|
if (!widths.length) return fallback
|
|
91
175
|
const max = Math.max(...widths)
|
|
92
176
|
return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
|
|
93
177
|
}
|
|
94
178
|
|
|
95
|
-
module.exports = {
|
|
179
|
+
module.exports = {
|
|
180
|
+
findTaskBlocks,
|
|
181
|
+
renderTaskBlock,
|
|
182
|
+
wrapEmphasisAware,
|
|
183
|
+
spanMask,
|
|
184
|
+
collapse,
|
|
185
|
+
collapseHyphenAware,
|
|
186
|
+
inferWidth,
|
|
187
|
+
DEFAULT_WIDTH,
|
|
188
|
+
}
|