@skitterbyte/skitterspec-linear 7.0.1 → 7.0.2

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.
@@ -190,3 +190,7 @@ queried directly.)
190
190
  - Never delete historical notes.
191
191
  - The spec file is the single source of truth for implementation progress.
192
192
  - Move specs between buckets with `git mv` to preserve history.
193
+ - Never let inline emphasis or a link cross a hard line break — keep a whole
194
+ `**bold**`, `*italic*`, or `[text](url)` on one line (let it overflow the wrap
195
+ column rather than splitting it). Many round-tripping editors mangle a
196
+ `**`/`*`/link span that straddles a newline, so clean source avoids the churn.
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { run } = require('../src/cli.js')
12
12
  const { specSync } = require('../src/vendor/linear/cli-sync.js')
13
+ const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
13
14
 
14
15
  async function main(argv) {
15
16
  const [cmd, ...rest] = argv
@@ -17,6 +18,10 @@ async function main(argv) {
17
18
  await specSync(rest)
18
19
  return
19
20
  }
21
+ if (cmd === 'spec-sanitise') {
22
+ process.exitCode = await specSanitise(rest)
23
+ return
24
+ }
20
25
  await run(argv)
21
26
  }
22
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "7.0.1",
3
+ "version": "7.0.2",
4
4
  "description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,101 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `spec-sanitise` — a one-time maintenance pass over spec markdown.
