create-pmos 0.31.0 → 0.31.1
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/README.md +330 -1000
- package/package.json +1 -1
- package/payload/AGENTS.md +51 -152
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/md-newlines.js +204 -0
- package/payload/scripts/md-normalize.js +223 -0
- package/workspace/lib/md-normalize.js +223 -0
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +4 -0
- package/workspace/public/pm/tab-doc.js +34 -3
- package/workspace/server.js +4 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/* md-normalize — a newline in a PMOS document means what it says.
|
|
2
|
+
*
|
|
3
|
+
* ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
|
|
4
|
+
* this reason:
|
|
5
|
+
* - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
|
|
6
|
+
* - workspace/server.js requires it (the doc tab's `normalize` action)
|
|
7
|
+
* - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
|
|
8
|
+
* so the command, the gate and the button can never disagree about what a normalized file is.
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
|
|
12
|
+
* files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
|
|
13
|
+
* lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
|
|
14
|
+
* inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
|
|
15
|
+
* newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
|
|
16
|
+
* emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
|
|
17
|
+
* converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
|
|
18
|
+
* source. The only place the distinction still exists is the file.
|
|
19
|
+
*
|
|
20
|
+
* ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
|
|
21
|
+
* Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
|
|
22
|
+
* link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
|
|
23
|
+
* hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
|
|
24
|
+
* inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
|
|
25
|
+
* that damages a file is worse than the defect it repairs.
|
|
26
|
+
*
|
|
27
|
+
* ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
|
|
28
|
+
* Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
|
|
29
|
+
* rather than over fixtures. The ratchet, the button and an adopter's repair run are all
|
|
30
|
+
* untrustworthy without it.
|
|
31
|
+
*/
|
|
32
|
+
'use strict';
|
|
33
|
+
(function (root, factory) {
|
|
34
|
+
const api = factory();
|
|
35
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
36
|
+
else root.MdNormalize = api;
|
|
37
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
38
|
+
const FENCE = /^(\s*)(`{3,}|~{3,})/;
|
|
39
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
40
|
+
const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
41
|
+
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
|
|
42
|
+
const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
|
|
43
|
+
const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
44
|
+
const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
|
|
45
|
+
const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
|
|
46
|
+
const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
|
|
47
|
+
const INDENTED_CODE = /^(?: {4,}|\t)\S/;
|
|
48
|
+
/* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
|
|
49
|
+
* and it is the one newline in a paragraph that is not an accident — eating it would be removing
|
|
50
|
+
* intent, which is the failure this tool exists to avoid, not to commit. */
|
|
51
|
+
const HARD_BREAK = /(?: {2,}|\\)$/;
|
|
52
|
+
|
|
53
|
+
const isBlank = (l) => !l.trim();
|
|
54
|
+
|
|
55
|
+
/** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
|
|
56
|
+
const isTableHeader = (lines, i) =>
|
|
57
|
+
lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
|
|
58
|
+
|
|
59
|
+
/** A row inside a table body — a pipe line in a run that began with a header. */
|
|
60
|
+
function tableRunEnd(lines, i) {
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
|
|
63
|
+
return j;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A line that can never be joined ONTO the previous one — it opens a block of its own. This is
|
|
68
|
+
* the list the blank-line rule is derived from too, so "what needs a blank line above it" and
|
|
69
|
+
* "what stops a paragraph" cannot drift apart.
|
|
70
|
+
*/
|
|
71
|
+
function opensBlock(lines, i) {
|
|
72
|
+
const l = lines[i];
|
|
73
|
+
return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
|
|
74
|
+
|| REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
|
|
75
|
+
|| SETEXT.test(l);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize `src`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
|
|
82
|
+
* were joined into a paragraph or an item, and how many blank lines were inserted above a block.
|
|
83
|
+
* The counts are what the CLI reports per file: a tool that says "changed" without saying what it
|
|
84
|
+
* changed is asking to be trusted rather than read.
|
|
85
|
+
*/
|
|
86
|
+
function normalize(src) {
|
|
87
|
+
const input = String(src == null ? '' : src);
|
|
88
|
+
const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
|
|
89
|
+
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
|
90
|
+
const out = [];
|
|
91
|
+
let joins = 0;
|
|
92
|
+
let blanks = 0;
|
|
93
|
+
let i = 0;
|
|
94
|
+
|
|
95
|
+
// ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
|
|
96
|
+
if (lines[0] === '---') {
|
|
97
|
+
let j = 1;
|
|
98
|
+
while (j < lines.length && lines[j] !== '---') j++;
|
|
99
|
+
if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
|
|
103
|
+
* break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
|
|
104
|
+
* newline in a paragraph that was asked for. */
|
|
105
|
+
const piece = (l) => {
|
|
106
|
+
const s2 = l.replace(/^\s+/, '');
|
|
107
|
+
return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The blank line a block construct needs above it, when prose is what sits there. */
|
|
111
|
+
const blankAbove = () => {
|
|
112
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
113
|
+
if (!out.length || isBlank(prev)) return;
|
|
114
|
+
// Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
|
|
115
|
+
// header, a fence closing — none of those want one, and inserting it would restructure the
|
|
116
|
+
// document rather than repair its wrapping.
|
|
117
|
+
if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
|
|
118
|
+
out.push('');
|
|
119
|
+
blanks++;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
|
|
125
|
+
if (isBlank(line)) { out.push(line); i++; continue; }
|
|
126
|
+
|
|
127
|
+
// fenced code — verbatim, to the closing fence or to EOF
|
|
128
|
+
const fence = FENCE.exec(line);
|
|
129
|
+
if (fence) {
|
|
130
|
+
blankAbove();
|
|
131
|
+
const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
|
|
132
|
+
out.push(line); i++;
|
|
133
|
+
while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
|
|
134
|
+
if (i < lines.length) { out.push(lines[i]); i++; }
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// indented code, headings, rules, reference definitions, HTML — one line, untouched
|
|
139
|
+
if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
|
|
140
|
+
|| HTML_BLOCK.test(line) || SETEXT.test(line)) {
|
|
141
|
+
/* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
|
|
142
|
+
* HEADING, not a rule, and separating it would turn one heading into a paragraph and a
|
|
143
|
+
* horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
|
|
144
|
+
if (HEADING.test(line)) blankAbove();
|
|
145
|
+
out.push(line); i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// a table: its header, its delimiter and its rows are records — never joined, never reflowed
|
|
150
|
+
if (isTableHeader(lines, i)) {
|
|
151
|
+
blankAbove();
|
|
152
|
+
const end = tableRunEnd(lines, i);
|
|
153
|
+
for (let k = i; k < end; k++) out.push(lines[k]);
|
|
154
|
+
i = end;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// a list item, plus the continuation lines that belong to it
|
|
159
|
+
const item = ITEM.exec(line);
|
|
160
|
+
if (item) {
|
|
161
|
+
blankAbove();
|
|
162
|
+
/* INSIDE a list item, four spaces is not code — it is the item's own text column. The
|
|
163
|
+
* generic `opensBlock` test calls an indented continuation an indented code block and stops,
|
|
164
|
+
* which left every nested item's wrapped line un-joined. Code inside an item starts four
|
|
165
|
+
* columns further in than the item's CONTENT does, so that is the line this measures. */
|
|
166
|
+
const contentCol = item[1].length + item[2].length + item[3].length;
|
|
167
|
+
const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
|
|
168
|
+
let body = line;
|
|
169
|
+
i++;
|
|
170
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
|
|
171
|
+
&& /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
|
|
172
|
+
&& !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
|
|
173
|
+
|| ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
|
|
174
|
+
body += ' ' + piece(lines[i]);
|
|
175
|
+
joins++; i++;
|
|
176
|
+
}
|
|
177
|
+
out.push(body);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// a blockquote line, plus its lazy continuations
|
|
182
|
+
const quote = QUOTE.exec(line);
|
|
183
|
+
if (quote) {
|
|
184
|
+
/* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
|
|
185
|
+
* quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
|
|
186
|
+
* turns two paragraphs into one, and (because the result then joins again on the next run)
|
|
187
|
+
* is how this tool would fail its own idempotence property. Found by running the proof over
|
|
188
|
+
* the real corpus rather than over fixtures: 50 files did exactly this. */
|
|
189
|
+
if (!quote[2].trim()) { out.push(line); i++; continue; }
|
|
190
|
+
let body = line;
|
|
191
|
+
i++;
|
|
192
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
|
|
193
|
+
const q = QUOTE.exec(lines[i]);
|
|
194
|
+
const inner = q ? q[2] : lines[i];
|
|
195
|
+
if (!inner.trim()) break; // a blank quote line ends it
|
|
196
|
+
if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
|
|
197
|
+
if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
|
|
198
|
+
body += ' ' + piece(inner);
|
|
199
|
+
joins++; i++;
|
|
200
|
+
}
|
|
201
|
+
out.push(body);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// prose — the case the whole tool is about
|
|
206
|
+
let para = line;
|
|
207
|
+
i++;
|
|
208
|
+
while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
|
|
209
|
+
para += ' ' + piece(lines[i]);
|
|
210
|
+
joins++; i++;
|
|
211
|
+
}
|
|
212
|
+
out.push(para);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const text = out.join(nl);
|
|
216
|
+
return { text, changed: text !== input, joins, blanks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the file is already normalized — the check half, with no allocation of a new string. */
|
|
220
|
+
const isNormalized = (src) => !normalize(src).changed;
|
|
221
|
+
|
|
222
|
+
return { normalize, isNormalized };
|
|
223
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/* md-normalize — a newline in a PMOS document means what it says.
|
|
2
|
+
*
|
|
3
|
+
* ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
|
|
4
|
+
* this reason:
|
|
5
|
+
* - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
|
|
6
|
+
* - workspace/server.js requires it (the doc tab's `normalize` action)
|
|
7
|
+
* - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
|
|
8
|
+
* so the command, the gate and the button can never disagree about what a normalized file is.
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
|
|
12
|
+
* files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
|
|
13
|
+
* lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
|
|
14
|
+
* inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
|
|
15
|
+
* newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
|
|
16
|
+
* emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
|
|
17
|
+
* converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
|
|
18
|
+
* source. The only place the distinction still exists is the file.
|
|
19
|
+
*
|
|
20
|
+
* ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
|
|
21
|
+
* Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
|
|
22
|
+
* link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
|
|
23
|
+
* hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
|
|
24
|
+
* inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
|
|
25
|
+
* that damages a file is worse than the defect it repairs.
|
|
26
|
+
*
|
|
27
|
+
* ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
|
|
28
|
+
* Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
|
|
29
|
+
* rather than over fixtures. The ratchet, the button and an adopter's repair run are all
|
|
30
|
+
* untrustworthy without it.
|
|
31
|
+
*/
|
|
32
|
+
'use strict';
|
|
33
|
+
(function (root, factory) {
|
|
34
|
+
const api = factory();
|
|
35
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
36
|
+
else root.MdNormalize = api;
|
|
37
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
38
|
+
const FENCE = /^(\s*)(`{3,}|~{3,})/;
|
|
39
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
40
|
+
const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
41
|
+
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
|
|
42
|
+
const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
|
|
43
|
+
const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
44
|
+
const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
|
|
45
|
+
const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
|
|
46
|
+
const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
|
|
47
|
+
const INDENTED_CODE = /^(?: {4,}|\t)\S/;
|
|
48
|
+
/* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
|
|
49
|
+
* and it is the one newline in a paragraph that is not an accident — eating it would be removing
|
|
50
|
+
* intent, which is the failure this tool exists to avoid, not to commit. */
|
|
51
|
+
const HARD_BREAK = /(?: {2,}|\\)$/;
|
|
52
|
+
|
|
53
|
+
const isBlank = (l) => !l.trim();
|
|
54
|
+
|
|
55
|
+
/** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
|
|
56
|
+
const isTableHeader = (lines, i) =>
|
|
57
|
+
lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
|
|
58
|
+
|
|
59
|
+
/** A row inside a table body — a pipe line in a run that began with a header. */
|
|
60
|
+
function tableRunEnd(lines, i) {
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
|
|
63
|
+
return j;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A line that can never be joined ONTO the previous one — it opens a block of its own. This is
|
|
68
|
+
* the list the blank-line rule is derived from too, so "what needs a blank line above it" and
|
|
69
|
+
* "what stops a paragraph" cannot drift apart.
|
|
70
|
+
*/
|
|
71
|
+
function opensBlock(lines, i) {
|
|
72
|
+
const l = lines[i];
|
|
73
|
+
return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
|
|
74
|
+
|| REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
|
|
75
|
+
|| SETEXT.test(l);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize `src`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
|
|
82
|
+
* were joined into a paragraph or an item, and how many blank lines were inserted above a block.
|
|
83
|
+
* The counts are what the CLI reports per file: a tool that says "changed" without saying what it
|
|
84
|
+
* changed is asking to be trusted rather than read.
|
|
85
|
+
*/
|
|
86
|
+
function normalize(src) {
|
|
87
|
+
const input = String(src == null ? '' : src);
|
|
88
|
+
const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
|
|
89
|
+
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
|
90
|
+
const out = [];
|
|
91
|
+
let joins = 0;
|
|
92
|
+
let blanks = 0;
|
|
93
|
+
let i = 0;
|
|
94
|
+
|
|
95
|
+
// ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
|
|
96
|
+
if (lines[0] === '---') {
|
|
97
|
+
let j = 1;
|
|
98
|
+
while (j < lines.length && lines[j] !== '---') j++;
|
|
99
|
+
if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
|
|
103
|
+
* break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
|
|
104
|
+
* newline in a paragraph that was asked for. */
|
|
105
|
+
const piece = (l) => {
|
|
106
|
+
const s2 = l.replace(/^\s+/, '');
|
|
107
|
+
return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The blank line a block construct needs above it, when prose is what sits there. */
|
|
111
|
+
const blankAbove = () => {
|
|
112
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
113
|
+
if (!out.length || isBlank(prev)) return;
|
|
114
|
+
// Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
|
|
115
|
+
// header, a fence closing — none of those want one, and inserting it would restructure the
|
|
116
|
+
// document rather than repair its wrapping.
|
|
117
|
+
if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
|
|
118
|
+
out.push('');
|
|
119
|
+
blanks++;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
|
|
125
|
+
if (isBlank(line)) { out.push(line); i++; continue; }
|
|
126
|
+
|
|
127
|
+
// fenced code — verbatim, to the closing fence or to EOF
|
|
128
|
+
const fence = FENCE.exec(line);
|
|
129
|
+
if (fence) {
|
|
130
|
+
blankAbove();
|
|
131
|
+
const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
|
|
132
|
+
out.push(line); i++;
|
|
133
|
+
while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
|
|
134
|
+
if (i < lines.length) { out.push(lines[i]); i++; }
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// indented code, headings, rules, reference definitions, HTML — one line, untouched
|
|
139
|
+
if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
|
|
140
|
+
|| HTML_BLOCK.test(line) || SETEXT.test(line)) {
|
|
141
|
+
/* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
|
|
142
|
+
* HEADING, not a rule, and separating it would turn one heading into a paragraph and a
|
|
143
|
+
* horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
|
|
144
|
+
if (HEADING.test(line)) blankAbove();
|
|
145
|
+
out.push(line); i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// a table: its header, its delimiter and its rows are records — never joined, never reflowed
|
|
150
|
+
if (isTableHeader(lines, i)) {
|
|
151
|
+
blankAbove();
|
|
152
|
+
const end = tableRunEnd(lines, i);
|
|
153
|
+
for (let k = i; k < end; k++) out.push(lines[k]);
|
|
154
|
+
i = end;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// a list item, plus the continuation lines that belong to it
|
|
159
|
+
const item = ITEM.exec(line);
|
|
160
|
+
if (item) {
|
|
161
|
+
blankAbove();
|
|
162
|
+
/* INSIDE a list item, four spaces is not code — it is the item's own text column. The
|
|
163
|
+
* generic `opensBlock` test calls an indented continuation an indented code block and stops,
|
|
164
|
+
* which left every nested item's wrapped line un-joined. Code inside an item starts four
|
|
165
|
+
* columns further in than the item's CONTENT does, so that is the line this measures. */
|
|
166
|
+
const contentCol = item[1].length + item[2].length + item[3].length;
|
|
167
|
+
const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
|
|
168
|
+
let body = line;
|
|
169
|
+
i++;
|
|
170
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
|
|
171
|
+
&& /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
|
|
172
|
+
&& !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
|
|
173
|
+
|| ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
|
|
174
|
+
body += ' ' + piece(lines[i]);
|
|
175
|
+
joins++; i++;
|
|
176
|
+
}
|
|
177
|
+
out.push(body);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// a blockquote line, plus its lazy continuations
|
|
182
|
+
const quote = QUOTE.exec(line);
|
|
183
|
+
if (quote) {
|
|
184
|
+
/* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
|
|
185
|
+
* quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
|
|
186
|
+
* turns two paragraphs into one, and (because the result then joins again on the next run)
|
|
187
|
+
* is how this tool would fail its own idempotence property. Found by running the proof over
|
|
188
|
+
* the real corpus rather than over fixtures: 50 files did exactly this. */
|
|
189
|
+
if (!quote[2].trim()) { out.push(line); i++; continue; }
|
|
190
|
+
let body = line;
|
|
191
|
+
i++;
|
|
192
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
|
|
193
|
+
const q = QUOTE.exec(lines[i]);
|
|
194
|
+
const inner = q ? q[2] : lines[i];
|
|
195
|
+
if (!inner.trim()) break; // a blank quote line ends it
|
|
196
|
+
if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
|
|
197
|
+
if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
|
|
198
|
+
body += ' ' + piece(inner);
|
|
199
|
+
joins++; i++;
|
|
200
|
+
}
|
|
201
|
+
out.push(body);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// prose — the case the whole tool is about
|
|
206
|
+
let para = line;
|
|
207
|
+
i++;
|
|
208
|
+
while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
|
|
209
|
+
para += ' ' + piece(lines[i]);
|
|
210
|
+
joins++; i++;
|
|
211
|
+
}
|
|
212
|
+
out.push(para);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const text = out.join(nl);
|
|
216
|
+
return { text, changed: text !== input, joins, blanks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the file is already normalized — the check half, with no allocation of a new string. */
|
|
220
|
+
const isNormalized = (src) => !normalize(src).changed;
|
|
221
|
+
|
|
222
|
+
return { normalize, isNormalized };
|
|
223
|
+
});
|
package/workspace/package.json
CHANGED
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
opinion about what an .html file is would mean the endpoint and the pane disagreeing about the
|
|
54
54
|
same document. Classic script (a UMD), like the three above. -->
|
|
55
55
|
<script src="/lib/preview-kind.js"></script>
|
|
56
|
+
<!-- The newline normalizer behind the doc tab's `normalize` action (markdown-newlines). The same
|
|
57
|
+
file scripts/md-newlines.js requires, loaded here as window.MdNormalize, so the one-click
|
|
58
|
+
repair and the CI ratchet can never disagree about what a normalized document is. -->
|
|
59
|
+
<script src="/lib/md-normalize.js"></script>
|
|
56
60
|
<link rel="preload" href="/vendor/ibm-plex-mono-500.woff2" as="font" type="font/woff2" crossorigin />
|
|
57
61
|
</head>
|
|
58
62
|
<body>
|
|
@@ -316,6 +316,7 @@ function diffBody(tab, raw, text, dirty) {
|
|
|
316
316
|
/* ── per-tab state that is DOM, not data ───────────────────────────────────────────────────── */
|
|
317
317
|
|
|
318
318
|
const boxes = new Map(); // tab id -> the persistent textarea for that tab
|
|
319
|
+
const normNote = new Map(); // tab id -> what the last `normalize` click did, in words
|
|
319
320
|
|
|
320
321
|
function editBox(tab) {
|
|
321
322
|
let ta = boxes.get(tab.id);
|
|
@@ -336,6 +337,7 @@ function editBox(tab) {
|
|
|
336
337
|
/** Release what a closed doc tab held. Called by panel-tabs.js when the tab goes away. */
|
|
337
338
|
export function disposeDoc(id) {
|
|
338
339
|
boxes.delete(id);
|
|
340
|
+
normNote.delete(id);
|
|
339
341
|
frames.delete(id);
|
|
340
342
|
scrollers.delete(id);
|
|
341
343
|
}
|
|
@@ -373,15 +375,44 @@ export function mountDoc(host, tab) {
|
|
|
373
375
|
hover: 'border-color:var(--ring)',
|
|
374
376
|
}, 'revert') : null;
|
|
375
377
|
|
|
378
|
+
/* ── normalize ────────────────────────────────────────────────────────────────────────────────
|
|
379
|
+
* The one-click half of markdown-newlines. It writes the normalized text into the BUFFER, never
|
|
380
|
+
* to disk: the pane goes dirty, `diff` shows exactly which lines were joined, and `save` is the
|
|
381
|
+
* existing write path with its existing refusal on a changed base. So a repair the PM did not
|
|
382
|
+
* want is one `revert` away, and no new way to write a file was added to get it.
|
|
383
|
+
*
|
|
384
|
+
* A document that is already normalized reports that and leaves the buffer alone — marking a
|
|
385
|
+
* clean file dirty for nothing is how a tool teaches you to ignore its dirty flag. */
|
|
386
|
+
const norm = typeof window !== 'undefined' ? window.MdNormalize : null;
|
|
387
|
+
const canNormalize = !!(norm && typeof norm.normalize === 'function')
|
|
388
|
+
&& !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(tab.path);
|
|
389
|
+
const normBtn = canNormalize ? el('button', {
|
|
390
|
+
onClick: () => {
|
|
391
|
+
const r = norm.normalize(text);
|
|
392
|
+
normNote.set(tab.id, r.changed
|
|
393
|
+
? r.joins + ' line(s) joined' + (r.blanks ? ', ' + r.blanks + ' blank inserted' : '')
|
|
394
|
+
: 'already normalized — every paragraph is one line');
|
|
395
|
+
if (!r.changed) { setState({}); return; }
|
|
396
|
+
setState((s) => ({ docEdits: { ...s.docEdits, [tab.path]: r.text } }));
|
|
397
|
+
},
|
|
398
|
+
style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)',
|
|
399
|
+
hover: 'border-color:var(--ring)',
|
|
400
|
+
title: 'Join hard-wrapped paragraphs into one line each — what a Jira issue, a Google Doc or any'
|
|
401
|
+
+ ' plain-text destination needs. Writes the buffer, not the file: read it in diff, then save.',
|
|
402
|
+
}, 'normalize') : null;
|
|
403
|
+
|
|
376
404
|
// The one place a real-mode refusal is reported: the same line that carries the dirty flag, so
|
|
377
|
-
// a save that did not happen cannot read as one that did.
|
|
378
|
-
|
|
405
|
+
// a save that did not happen cannot read as one that did. The normalize note shares the line for
|
|
406
|
+
// the same reason — a repair that changed nothing must say so where the PM is already looking.
|
|
407
|
+
const note = normNote.get(tab.id);
|
|
408
|
+
const dirtyLabel = err ? 'not saved — ' + err
|
|
409
|
+
: (dirty ? '● modified — unsaved' : (note || 'saved on disk'));
|
|
379
410
|
const dirtyColor = err ? 'var(--destructive)' : (dirty ? 'var(--warning)' : 'var(--muted-foreground)');
|
|
380
411
|
|
|
381
412
|
const modeBar = el('div', {
|
|
382
413
|
style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
|
|
383
414
|
},
|
|
384
|
-
modeBtns, saveBtn, revertBtn,
|
|
415
|
+
modeBtns, normBtn, saveBtn, revertBtn,
|
|
385
416
|
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
|
|
386
417
|
);
|
|
387
418
|
|
package/workspace/server.js
CHANGED
|
@@ -3760,6 +3760,10 @@ async function handleRequest(req, res) {
|
|
|
3760
3760
|
// /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
|
|
3761
3761
|
// 404s loudly rather than silently resurrecting a second, diverging surface.
|
|
3762
3762
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
3763
|
+
// The newline normalizer the doc tab's `normalize` action runs, and the same file
|
|
3764
|
+
// scripts/md-newlines.js requires — one implementation, so the button and the gate can never
|
|
3765
|
+
// disagree about what a normalized document is (markdown-newlines).
|
|
3766
|
+
if (p === '/lib/md-normalize.js') return serveFile(res, path.join(__dirname, 'lib', 'md-normalize.js'));
|
|
3763
3767
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
3764
3768
|
if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
|
|
3765
3769
|
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|