@helping-ai-workflow/md2doc 2.8.1 → 2.10.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/README.md +226 -0
- package/bin/md2doc.js +46 -33
- package/lib/editor/blockmap.js +75 -0
- package/lib/editor/cli.js +23 -0
- package/lib/editor/client.js +4739 -0
- package/lib/editor/history.js +94 -0
- package/lib/editor/inline-md.js +220 -0
- package/lib/editor/lineops.js +71 -0
- package/lib/editor/list-md.js +258 -0
- package/lib/editor/open.js +59 -0
- package/lib/editor/server.js +190 -0
- package/lib/editor/table-md.js +169 -0
- package/lib/md2doc.js +1110 -591
- package/package.json +2 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function isWSL() {
|
|
6
|
+
if (process.platform !== 'linux') return false;
|
|
7
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
|
|
8
|
+
try {
|
|
9
|
+
return fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop');
|
|
10
|
+
} catch (_) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const URL_RE = /^https?:\/\//i;
|
|
16
|
+
|
|
17
|
+
function defaultWslpath(target) {
|
|
18
|
+
return spawnSync('wslpath', ['-w', target], { encoding: 'utf8' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pure decision helper: given a target (file path or URL) and injectable
|
|
22
|
+
// environment probes, returns the { cmd, args } to spawn. No side effects
|
|
23
|
+
// beyond the injected wslpathFn (which itself may spawn `wslpath`).
|
|
24
|
+
//
|
|
25
|
+
// WSL note: `wslpath -w '<a URL>'` does NOT fail — it mangles the string
|
|
26
|
+
// into a garbage Windows-flavoured path (e.g. 'http://127.0.0.1:1234/edit/0'
|
|
27
|
+
// -> 'http\127.0.0.11234\edit\0') and still exits 0. So a URL target must be
|
|
28
|
+
// detected up front and routed straight to explorer.exe, which accepts URLs
|
|
29
|
+
// directly on Windows; the wslpath conversion path is for real file paths
|
|
30
|
+
// only.
|
|
31
|
+
function resolveViewerCommand(target, { platform = process.platform, wsl = isWSL(), wslpathFn = defaultWslpath } = {}) {
|
|
32
|
+
if (platform === 'darwin') {
|
|
33
|
+
return { cmd: 'open', args: [target] };
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
return { cmd: 'cmd', args: ['/c', 'start', '""', target] };
|
|
37
|
+
}
|
|
38
|
+
if (wsl) {
|
|
39
|
+
if (URL_RE.test(target)) {
|
|
40
|
+
return { cmd: 'explorer.exe', args: [target] };
|
|
41
|
+
}
|
|
42
|
+
const r = wslpathFn(target);
|
|
43
|
+
if (r.status === 0 && r.stdout) {
|
|
44
|
+
return { cmd: 'explorer.exe', args: [r.stdout.trim()] };
|
|
45
|
+
}
|
|
46
|
+
return { cmd: 'xdg-open', args: [target] };
|
|
47
|
+
}
|
|
48
|
+
return { cmd: 'xdg-open', args: [target] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function openViewer(filePath) {
|
|
52
|
+
const { cmd, args } = resolveViewerCommand(filePath);
|
|
53
|
+
const r = spawnSync(cmd, args, { stdio: 'ignore' });
|
|
54
|
+
if (r.error) {
|
|
55
|
+
process.stderr.write('warning: could not launch viewer for ' + filePath + ': ' + r.error.message + '\n');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { openViewer, isWSL, resolveViewerCommand };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { renderMarkdown } = require('../md2doc.js');
|
|
6
|
+
|
|
7
|
+
const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
|
|
8
|
+
const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
|
|
9
|
+
const TABLE_MD_SRC = fs.readFileSync(path.join(__dirname, 'table-md.js'), 'utf8');
|
|
10
|
+
// Task 4 (Phase 3): list-md.js's browser factory reads `root.md2docInlineMd`
|
|
11
|
+
// directly (same pattern table-md.js uses) — must land after inline-md.js,
|
|
12
|
+
// same as table-md.js above. Order relative to table-md.js itself doesn't
|
|
13
|
+
// matter (list-md.js never reads window.md2docTableMd), but it's injected
|
|
14
|
+
// right after it to keep the two sibling serializers grouped together.
|
|
15
|
+
const LIST_MD_SRC = fs.readFileSync(path.join(__dirname, 'list-md.js'), 'utf8');
|
|
16
|
+
const HISTORY_SRC = fs.readFileSync(path.join(__dirname, 'history.js'), 'utf8');
|
|
17
|
+
|
|
18
|
+
function readJson(req, limitBytes = 50 * 1024 * 1024) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
// Accumulate raw Buffers and decode ONCE at the end. Per-chunk string
|
|
21
|
+
// concatenation (`buf += chunk`) decodes each chunk independently, so a
|
|
22
|
+
// multi-byte UTF-8 character straddling a TCP chunk boundary becomes
|
|
23
|
+
// U+FFFD — on a multi-MB CJK document that silently corrupts one
|
|
24
|
+
// character per unlucky chunk boundary (seen in the wild: 9 mangled
|
|
25
|
+
// chars in one zero-edit save).
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let total = 0;
|
|
28
|
+
req.on('data', (c) => {
|
|
29
|
+
chunks.push(c);
|
|
30
|
+
total += c.length;
|
|
31
|
+
if (total > limitBytes) { reject(new Error('payload too large')); req.destroy(); }
|
|
32
|
+
});
|
|
33
|
+
req.on('end', () => {
|
|
34
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); }
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function send(res, status, obj) {
|
|
40
|
+
const body = JSON.stringify(obj);
|
|
41
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
42
|
+
res.end(body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '', listenPort = 0 }) {
|
|
46
|
+
const absFiles = files.map((f) => path.resolve(f));
|
|
47
|
+
let idleTimer = null;
|
|
48
|
+
let started = false;
|
|
49
|
+
|
|
50
|
+
function bumpIdle(server) {
|
|
51
|
+
if (!started) return;
|
|
52
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
53
|
+
idleTimer = setTimeout(() => server.close(), idleTimeoutMs);
|
|
54
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = http.createServer(async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
60
|
+
const editMatch = url.pathname.match(/^\/edit\/(\d+)$/);
|
|
61
|
+
|
|
62
|
+
// Cross-origin defense for every state-changing POST route: a
|
|
63
|
+
// cross-site "simple" POST (form submission, no CORS preflight) cannot
|
|
64
|
+
// set a non-simple content-type header like application/json, so
|
|
65
|
+
// requiring it here means a browser blocks the request before it ever
|
|
66
|
+
// reaches this server — this server never sends CORS headers, so the
|
|
67
|
+
// browser would otherwise let the request fire-and-forget cross-origin.
|
|
68
|
+
// /api/ping is included; lib/editor/client.js's fetch calls already
|
|
69
|
+
// send this header for render, save, and ping.
|
|
70
|
+
const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping']);
|
|
71
|
+
if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
|
|
72
|
+
const contentType = String(req.headers['content-type'] || '');
|
|
73
|
+
if (!/^application\/json\b/i.test(contentType)) {
|
|
74
|
+
return send(res, 415, { error: 'content-type must be application/json' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (req.method === 'GET' && editMatch) {
|
|
79
|
+
const fileId = Number(editMatch[1]);
|
|
80
|
+
const file = absFiles[fileId];
|
|
81
|
+
if (!file || !fs.existsSync(file)) return send(res, 404, { error: 'unknown file' });
|
|
82
|
+
const mdText = fs.readFileSync(file, 'utf8');
|
|
83
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
84
|
+
const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
|
|
85
|
+
const payload = JSON.stringify({
|
|
86
|
+
fileId, mtimeMs, lines: mdText.split('\n'), blocks,
|
|
87
|
+
});
|
|
88
|
+
const inject =
|
|
89
|
+
`<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
|
|
90
|
+
`<script>${LINEOPS_SRC}</script>\n` +
|
|
91
|
+
`<script>${INLINE_MD_SRC}</script>\n` +
|
|
92
|
+
`<script>${TABLE_MD_SRC}</script>\n` +
|
|
93
|
+
`<script>${LIST_MD_SRC}</script>\n` +
|
|
94
|
+
`<script>${HISTORY_SRC}</script>\n` +
|
|
95
|
+
`<script>${clientJs}</script>\n`;
|
|
96
|
+
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
97
|
+
// The first occurrence can sit inside an inlined diagram bundle's JS
|
|
98
|
+
// string literal (mermaid's DOMPurify source contains "</body>");
|
|
99
|
+
// String.replace would inject __ED__ mid-bundle and break every
|
|
100
|
+
// script on the page. Also avoids replace()'s "$" substitution rules.
|
|
101
|
+
const bodyAt = html.lastIndexOf('</body>');
|
|
102
|
+
const out = bodyAt !== -1
|
|
103
|
+
? html.slice(0, bodyAt) + inject + html.slice(bodyAt)
|
|
104
|
+
: html + inject;
|
|
105
|
+
started = true;
|
|
106
|
+
bumpIdle(server);
|
|
107
|
+
// no-store: the page embeds a snapshot of the client runtime AND the
|
|
108
|
+
// file's lines/mtime — a cached copy is stale code + a guaranteed
|
|
109
|
+
// mtime conflict after any external edit.
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
'content-type': 'text/html; charset=utf-8',
|
|
112
|
+
'cache-control': 'no-store',
|
|
113
|
+
});
|
|
114
|
+
return res.end(out);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (req.method === 'POST' && url.pathname === '/api/render') {
|
|
118
|
+
const { fileId, content } = await readJson(req);
|
|
119
|
+
const file = absFiles[fileId];
|
|
120
|
+
if (!file) return send(res, 404, { error: 'unknown file' });
|
|
121
|
+
const { bodyHtml, blocks } = await renderMarkdown(content, file, { editMode: true });
|
|
122
|
+
bumpIdle(server);
|
|
123
|
+
return send(res, 200, { bodyHtml, blocks });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === 'POST' && url.pathname === '/api/save') {
|
|
127
|
+
const { fileId, content, baseMtimeMs } = await readJson(req);
|
|
128
|
+
const file = absFiles[fileId];
|
|
129
|
+
if (!file) return send(res, 404, { error: 'unknown file' });
|
|
130
|
+
// baseMtimeMs is REQUIRED, not optional: skipping the compare when
|
|
131
|
+
// it's missing would let a stale editor tab silently clobber a
|
|
132
|
+
// newer on-disk edit (the whole point of the mtime guard).
|
|
133
|
+
if (baseMtimeMs === undefined || baseMtimeMs === null) {
|
|
134
|
+
return send(res, 400, { error: 'baseMtimeMs is required' });
|
|
135
|
+
}
|
|
136
|
+
const cur = fs.statSync(file).mtimeMs;
|
|
137
|
+
if (cur !== baseMtimeMs) {
|
|
138
|
+
return send(res, 409, { error: 'mtime-conflict', mtimeMs: cur });
|
|
139
|
+
}
|
|
140
|
+
const tmp = file + '.md2doc-tmp';
|
|
141
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
142
|
+
fs.renameSync(tmp, file);
|
|
143
|
+
bumpIdle(server);
|
|
144
|
+
return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (req.method === 'POST' && url.pathname === '/api/ping') {
|
|
148
|
+
bumpIdle(server);
|
|
149
|
+
res.writeHead(204);
|
|
150
|
+
return res.end();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return send(res, 404, { error: 'not found' });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return send(res, 500, { error: String((e && e.message) || e) });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// EADDRINUSE (or any other listen-time error, e.g. a pinned --port that's
|
|
160
|
+
// already taken) previously fired as an uncaught 'error' event on `server`
|
|
161
|
+
// with nothing listening, since a plain `server.listen(..., resolve)`
|
|
162
|
+
// Promise never rejects — it only ever resolves on the 'listening' event.
|
|
163
|
+
// That crashed the whole process, bypassing bin's `.catch`. Listen for
|
|
164
|
+
// 'error' too and reject the promise so the caller gets a normal rejection.
|
|
165
|
+
await new Promise((resolve, reject) => {
|
|
166
|
+
function onError(err) {
|
|
167
|
+
server.removeListener('listening', onListening);
|
|
168
|
+
reject(err);
|
|
169
|
+
}
|
|
170
|
+
function onListening() {
|
|
171
|
+
server.removeListener('error', onError);
|
|
172
|
+
resolve();
|
|
173
|
+
}
|
|
174
|
+
server.once('error', onError);
|
|
175
|
+
server.once('listening', onListening);
|
|
176
|
+
server.listen(listenPort, '127.0.0.1');
|
|
177
|
+
});
|
|
178
|
+
const port = server.address().port;
|
|
179
|
+
return {
|
|
180
|
+
server,
|
|
181
|
+
port,
|
|
182
|
+
urlFor(absPath) {
|
|
183
|
+
const i = absFiles.indexOf(path.resolve(absPath));
|
|
184
|
+
return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
|
|
185
|
+
},
|
|
186
|
+
close() { if (idleTimer) clearTimeout(idleTimer); server.close(); },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createEditorServer };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
(function (root, factory) {
|
|
3
|
+
if (typeof module === 'object' && module.exports) {
|
|
4
|
+
module.exports = factory(require('./inline-md.js'));
|
|
5
|
+
} else {
|
|
6
|
+
root.md2docTableMd = factory(root.md2docInlineMd);
|
|
7
|
+
}
|
|
8
|
+
})(typeof self !== 'undefined' ? self : this, function (inlineMd) {
|
|
9
|
+
|
|
10
|
+
// table-md.js — table DOM -> minimal-form GFM markdown (Phase-2 Task 5).
|
|
11
|
+
//
|
|
12
|
+
// ── Renderer finding (verified against lib/md2doc.js's renderer.table,
|
|
13
|
+
// ~line 650, BEFORE writing serializeTable below — required by the
|
|
14
|
+
// task brief) ───────────────────────────────────────────────────────
|
|
15
|
+
// Per-column alignment: renderer.table's `alignStyle(cell)` helper emits
|
|
16
|
+
// `style="text-align:<left|right|center>"` on BOTH <th> and <td> for that
|
|
17
|
+
// column ONLY when marked's lexed `cell.align` is truthy (an explicit GFM
|
|
18
|
+
// `:---` / `---:` / `:---:` separator was used). A column with the plain
|
|
19
|
+
// `---` separator (marked's `cell.align === null`) gets NO style
|
|
20
|
+
// attribute at all — not `style="text-align:left"`, nothing. So
|
|
21
|
+
// alignment must be read from the PRESENCE/VALUE of the `style`
|
|
22
|
+
// attribute — read here from the header row's <th> cells, since
|
|
23
|
+
// alignStyle() is applied identically per-column to every row — never
|
|
24
|
+
// from a class or an `align=` attribute; renderer.table emits neither.
|
|
25
|
+
//
|
|
26
|
+
// classifyColumns() (same file) ALSO stamps `class="cell-narrow"` /
|
|
27
|
+
// `class="cell-prose"` per column — those are rendering-WIDTH hints (see
|
|
28
|
+
// `.content table col.col-narrow { width: 1% }` in the CSS block) with
|
|
29
|
+
// nothing to do with text alignment. Reading them as alignment would be a
|
|
30
|
+
// bug; they are deliberately never consulted below.
|
|
31
|
+
//
|
|
32
|
+
// ── Emission form (spec §4 / task-5 brief) ─────────────────────────────
|
|
33
|
+
// '| a | b |' rows (single-space padding); '|---|' separator row — no
|
|
34
|
+
// padding around the dashes, ':' alignment variants (':---' / '---:' /
|
|
35
|
+
// ':---:'), dashes never stretched to content width; literal '|' inside
|
|
36
|
+
// a cell -> '|'; a cell newline is the literal '<br>' inline-md.js's
|
|
37
|
+
// serializeInline() already emits for both a real <br> node and a
|
|
38
|
+
// contenteditable <div> line-split boundary — AND (final-review Finding 1
|
|
39
|
+
// defense-in-depth) any raw '\n'/'\r' that still reaches a text node is
|
|
40
|
+
// collapsed to '<br>' here too, via escapeNewlines() below, so a cell can
|
|
41
|
+
// never split serializeTable()'s output into more physical lines than
|
|
42
|
+
// it has rows. No trailing whitespace on any emitted line (every row/
|
|
43
|
+
// separator line is built as '|'-delimited fields, so it always ends in
|
|
44
|
+
// '|', never in whitespace, by construction). A '|' inside a <code> span
|
|
45
|
+
// has no faithful gate-safe emission — see cellHasCodePipe() below —
|
|
46
|
+
// and reports 'CODE' unsupported instead of corrupting it.
|
|
47
|
+
//
|
|
48
|
+
// Constrained (same as inline-md.js, and for the same reason: the node
|
|
49
|
+
// test drives this with the hand-rolled element stub from
|
|
50
|
+
// test/inline-md.test.js) to childNodes / nodeType / nodeName /
|
|
51
|
+
// textContent / getAttribute — NO querySelector/querySelectorAll.
|
|
52
|
+
|
|
53
|
+
function elementChildren(node) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (let i = 0; i < node.childNodes.length; i++) {
|
|
56
|
+
const c = node.childNodes[i];
|
|
57
|
+
if (c.nodeType === 1) out.push(c);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstChildNamed(node, name) {
|
|
63
|
+
const kids = elementChildren(node);
|
|
64
|
+
for (let i = 0; i < kids.length; i++) {
|
|
65
|
+
if (kids[i].nodeName === name) return kids[i];
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function childrenNamed(node, name) {
|
|
71
|
+
return elementChildren(node).filter((c) => c.nodeName === name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isCell(node) {
|
|
75
|
+
return node.nodeName === 'TH' || node.nodeName === 'TD';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// '|' — the postprocess literal-pipe escape (spec §4). Applied AFTER
|
|
79
|
+
// inline serialization: inline-md.js's own escaping table has no reason
|
|
80
|
+
// to know about the table-cell delimiter, so it never touches a bare '|'.
|
|
81
|
+
function escapePipes(s) {
|
|
82
|
+
return s.split('|').join('|');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Finding 1 (final-review, defense in depth): inline-md.js's escapeText()
|
|
86
|
+
// and serializeCode() never touch '\n' — a text node carrying a raw
|
|
87
|
+
// embedded newline (e.g. a paste path that bypassed client.js's own
|
|
88
|
+
// <br>-segmentation, or any future caller of serializeInline()) would
|
|
89
|
+
// otherwise leak a literal line break into this cell's md, splitting one
|
|
90
|
+
// table row into an orphan-cell-line the gate can't parse. Collapse any
|
|
91
|
+
// raw newline sequence to the same literal '<br>' token a real <br>
|
|
92
|
+
// element already serializes to (case 4 in table-md.test.js / gate-compat
|
|
93
|
+
// .test.js) — applied at the same postprocess spot as escapePipes().
|
|
94
|
+
function escapeNewlines(s) {
|
|
95
|
+
return s.replace(/\r\n|\r|\n/g, '<br>');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Finding 4: a '|' inside a <code> span has no faithful gate-safe
|
|
99
|
+
// emission — escapePipes() above runs on the WHOLE cell's serialized md
|
|
100
|
+
// and can't tell a code span's content from plain text, so naively
|
|
101
|
+
// applying it would corrupt the code span (turning `` `a|b` `` into the
|
|
102
|
+
// broken `` `a|b` ``, which never decodes back inside a code span).
|
|
103
|
+
// `\|` (some other renderers' escape) is the gate's documented trap, so
|
|
104
|
+
// there's no safe escape at all here — degrade-never-lose: detect a CODE
|
|
105
|
+
// element anywhere in the cell whose textContent contains '|' and report
|
|
106
|
+
// it unsupported instead, same signal an IMG/SPAN/etc. already uses to
|
|
107
|
+
// degrade the whole table to raw-edit (see canWysiwygForTable() in
|
|
108
|
+
// client.js). Recurses through the cell's element children looking for
|
|
109
|
+
// CODE — constrained to childNodes/nodeType/nodeName like the rest of
|
|
110
|
+
// this file (no querySelector, per the node-test stub).
|
|
111
|
+
function cellHasCodePipe(node) {
|
|
112
|
+
const kids = elementChildren(node);
|
|
113
|
+
for (let i = 0; i < kids.length; i++) {
|
|
114
|
+
const k = kids[i];
|
|
115
|
+
if (k.nodeName === 'CODE') {
|
|
116
|
+
if (k.textContent.indexOf('|') !== -1) return true;
|
|
117
|
+
} else if (cellHasCodePipe(k)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cellAlign(cell) {
|
|
125
|
+
const style = cell.getAttribute('style');
|
|
126
|
+
if (!style) return null;
|
|
127
|
+
const m = /text-align\s*:\s*(left|right|center)/.exec(style);
|
|
128
|
+
return m ? m[1] : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sepCellFor(align) {
|
|
132
|
+
if (align === 'left') return ':---';
|
|
133
|
+
if (align === 'right') return '---:';
|
|
134
|
+
if (align === 'center') return ':---:';
|
|
135
|
+
return '---';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function serializeRow(cells, unsupported) {
|
|
139
|
+
const parts = cells.map((cell) => {
|
|
140
|
+
if (cellHasCodePipe(cell)) unsupported.push('CODE');
|
|
141
|
+
const res = inlineMd.serializeInline(cell);
|
|
142
|
+
res.unsupported.forEach((u) => unsupported.push(u));
|
|
143
|
+
return escapeNewlines(escapePipes(res.md));
|
|
144
|
+
});
|
|
145
|
+
return '| ' + parts.join(' | ') + ' |';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function serializeTable(tableEl) {
|
|
149
|
+
const unsupported = [];
|
|
150
|
+
const thead = firstChildNamed(tableEl, 'THEAD');
|
|
151
|
+
const tbody = firstChildNamed(tableEl, 'TBODY');
|
|
152
|
+
const headerRow = thead ? firstChildNamed(thead, 'TR') : null;
|
|
153
|
+
const headerCells = headerRow ? elementChildren(headerRow).filter(isCell) : [];
|
|
154
|
+
|
|
155
|
+
const lines = [];
|
|
156
|
+
lines.push(serializeRow(headerCells, unsupported));
|
|
157
|
+
lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
|
|
158
|
+
|
|
159
|
+
const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
|
|
160
|
+
bodyRows.forEach((tr) => {
|
|
161
|
+
const cells = elementChildren(tr).filter(isCell);
|
|
162
|
+
lines.push(serializeRow(cells, unsupported));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { md: lines.join('\n'), unsupported };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { serializeTable };
|
|
169
|
+
});
|