5
+ *
6
+ * Rewrites files so no inline emphasis (`**bold**`, `*italic*`) or link text
7
+ * straddles a hard line break, and repairs any Linear-mangled `****` artifacts
8
+ * already committed. Hand-wrapped specs acquire straddles over time; Linear
9
+ * corrupts them on save, so this brings the corpus "inline" to round-trip cleanly.
10
+ *
11
+ * Dry-run by default (reports the files it WOULD change); pass `--write` to apply.
12
+ * Minimal-diff and idempotent — only paragraphs/items that actually straddle are
13
+ * reflowed (see sync-core `sanitizeSpecMarkdown`).
14
+ *
15
+ * skitterspec-linear spec-sanitise [paths...] [--write] [--width N]
16
+ *
17
+ * Defaults to scanning `specs/`. Exit code 1 in dry-run when changes are pending
18
+ * (so it's CI-friendly), 0 once everything is clean.
19
+ */
20
+
21
+ const fs = require('node:fs')
22
+ const path = require('node:path')
23
+ const { sanitizeSpecMarkdown } = require('../sync-core')
24
+
25
+ // Recursively collect *.md files under a path (a file path is returned as-is).
26
+ function collectMarkdown(target) {
27
+ const out = []
28
+ const walk = (p) => {
29
+ let st
30
+ try {
31
+ st = fs.statSync(p)
32
+ } catch {
33
+ return
34
+ }
35
+ if (st.isDirectory()) {
36
+ if (/(^|\/)(node_modules|\.git)$/.test(p)) return
37
+ for (const name of fs.readdirSync(p).sort()) walk(path.join(p, name))
38
+ } else if (st.isFile() && p.endsWith('.md')) {
39
+ out.push(p)
40
+ }
41
+ }
42
+ walk(target)
43
+ return out
44
+ }
45
+
46
+ function parseArgs(argv) {
47
+ const paths = []
48
+ let write = false
49
+ let width = null
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i]
52
+ if (a === '--write') write = true
53
+ else if (a === '--width') width = parseInt(argv[++i], 10)
54
+ else if (a === '--help' || a === '-h') return { help: true }
55
+ else paths.push(a)
56
+ }
57
+ if (!paths.length) paths.push('specs')
58
+ return { paths, write, width: Number.isFinite(width) ? width : null }
59
+ }
60
+
61
+ async function specSanitise(argv, { cwd = process.cwd(), out = process.stdout } = {}) {
62
+ const opts = parseArgs(argv)
63
+ if (opts.help) {
64
+ out.write('Usage: skitterspec-linear spec-sanitise [paths...] [--write] [--width N]\n')
65
+ return 0
66
+ }
67
+
68
+ const files = new Set()
69
+ for (const p of opts.paths) for (const f of collectMarkdown(path.resolve(cwd, p))) files.add(f)
70
+
71
+ let changedFiles = 0
72
+ let totalFixes = 0
73
+ for (const file of [...files].sort()) {
74
+ const src = fs.readFileSync(file, 'utf-8')
75
+ const res = sanitizeSpecMarkdown(src, opts.width ? { width: opts.width } : {})
76
+ if (!res.changed) continue
77
+ changedFiles++
78
+ totalFixes += res.fixes
79
+ const rel = path.relative(cwd, file)
80
+ if (opts.write) {
81
+ fs.writeFileSync(file, res.text, 'utf-8')
82
+ out.write(`fixed ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
83
+ } else {
84
+ out.write(`would fix ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
85
+ }
86
+ }
87
+
88
+ if (!changedFiles) {
89
+ out.write(`spec-sanitise: clean — no straddling spans in ${files.size} file(s).\n`)
90
+ return 0
91
+ }
92
+ const verb = opts.write ? 'Fixed' : 'Would fix'
93
+ out.write(`\n${verb} ${totalFixes} span(s) across ${changedFiles} file(s).\n`)
94
+ if (!opts.write) {
95
+ out.write('Re-run with --write to apply. Review the diff before committing.\n')
96
+ return 1
97
+ }
98
+ return 0
99
+ }
100
+
101
+ module.exports = { specSanitise, collectMarkdown, parseArgs }
@@ -16,6 +16,7 @@ const { pull } = require('./src/pull.js')
16
16
  const { push } = require('./src/push.js')
17
17
  const { writeFrontmatter } = require('./src/write.js')
18
18
  const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
19
+ const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
20
 
20
21
  module.exports = {
21
22
  normalizeLocal,
@@ -32,4 +33,5 @@ module.exports = {
32
33
  writeFrontmatter,
33
34
  frontmatterPatchFor,
34
35
  localWorkflowState,
36
+ sanitizeSpecMarkdown,
35
37
  }
@@ -83,21 +83,123 @@ function parseSections(body) {
83
83
  return { title, sections }
84
84
  }
85
85
 
86
+ // Linear mangles inline emphasis whose markers straddle a hard line break: it
87
+ // terminates the run at end-of-line and restarts it at the next, so `**a\nb**`
88
+ // comes back as `**a****\n****b**`, `*a\nb*` as `*a**\n**b*`, and a link splits
89
+ // into two. Canonicalise BOTH representations — the clean straddle we author and
90
+ // the mangled form Linear returns — to the same single-line span, so an
91
+ // already-mangled remote stops reading as a spurious `remote-only` diff and the
92
+ // payload we push carries no straddle for Linear to mangle again. Idempotent: a
93
+ // joined span has no interior newline, so nothing re-fires. Repo files are never
94
+ // rewritten — this only shapes the normalized projection the compare/push see.
95
+ function joinEmphasisAcrossBreaks(text) {
96
+ let s = String(text)
97
+ // (1) Repair Linear's mangle artifacts first — an emphasis run terminated at
98
+ // end-of-line and restarted at the next. These are very specific asterisk runs
99
+ // flanking a break, so a targeted regex is safe:
100
+ // bold: `**X****\n****Y**` → the `****\n****` empty-bold artifact → a space.
101
+ s = s.replace(/\*{4}[ \t]*\n[ \t]*\*{4}/g, ' ')
102
+ // italic: `*X**\n**Y*` — the `**\n**` artifact sits INSIDE a single-`*` span;
103
+ // gate on the enclosing single `*` so a genuine pair of adjacent bolds at a
104
+ // line boundary (`a**\n**b`) is left alone.
105
+ s = s.replace(/(^|[^*\n])\*([^*\n]+)\*\*[ \t]*\n[ \t]*\*\*([^*\n]+)\*(?![*])/g, '$1*$2 $3*')
106
+ // link split across a break onto the same url → one link.
107
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)[ \t]*\n[ \t]*\[([^\]]+)\]\(\2\)/g, '[$1 $3]($2)')
108
+ // (2) Join the clean straddle we author — a newline that falls while an
109
+ // emphasis/link span is OPEN. A scanner (not a regex) so an opening `**` is
110
+ // never mis-paired with an unrelated closing `**` on the next line, and markers
111
+ // inside a `code span` are ignored (Linear only rejoins those, harmlessly).
112
+ return joinOpenSpans(s)
113
+ }
114
+
115
+ // Walk the text tracking whether we're inside a `**bold**`, `*italic*`, `[link
116
+ // text]`/`(url)`, or `` `code` `` span. A newline encountered while a
117
+ // non-code span is open joins the two lines with a single space (dropping the
118
+ // next line's indentation); every other newline is preserved.
119
+ function joinOpenSpans(text) {
120
+ const out = []
121
+ let bold = false
122
+ let italic = false
123
+ let code = false
124
+ let link = 0 // 0 none · 1 in link text · 2 in url
125
+ for (let i = 0; i < text.length; i++) {
126
+ const c = text[i]
127
+ if (c === '\n') {
128
+ if (!code && (bold || italic || link)) {
129
+ out.push(' ')
130
+ while (i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\t')) i++
131
+ } else {
132
+ out.push('\n')
133
+ }
134
+ continue
135
+ }
136
+ if (c === '`') {
137
+ code = !code
138
+ out.push(c)
139
+ continue
140
+ }
141
+ if (code) {
142
+ out.push(c)
143
+ continue
144
+ }
145
+ if (c === '*' && text[i + 1] === '*') {
146
+ bold = !bold
147
+ out.push('**')
148
+ i++
149
+ continue
150
+ }
151
+ if (c === '*') {
152
+ // Basic flanking so a stray `*` (e.g. `2 * 3`) doesn't open a phantom span:
153
+ // an opener needs a non-space to its right, a closer a non-space to its left.
154
+ const ok = italic ? !/\s/.test(text[i - 1] || '') : !/\s/.test(text[i + 1] || '')
155
+ if (ok) italic = !italic
156
+ out.push(c)
157
+ continue
158
+ }
159
+ if (c === '[' && link === 0) {
160
+ link = 1
161
+ out.push(c)
162
+ continue
163
+ }
164
+ if (c === ']' && link === 1) {
165
+ if (text[i + 1] === '(') {
166
+ link = 2
167
+ out.push('](')
168
+ i++
169
+ } else {
170
+ link = 0
171
+ out.push(c)
172
+ }
173
+ continue
174
+ }
175
+ if (c === ')' && link === 2) {
176
+ link = 0
177
+ out.push(c)
178
+ continue
179
+ }
180
+ out.push(c)
181
+ }
182
+ return out.join('')
183
+ }
184
+
86
185
  // Canonicalise markdown so semantically-equal content hashes equal across the
87
186
  // boundary. Linear reserializes markdown on save (authored `-` bullets come back
88
- // as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
89
- // clean push→pull would report `description` as perpetually changed. Applied to
90
- // the description on BOTH sides. Conservative: only unifies list markers and
91
- // whitespace the transforms actually observed from Linear.
187
+ // as `*`, trailing whitespace trimmed, blank runs collapsed, emphasis spanning a
188
+ // line break mangled), so without this a clean push→pull would report
189
+ // `description` as perpetually changed. Applied to the description on BOTH sides.
190
+ // Conservative: only unifies list markers, whitespace, and emphasis-across-a-break
191
+ // — the transforms actually observed from Linear.
92
192
  function canonicalizeMarkdown(text) {
93
193
  if (text == null) return text
94
- return String(text)
194
+ const marked = String(text)
95
195
  .replace(/\r\n/g, '\n')
96
196
  .split('\n')
97
197
  // Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
98
- // after the marker so bold/emphasis (`**Goal:**`) is untouched.
198
+ // after the marker so bold/emphasis (`**Goal:**`) is untouched. Done BEFORE
199
+ // the emphasis join so a `*` list bullet can't spoof an italic delimiter.
99
200
  .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
100
201
  .join('\n')
202
+ return joinEmphasisAcrossBreaks(marked)
101
203
  .replace(/\n{3,}/g, '\n\n')
102
204
  .trim()
103
205
  }
@@ -378,5 +480,6 @@ module.exports = {
378
480
  parseTaskLine,
379
481
  canonicalRemoteStatus,
380
482
  canonicalizeMarkdown,
483
+ joinEmphasisAcrossBreaks,
381
484
  bucketForState,
382
485
  }
@@ -0,0 +1,147 @@
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 {
19
+ wrapEmphasisAware,
20
+ collapse,
21
+ inferWidth,
22
+ DEFAULT_WIDTH,
23
+ } = require('./task-block.js')
24
+ const { joinEmphasisAcrossBreaks } = require('./normalize.js')
25
+
26
+ // A run of non-blank lines contains an emphasis straddle / mangle iff joining
27
+ // spans across breaks changes it.
28
+ function hasStraddle(text) {
29
+ return joinEmphasisAcrossBreaks(text) !== text
30
+ }
31
+
32
+ // Turn a multi-line region into its clean single logical line: repair mangles and
33
+ // join straddles first (needs the newlines), then collapse whitespace.
34
+ function cleanLogicalLine(text) {
35
+ return collapse(joinEmphasisAcrossBreaks(text))
36
+ }
37
+
38
+ // A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
39
+ // checkbox), then the text.
40
+ const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
41
+ // Structural blocks we never reflow.
42
+ const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
43
+ // A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
44
+ // dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
45
+ // inline `code|span` doesn't make us treat a whole list as an untouchable table.
46
+ const TABLE_SEP_RE = /^[\s|:-]*\|[\s|:-]*-[\s|:-]*$|^[\s|:-]*-[\s|:-]*\|[\s|:-]*$/
47
+
48
+ // Split a block (consecutive non-blank lines) into items when it's a list; each
49
+ // item is its bullet line plus more-indented continuation lines.
50
+ function splitListItems(block) {
51
+ const items = []
52
+ let cur = null
53
+ for (const line of block) {
54
+ if (BULLET_RE.test(line)) {
55
+ cur = [line]
56
+ items.push(cur)
57
+ } else if (cur) {
58
+ cur.push(line) // continuation of the current bullet
59
+ } else {
60
+ return null // leading non-bullet line — not a clean list block
61
+ }
62
+ }
63
+ return items
64
+ }
65
+
66
+ // Reflow one bullet item (raw lines) to width, emphasis-aware. Returns the item's
67
+ // lines unchanged when it carries no straddle.
68
+ function sanitizeItem(itemLines, width) {
69
+ const raw = itemLines.join('\n')
70
+ if (!hasStraddle(raw)) return { lines: itemLines, fixed: false }
71
+ const m = BULLET_RE.exec(itemLines[0])
72
+ const indent = m[1]
73
+ const marker = m[2]
74
+ const firstPrefix = indent + marker
75
+ const hang = ' '.repeat(firstPrefix.length)
76
+ // Body = the item's text (first line after the marker + continuations).
77
+ const bodyRaw = [m[3], ...itemLines.slice(1)].join('\n')
78
+ const body = cleanLogicalLine(bodyRaw)
79
+ return { lines: wrapEmphasisAware(body, { firstPrefix, hang, width }), fixed: true }
80
+ }
81
+
82
+ // Reflow a plain prose paragraph (raw lines) to width, preserving its leading
83
+ // indent. Unchanged when it carries no straddle.
84
+ function sanitizeProse(block, width) {
85
+ const raw = block.join('\n')
86
+ if (!hasStraddle(raw)) return { lines: block, fixed: false }
87
+ const indent = (/^(\s*)/.exec(block[0]) || ['', ''])[1]
88
+ const body = cleanLogicalLine(raw)
89
+ return { lines: wrapEmphasisAware(body, { firstPrefix: indent, hang: indent, width }), fixed: true }
90
+ }
91
+
92
+ /**
93
+ * Sanitise one markdown document.
94
+ * @returns {{ text:string, changed:boolean, fixes:number }} fixes = blocks/items reflowed.
95
+ */
96
+ function sanitizeSpecMarkdown(text, { width } = {}) {
97
+ const src = String(text)
98
+ const lines = src.split('\n')
99
+ const w = width || inferWidth(lines) || DEFAULT_WIDTH
100
+ const out = []
101
+ let i = 0
102
+ let fixes = 0
103
+ while (i < lines.length) {
104
+ const line = lines[i]
105
+ // Fenced code — copy verbatim through the closing fence.
106
+ if (/^[ \t]*```/.test(line)) {
107
+ out.push(line)
108
+ i++
109
+ while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
110
+ if (i < lines.length) out.push(lines[i++])
111
+ continue
112
+ }
113
+ if (!line.trim()) {
114
+ out.push(line)
115
+ i++
116
+ continue
117
+ }
118
+ // Gather a block of consecutive non-blank, non-fence lines.
119
+ let j = i
120
+ while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
121
+ const block = lines.slice(i, j)
122
+ i = j
123
+
124
+ // Never reflow structural blocks (headings, tables, quotes, rules, HTML).
125
+ if (PASSTHROUGH_RE.test(block[0]) || block.some((l) => TABLE_SEP_RE.test(l))) {
126
+ out.push(...block)
127
+ continue
128
+ }
129
+
130
+ const items = BULLET_RE.test(block[0]) ? splitListItems(block) : null
131
+ if (items) {
132
+ for (const it of items) {
133
+ const r = sanitizeItem(it, w)
134
+ out.push(...r.lines)
135
+ if (r.fixed) fixes++
136
+ }
137
+ } else {
138
+ const r = sanitizeProse(block, w)
139
+ out.push(...r.lines)
140
+ if (r.fixed) fixes++
141
+ }
142
+ }
143
+ const result = out.join('\n')
144
+ return { text: result, changed: result !== src, fixes }
145
+ }
146
+
147
+ module.exports = { sanitizeSpecMarkdown, hasStraddle }
@@ -26,6 +26,40 @@ function collapse(text) {
26
26
  return String(text).replace(/\s+/g, ' ').trim()
27
27
  }
28
28
 
29
+ // Mark every character of `body` that lies inside an inline emphasis or link
30
+ // span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
31
+ // one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
32
+ // spans are exempt (Linear only rejoins them, harmlessly) but their contents are
33
+ // neutralised first so a `*` inside code can't spoof an italic marker.
34
+ function spanMask(body) {
35
+ const mask = new Array(body.length).fill(false)
36
+ // Neutralise code-span contents to same-length filler (indices stay aligned).
37
+ const chars = body.split('')
38
+ let m
39
+ const codeRe = /`[^`]*`/g
40
+ while ((m = codeRe.exec(body)) !== null) {
41
+ for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
42
+ }
43
+ let masked = chars.join('')
44
+ const cover = (re) => {
45
+ re.lastIndex = 0
46
+ let mm
47
+ while ((mm = re.exec(masked)) !== null) {
48
+ for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
49
+ if (mm[0].length === 0) re.lastIndex++
50
+ }
51
+ }
52
+ cover(/\*\*.+?\*\*/g) // bold
53
+ cover(/\[[^\]]*\]\([^)]*\)/g) // link
54
+ // Neutralise the bold/link spans already found, so their asterisks aren't
55
+ // reused when scanning for single-`*` italics.
56
+ const m2 = masked.split('')
57
+ for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
58
+ masked = m2.join('')
59
+ cover(/\*[^*]+?\*/g) // italic
60
+ return mask
61
+ }
62
+
29
63
  /**
30
64
  * Find every task bullet in `lines` as a logical block.
31
65
  * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
@@ -62,16 +96,34 @@ function findTaskBlocks(lines) {
62
96
  * style: `- [x] ` opener, continuations aligned under the text.
63
97
  * @returns {string[]}
64
98
  */
65
- function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
66
- const opener = `${indent}- [${done ? 'x' : ' '}] `
67
- const hang = ' '.repeat(opener.length)
68
- const body = collapse(text) + (id ? ` (${id})` : '')
99
+ // Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
100
+ // only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
101
+ // emphasis run never straddles a line (Linear mangles one that does). The first
102
+ // line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
103
+ // single span overflows rather than being split.
104
+ function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
105
+ const mask = spanMask(body)
106
+ const words = body.split(' ')
107
+ // The index in `body` of the space that precedes each word.
108
+ const spaceBefore = []
109
+ let pos = 0
110
+ for (let k = 0; k < words.length; k++) {
111
+ if (k > 0) {
112
+ spaceBefore[k] = pos
113
+ pos += 1
114
+ }
115
+ pos += words[k].length
116
+ }
69
117
 
70
118
  const out = []
71
- let line = opener
119
+ let line = firstPrefix
72
120
  let first = true
73
- for (const word of body.split(' ')) {
74
- if (!first && line.length + 1 + word.length > width) {
121
+ for (let k = 0; k < words.length; k++) {
122
+ const word = words[k]
123
+ // Break only at a safe gap; a gap inside a span overflows instead of
124
+ // splitting it (so an over-width single span stays whole on one line).
125
+ const canBreak = k > 0 && !mask[spaceBefore[k]]
126
+ if (!first && canBreak && line.length + 1 + word.length > width) {
75
127
  out.push(line)
76
128
  line = hang + word
77
129
  } else {
@@ -83,13 +135,42 @@ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH)
83
135
  return out
84
136
  }
85
137
 
138
+ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
139
+ const opener = `${indent}- [${done ? 'x' : ' '}] `
140
+ const hang = ' '.repeat(opener.length)
141
+ const body = collapse(text) + (id ? ` (${id})` : '')
142
+ return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
143
+ }
144
+
86
145
  // Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
87
- // different column. Falls back to the default when there's nothing to learn from.
146
+ // different column. Only *prose* lines count a single wide table row or a long
147
+ // line of fenced code would otherwise pull the whole file's prose to a wider
148
+ // column than the author wrapped at. Falls back to the default when there's
149
+ // nothing prose-like to learn from.
88
150
  function inferWidth(lines, fallback = DEFAULT_WIDTH) {
89
- const widths = lines.filter((l) => l.trim()).map((l) => l.length)
151
+ let inFence = false
152
+ const widths = []
153
+ for (const l of lines) {
154
+ if (/^[ \t]*```/.test(l)) {
155
+ inFence = !inFence
156
+ continue
157
+ }
158
+ if (inFence) continue
159
+ if (!l.trim()) continue
160
+ if (l.includes('|')) continue // table row
161
+ widths.push(l.length)
162
+ }
90
163
  if (!widths.length) return fallback
91
164
  const max = Math.max(...widths)
92
165
  return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
93
166
  }
94
167
 
95
- module.exports = { findTaskBlocks, renderTaskBlock, collapse, inferWidth, DEFAULT_WIDTH }
168
+ module.exports = {
169
+ findTaskBlocks,
170
+ renderTaskBlock,
171
+ wrapEmphasisAware,
172
+ spanMask,
173
+ collapse,
174
+ inferWidth,
175
+ DEFAULT_WIDTH,
176
+ }