@dogsbay/format-rst 0.2.0-beta.114 → 0.2.0-beta.116
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/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +320 -17
- package/dist/cli.js.map +1 -1
- package/dist/conf-static.d.ts +40 -0
- package/dist/conf-static.d.ts.map +1 -0
- package/dist/conf-static.js +309 -0
- package/dist/conf-static.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/loaders/plain.d.ts +3 -2
- package/dist/loaders/plain.d.ts.map +1 -1
- package/dist/loaders/plain.js +5 -2
- package/dist/loaders/plain.js.map +1 -1
- package/dist/loaders/sphinx.d.ts +34 -0
- package/dist/loaders/sphinx.d.ts.map +1 -1
- package/dist/loaders/sphinx.js +72 -3
- package/dist/loaders/sphinx.js.map +1 -1
- package/dist/parse.d.ts +24 -3
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +9 -5
- package/dist/parse.js.map +1 -1
- package/dist/sidecar.d.ts +6 -0
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +20 -1
- package/dist/sidecar.js.map +1 -1
- package/dist/transform.d.ts +34 -0
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +672 -63
- package/dist/transform.js.map +1 -1
- package/package.json +4 -3
- package/python/rst2dogsbay.py +115 -12
package/dist/transform.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* See plans/format-rst.md for the full node→Dogsbay-MD mapping.
|
|
16
16
|
*/
|
|
17
17
|
import { parseDocument } from "htmlparser2";
|
|
18
|
+
import { embeddedSpecRefs, resolveEmbeddedSpec } from "@dogsbay/format-openapi/embed";
|
|
18
19
|
import { stringify as stringifyYaml } from "yaml";
|
|
20
|
+
import { dogsbayMdToTree, slugifyHeadingText, treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
|
|
19
21
|
// NOTE: guard on the node's `.type` string, never `instanceof`. htmlparser2
|
|
20
22
|
// builds the tree with its own bundled copy of domhandler, which may be a
|
|
21
23
|
// different module instance than the one we import — `instanceof Element`
|
|
@@ -52,7 +54,7 @@ export function docutilsXmlToDogsbayMd(xml, opts = {}) {
|
|
|
52
54
|
const root = findElement(doc, "document");
|
|
53
55
|
const ledger = [];
|
|
54
56
|
if (!root)
|
|
55
|
-
return { markdown: "", ledger };
|
|
57
|
+
return { markdown: "", generated: [], openapiSpecs: [], ledger };
|
|
56
58
|
// Sphinx omits the `title=` attr on <document>; the H1 is the first
|
|
57
59
|
// section's <title>. Plain docutils sets `title=`.
|
|
58
60
|
const title = root.attribs?.title ?? firstSectionTitle(root);
|
|
@@ -61,11 +63,26 @@ export function docutilsXmlToDogsbayMd(xml, opts = {}) {
|
|
|
61
63
|
// Plain docutils puts the H1 in `<document title><title>…`; start at
|
|
62
64
|
// depth 1. Sphinx wraps the whole doc in a top `<section>` whose title
|
|
63
65
|
// is the H1, so start at 0 and let that section bump it to 1.
|
|
66
|
+
// This doc's own anchors, else the corpus index. Not a merged copy: copying
|
|
67
|
+
// the index per doc made a corpus quadratic (8,000 docs: 15 s of copying).
|
|
68
|
+
const own = sectionAnchors(root);
|
|
69
|
+
const anchorOf = (doc, id) => (doc === docPath ? own.get(id) : opts.anchors?.get(doc)?.get(id));
|
|
64
70
|
const ctx = {
|
|
65
71
|
ledger,
|
|
66
72
|
depth: root.attribs?.title ? 1 : 0,
|
|
67
73
|
docDir,
|
|
68
74
|
hrefPrefix: opts.hrefPrefix ?? "",
|
|
75
|
+
docPath,
|
|
76
|
+
anchorOf,
|
|
77
|
+
meta: {},
|
|
78
|
+
buildDirs: (Array.isArray(opts.buildDir) ? opts.buildDir : opts.buildDir ? [opts.buildDir] : []).filter(Boolean),
|
|
79
|
+
srcDir: opts.srcDir,
|
|
80
|
+
extraDirs: opts.extraDirs ?? [],
|
|
81
|
+
staticDirs: opts.staticDirs ?? [],
|
|
82
|
+
docname: opts.docname ?? docPath,
|
|
83
|
+
generated: new Set(),
|
|
84
|
+
openapiSpecs: new Set(),
|
|
85
|
+
anchorIds: new Set(),
|
|
69
86
|
};
|
|
70
87
|
// The root `<document>`'s own `<title>` is the H1; its remaining children
|
|
71
88
|
// are the body. Section titles below get depth+1.
|
|
@@ -73,16 +90,151 @@ export function docutilsXmlToDogsbayMd(xml, opts = {}) {
|
|
|
73
90
|
const md = blocks
|
|
74
91
|
.filter((b) => b.trim() !== "")
|
|
75
92
|
.join("\n\n")
|
|
93
|
+
// A break left outside a line block is a space (with any around it).
|
|
94
|
+
.replace(/[ \t]*\u0000BR\u0000[ \t]*/g, " ")
|
|
76
95
|
.replace(/\n{3,}/g, "\n\n")
|
|
77
96
|
.trim();
|
|
78
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
markdown: md + "\n",
|
|
99
|
+
title,
|
|
100
|
+
description: ctx.meta.description,
|
|
101
|
+
generated: [...ctx.generated].sort(),
|
|
102
|
+
openapiSpecs: [...ctx.openapiSpecs].sort(),
|
|
103
|
+
ledger,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A raw HTML block that embeds an API viewer → `:::openapi{spec="/path"}`
|
|
108
|
+
* per spec found in the source tree. `undefined` when the block is not an
|
|
109
|
+
* embed (it is handled as raw HTML); `null` when it is one whose spec is not
|
|
110
|
+
* in the source (ledgered, nothing emitted: the viewer's script is no use).
|
|
111
|
+
*/
|
|
112
|
+
function renderApiViewerEmbed(html, ctx) {
|
|
113
|
+
const refs = embeddedSpecRefs(html);
|
|
114
|
+
if (!refs.length)
|
|
115
|
+
return undefined;
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const ref of refs) {
|
|
118
|
+
const spec = ctx.srcDir
|
|
119
|
+
? resolveEmbeddedSpec(ref, { srcDir: ctx.srcDir, docname: ctx.docname, extraDirs: ctx.extraDirs, staticDirs: ctx.staticDirs })
|
|
120
|
+
: null;
|
|
121
|
+
// The path goes into a directive attribute: nothing that could end it.
|
|
122
|
+
if (!spec || !/^[^"{}\\\s<>]+$/.test(spec)) {
|
|
123
|
+
ctx.ledger.push({
|
|
124
|
+
construct: "API viewer embed",
|
|
125
|
+
note: ctx.srcDir ? `spec ${ref} not found in the source — dropped` : `spec ${ref} not looked up (no source dir) — dropped`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
ctx.openapiSpecs.add(spec);
|
|
130
|
+
ctx.ledger.push({ construct: "API viewer embed", note: `→ :::openapi from ${spec}` });
|
|
131
|
+
out.push(`:::openapi{spec="/${spec}"}\n:::`);
|
|
132
|
+
}
|
|
133
|
+
return out.length ? out.join("\n\n") : null;
|
|
134
|
+
}
|
|
135
|
+
/** `<a id>` for an id not yet anchored on this page; "" otherwise. */
|
|
136
|
+
function anchorTag(ctx, id) {
|
|
137
|
+
if (!id || ctx.anchorIds.has(id))
|
|
138
|
+
return "";
|
|
139
|
+
ctx.anchorIds.add(id);
|
|
140
|
+
return `<a id="${escapeAttr(id)}"></a>`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A Markdown link or image DESTINATION from content. A `)` would end it
|
|
144
|
+
* early and let the rest of the path become live markup
|
|
145
|
+
* (`.. image:: a)<script>…</script>.png` did, in plain mode too); spaces,
|
|
146
|
+
* angle brackets, quotes and backslashes are encoded the same way.
|
|
147
|
+
*/
|
|
148
|
+
function mdDest(uri) {
|
|
149
|
+
// encodeURIComponent leaves `(`, `)` and `'` alone: explicit codes.
|
|
150
|
+
return uri.replace(/[\s<>()"'`\\]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"));
|
|
151
|
+
}
|
|
152
|
+
/** Sentinel for a `line` nested in inline content: a hard break inside a
|
|
153
|
+
* line block, a space anywhere else (a table cell, heading, link text). */
|
|
154
|
+
const LINE_BREAK = "\u0000BR\u0000";
|
|
155
|
+
/** Inline text with its nested-line breaks as spaces, none at the end. */
|
|
156
|
+
function inlineOneLine(t) {
|
|
157
|
+
const u = t.endsWith(LINE_BREAK) ? t.slice(0, -LINE_BREAK.length) : t;
|
|
158
|
+
return u.split(LINE_BREAK).join(" ");
|
|
159
|
+
}
|
|
160
|
+
/** Sphinx 7 writes XML booleans as `True`; older docutils as `1`. */
|
|
161
|
+
function isTrue(v) {
|
|
162
|
+
return v === "1" || v === "True" || v === "true";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The id a section's heading is given: its first LABEL when it has one
|
|
166
|
+
* (`ids="rust-plugin craft-parts-rust-plugin"` → the label, which is what
|
|
167
|
+
* `:ref:` links target and what the author chose), else docutils' id.
|
|
168
|
+
*/
|
|
169
|
+
function sectionHeadingId(section) {
|
|
170
|
+
const ids = (section.attribs?.ids ?? "").split(/\s+/).filter(Boolean);
|
|
171
|
+
return ids.length > 1 ? ids[1] : ids[0];
|
|
172
|
+
}
|
|
173
|
+
/** Every section id in one document → its heading id. */
|
|
174
|
+
function sectionAnchors(root) {
|
|
175
|
+
const map = new Map();
|
|
176
|
+
const walk = (el) => {
|
|
177
|
+
for (const c of elementChildren(el)) {
|
|
178
|
+
if (c.tagName === "section") {
|
|
179
|
+
const id = sectionHeadingId(c);
|
|
180
|
+
if (id)
|
|
181
|
+
for (const any of (c.attribs?.ids ?? "").split(/\s+/).filter(Boolean))
|
|
182
|
+
map.set(any, id);
|
|
183
|
+
}
|
|
184
|
+
walk(c);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
walk(root);
|
|
188
|
+
return map;
|
|
189
|
+
}
|
|
190
|
+
/** Section anchors of one XML document, for the corpus pre-pass. */
|
|
191
|
+
export function collectSectionAnchors(xml) {
|
|
192
|
+
const root = findElement(parseDocument(xml, { xmlMode: true }), "document");
|
|
193
|
+
return root ? sectionAnchors(root) : new Map();
|
|
194
|
+
}
|
|
195
|
+
const SLUG_SKIPPED = new Set(["literal", "reference", "footnote_reference", "citation_reference", "image", "target"]);
|
|
196
|
+
/** A title's text as the Dogsbay slugger reads it: code spans do not count,
|
|
197
|
+
* unless nothing else is left. */
|
|
198
|
+
function plainTitleText(title) {
|
|
199
|
+
const walk = (el) => {
|
|
200
|
+
let s = "";
|
|
201
|
+
for (const c of el.children) {
|
|
202
|
+
if (isTextNode(c))
|
|
203
|
+
s += textData(c);
|
|
204
|
+
// The slugger reads text nodes only: a code span, a link's label, a
|
|
205
|
+
// footnote mark or an image contribute nothing to the heading's slug.
|
|
206
|
+
else if (isTag(c) && !SLUG_SKIPPED.has(c.tagName))
|
|
207
|
+
s += walk(c);
|
|
208
|
+
}
|
|
209
|
+
return s;
|
|
210
|
+
};
|
|
211
|
+
const text = walk(title).trim();
|
|
212
|
+
return slugifyHeadingText(text) ? text : textContent(title).trim();
|
|
79
213
|
}
|
|
80
214
|
// ── Block level ────────────────────────────────────────────────────────────
|
|
215
|
+
/** Blocks that place their own ids (or have none worth keeping). */
|
|
216
|
+
const OWN_ANCHORS = new Set(["section", "footnote", "citation", "topic", "desc", "raw", "comment", "substitution_definition"]);
|
|
81
217
|
function renderBlocks(nodes, ctx) {
|
|
82
218
|
const out = [];
|
|
83
219
|
for (const n of nodes) {
|
|
84
220
|
if (!isTag(n))
|
|
85
221
|
continue; // whitespace text between blocks — ignore
|
|
222
|
+
// An explicit target (`.. _name:`) on a block other than a section puts
|
|
223
|
+
// its id on that block; keep it as an anchor. Auto ids (`id1`) carry no
|
|
224
|
+
// `names` and are never linked, so they are left out.
|
|
225
|
+
// A label that cannot attach to the next block (something stands between
|
|
226
|
+
// them, such as a substitution definition) stays a `<target>` holding the
|
|
227
|
+
// id itself; it is still a link target. One with `refuri` is an external
|
|
228
|
+
// hyperlink target, not an anchor.
|
|
229
|
+
const standaloneTarget = n.tagName === "target" && !n.attribs?.refuri;
|
|
230
|
+
// A sphinx-needs need renders as a table whose ids ARE the need's ID
|
|
231
|
+
// (`SP_INT`), with no `names`; links to the need target it.
|
|
232
|
+
const needTable = n.tagName === "table" && (n.attribs?.classes ?? "").split(/\s+/).includes("need");
|
|
233
|
+
if (!OWN_ANCHORS.has(n.tagName) && (n.tagName !== "target" || standaloneTarget) && n.attribs?.ids && (n.attribs?.names || needTable)) {
|
|
234
|
+
const tags = n.attribs.ids.split(/\s+/).filter(Boolean).map((id) => anchorTag(ctx, id)).join("");
|
|
235
|
+
if (tags)
|
|
236
|
+
out.push(tags);
|
|
237
|
+
}
|
|
86
238
|
const s = renderBlock(n, ctx);
|
|
87
239
|
if (s != null)
|
|
88
240
|
out.push(s);
|
|
@@ -96,7 +248,33 @@ function renderBlock(el, ctx) {
|
|
|
96
248
|
return `${"#".repeat(Math.min(ctx.depth, 6))} ${renderInline(kids(el), ctx).trim()}`;
|
|
97
249
|
case "section": {
|
|
98
250
|
const child = { ...ctx, depth: ctx.depth + 1 };
|
|
99
|
-
|
|
251
|
+
const id = sectionHeadingId(el);
|
|
252
|
+
const children = elementChildren(el);
|
|
253
|
+
const titleEl = children[0]?.tagName === "title" ? children[0] : undefined;
|
|
254
|
+
const parts = [];
|
|
255
|
+
if (titleEl) {
|
|
256
|
+
let heading = renderBlock(titleEl, child) ?? "";
|
|
257
|
+
// The heading carries the id links target; written out only when it
|
|
258
|
+
// differs from the slug the site derives from the heading text.
|
|
259
|
+
const derived = slugifyHeadingText(plainTitleText(titleEl));
|
|
260
|
+
if (id && id !== derived)
|
|
261
|
+
heading += ` {#${id}}`;
|
|
262
|
+
if (id)
|
|
263
|
+
ctx.anchorIds.add(id);
|
|
264
|
+
// Sphinx's HTML carries EVERY id of a section, and its ¶ permalink
|
|
265
|
+
// uses the first, so `/page/#steps` links shared from the old site
|
|
266
|
+
// target ids the heading no longer has. Keep them as anchors.
|
|
267
|
+
// After the heading, not before: the layout looks for an H1 as the
|
|
268
|
+
// page's FIRST node, and an anchor ahead of it made every labelled
|
|
269
|
+
// page render its title twice.
|
|
270
|
+
const extra = (el.attribs?.ids ?? "").split(/\s+/).filter((x) => x && x !== id && !(x === derived && id === derived));
|
|
271
|
+
parts.push(heading);
|
|
272
|
+
const extraTags = extra.map((x) => anchorTag(ctx, x)).join("");
|
|
273
|
+
if (extraTags)
|
|
274
|
+
parts.push(extraTags);
|
|
275
|
+
}
|
|
276
|
+
parts.push(...renderBlocks(titleEl ? children.slice(1) : children, child));
|
|
277
|
+
return parts.join("\n\n");
|
|
100
278
|
}
|
|
101
279
|
case "paragraph":
|
|
102
280
|
return renderInline(kids(el), ctx).trim();
|
|
@@ -123,19 +301,115 @@ function renderBlock(el, ctx) {
|
|
|
123
301
|
}
|
|
124
302
|
case "line_block":
|
|
125
303
|
// rST line block — significant line breaks; join with hard breaks.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
304
|
+
{
|
|
305
|
+
// Every line (nested `line`s too) on its own line; empty ones dropped
|
|
306
|
+
// (a need with no status or tags left a lone `\`); each escaped so a
|
|
307
|
+
// line cannot start a heading or a list.
|
|
308
|
+
const lines = elementChildren(el)
|
|
309
|
+
.filter((c) => c.tagName === "line")
|
|
310
|
+
.flatMap((line) => renderInline(kids(line), ctx).split(LINE_BREAK))
|
|
311
|
+
.map((t) => t.trim())
|
|
312
|
+
.filter(Boolean)
|
|
313
|
+
.map(escapeLineStart);
|
|
314
|
+
return lines.length ? lines.join("\\\n") : null;
|
|
315
|
+
}
|
|
130
316
|
case "container":
|
|
131
317
|
return renderContainer(el, ctx);
|
|
132
318
|
case "compound":
|
|
133
|
-
//
|
|
134
|
-
// it's
|
|
135
|
-
//
|
|
319
|
+
// A VISIBLE toctree is page content: Sphinx renders it as a list of
|
|
320
|
+
// links where it stands (a landing page's list of its children). The
|
|
321
|
+
// nav comes from the build environment instead (loaders/sphinx.ts);
|
|
322
|
+
// a `:hidden:` toctree reaches the XML empty and renders nothing.
|
|
136
323
|
if ((el.attribs?.classes ?? "").includes("toctree-wrapper"))
|
|
137
|
-
return
|
|
324
|
+
return renderToctree(el, ctx);
|
|
138
325
|
return renderBlocks(elementChildren(el), ctx).join("\n\n");
|
|
326
|
+
case "compact_paragraph":
|
|
327
|
+
return renderLoose(el, ctx);
|
|
328
|
+
case "raw": {
|
|
329
|
+
// An API viewer (Swagger UI, Redoc) drawing a spec in the browser:
|
|
330
|
+
// dropping the script left the page empty. The spec becomes a
|
|
331
|
+
// `:::openapi` directive the site build renders.
|
|
332
|
+
if ((el.attribs?.format ?? "").split(/\s+/).includes("html")) {
|
|
333
|
+
const embed = renderApiViewerEmbed(textContent(el), ctx);
|
|
334
|
+
if (embed !== undefined)
|
|
335
|
+
return embed;
|
|
336
|
+
}
|
|
337
|
+
// Output-format passthrough. Raw HTML is not carried into a migrated
|
|
338
|
+
// page, but in MyST it often wraps words (`<spellexception>master
|
|
339
|
+
// </spellexception>`); those are kept as text. Comments and other
|
|
340
|
+
// formats (LaTeX) are dropped.
|
|
341
|
+
const text = (el.attribs?.format ?? "").split(/\s+/).includes("html") ? rawHtmlText(el).trim() : "";
|
|
342
|
+
ctx.ledger.push({
|
|
343
|
+
construct: `raw ${el.attribs?.format ?? ""}`.trim(),
|
|
344
|
+
note: text ? "raw HTML — tags dropped, text kept" : "raw output passthrough — dropped",
|
|
345
|
+
});
|
|
346
|
+
// Alone on its lines, the text must not start a list or heading.
|
|
347
|
+
return text ? escapeLineStart(escapeText(text)) : null;
|
|
348
|
+
}
|
|
349
|
+
case "plantuml": {
|
|
350
|
+
// sphinxcontrib-plantuml (sphinx-needs' needflow, needuml, needarch,
|
|
351
|
+
// needgantt): the XML builder renders no image, so the diagram source
|
|
352
|
+
// is kept as a code block.
|
|
353
|
+
const code = (el.attribs?.uml ?? "").replace(/\n$/, "");
|
|
354
|
+
ctx.ledger.push({ construct: "plantuml", note: "PlantUML diagram kept as source (not rendered)" });
|
|
355
|
+
if (!code.trim())
|
|
356
|
+
return null;
|
|
357
|
+
const fence = pickCodeFence(code);
|
|
358
|
+
return `${fence}plantuml\n${code}\n${fence}`;
|
|
359
|
+
}
|
|
360
|
+
case "mermaid": {
|
|
361
|
+
// sphinxcontrib-mermaid keeps the diagram source in `code`; the site
|
|
362
|
+
// renders a mermaid fence.
|
|
363
|
+
const code = (el.attribs?.code ?? "").replace(/\n$/, "");
|
|
364
|
+
if (!code.trim())
|
|
365
|
+
return null;
|
|
366
|
+
const fence = pickCodeFence(code);
|
|
367
|
+
return `${fence}mermaid\n${code}\n${fence}`;
|
|
368
|
+
}
|
|
369
|
+
case "topic":
|
|
370
|
+
case "sidebar": {
|
|
371
|
+
if (tag === "topic" && (el.attribs?.classes ?? "").split(/\s+/).includes("contents")) {
|
|
372
|
+
// `.. contents::` — the page's own table of contents replaces it.
|
|
373
|
+
ctx.ledger.push({ construct: "contents", note: "local table of contents — the site's page TOC replaces it" });
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const titleEl = elementChildren(el).find((c) => c.tagName === "title");
|
|
377
|
+
const title = titleEl ? plainText(titleEl) : undefined;
|
|
378
|
+
const body = elementChildren(el).filter((c) => c.tagName !== "title" && c.tagName !== "subtitle");
|
|
379
|
+
const inner = renderBlocks(body, ctx).join("\n\n").trim();
|
|
380
|
+
if (!inner)
|
|
381
|
+
return null;
|
|
382
|
+
const fence = ":".repeat(3 + colonDepth(inner));
|
|
383
|
+
return `${fence}note${title ? `{title="${escapeAttr(title)}"}` : ""}\n${inner}\n${fence}`;
|
|
384
|
+
}
|
|
385
|
+
case "footnote":
|
|
386
|
+
case "citation":
|
|
387
|
+
return renderFootnote(el, ctx);
|
|
388
|
+
case "glossary":
|
|
389
|
+
// Sphinx's wrapper around a definition list whose terms carry the
|
|
390
|
+
// `term-…` ids `:term:` links point at (renderDefinitionList keeps them).
|
|
391
|
+
return renderBlocks(elementChildren(el), ctx).join("\n\n") || null;
|
|
392
|
+
case "rubric":
|
|
393
|
+
// An informal heading: kept out of the TOC, as Sphinx does.
|
|
394
|
+
return `**${renderInline(kids(el), ctx).trim()}**`;
|
|
395
|
+
case "meta":
|
|
396
|
+
if (el.attribs?.name === "description" && el.attribs?.content) {
|
|
397
|
+
ctx.meta.description = el.attribs.content.replace(/\s+/g, " ").trim();
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
ctx.ledger.push({ construct: `meta ${el.attribs?.name ?? ""}`.trim(), note: "page metadata other than description — dropped" });
|
|
401
|
+
return null;
|
|
402
|
+
case "Domain":
|
|
403
|
+
// sphinx-structured-toc (Canonical's starter pack): a landing grid of
|
|
404
|
+
// named slices, each a list of links.
|
|
405
|
+
return renderBlocks(elementChildren(el), ctx).join("\n\n") || null;
|
|
406
|
+
case "Slice": {
|
|
407
|
+
const items = elementChildren(el)
|
|
408
|
+
.filter((c) => c.tagName === "SliceItem")
|
|
409
|
+
.map((c) => `- ${renderInline(kids(c), ctx).trim()}`);
|
|
410
|
+
const name = el.attribs?.name ? `**${escapeText(el.attribs.name)}**` : "";
|
|
411
|
+
return [name, items.join("\n")].filter(Boolean).join("\n\n") || null;
|
|
412
|
+
}
|
|
139
413
|
case "bullet_list":
|
|
140
414
|
return renderList(el, ctx, false);
|
|
141
415
|
case "enumerated_list":
|
|
@@ -147,10 +421,12 @@ function renderBlock(el, ctx) {
|
|
|
147
421
|
case "block_quote":
|
|
148
422
|
return renderBlockQuote(el, ctx);
|
|
149
423
|
case "image":
|
|
150
|
-
return renderImage(el);
|
|
424
|
+
return renderImage(el, ctx);
|
|
151
425
|
case "figure":
|
|
152
426
|
return renderFigure(el, ctx);
|
|
153
427
|
case "table":
|
|
428
|
+
case "NeedtableTable":
|
|
429
|
+
// sphinx-needs' needtable: a table under its own node names.
|
|
154
430
|
return renderTable(el, ctx);
|
|
155
431
|
case "transition":
|
|
156
432
|
return "---";
|
|
@@ -184,10 +460,52 @@ function renderBlock(el, ctx) {
|
|
|
184
460
|
return null;
|
|
185
461
|
case "literal": // stray block-level literal
|
|
186
462
|
return renderInline([el], ctx).trim();
|
|
187
|
-
default:
|
|
188
|
-
|
|
189
|
-
|
|
463
|
+
default: {
|
|
464
|
+
// Never drop what we do not know: render its content and say so.
|
|
465
|
+
const out = renderLoose(el, ctx);
|
|
466
|
+
ctx.ledger.push({
|
|
467
|
+
construct: `<${tag}>`,
|
|
468
|
+
note: out ? "unmapped node — wrapper dropped, content kept" : "unmapped node with no content — dropped",
|
|
469
|
+
});
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const INLINE_TAGS = new Set([
|
|
475
|
+
"reference", "inline", "literal", "strong", "emphasis", "title_reference", "math",
|
|
476
|
+
"footnote_reference", "substitution_reference", "abbreviation", "literal_strong",
|
|
477
|
+
"literal_emphasis", "pending_xref", "image", "target",
|
|
478
|
+
]);
|
|
479
|
+
/** Content of a node with no mapping: a paragraph when it holds only text
|
|
480
|
+
* and inline elements, its blocks otherwise. */
|
|
481
|
+
function renderLoose(el, ctx) {
|
|
482
|
+
const inlineOnly = kids(el).every((c) => isTextNode(c) ? true : isTag(c) ? INLINE_TAGS.has(c.tagName) : true);
|
|
483
|
+
const out = inlineOnly
|
|
484
|
+
? renderInline(kids(el), ctx).trim()
|
|
485
|
+
: renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
|
|
486
|
+
return out || null;
|
|
487
|
+
}
|
|
488
|
+
/** A footnote or citation → `[^id]: text`, matching footnote_reference's `[^refid]`. */
|
|
489
|
+
function renderFootnote(el, ctx) {
|
|
490
|
+
const id = (el.attribs?.ids ?? "").split(/\s+/).filter(Boolean)[0];
|
|
491
|
+
const body = renderBlocks(elementChildren(el).filter((c) => c.tagName !== "label"), ctx).join("\n\n").trim();
|
|
492
|
+
if (!id || !body)
|
|
493
|
+
return null;
|
|
494
|
+
const [first, ...rest] = body.split("\n");
|
|
495
|
+
return [`[^${id}]: ${first}`, ...rest.map((r) => (r ? ` ${r}` : r))].join("\n");
|
|
496
|
+
}
|
|
497
|
+
/** A visible toctree: its caption, then its entries as a list of links. */
|
|
498
|
+
function renderToctree(el, ctx) {
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const cp of elementChildren(el).filter((c) => c.tagName === "compact_paragraph")) {
|
|
501
|
+
const caption = elementChildren(cp).find((c) => c.tagName === "title");
|
|
502
|
+
if (caption)
|
|
503
|
+
out.push(`**${renderInline(kids(caption), ctx).trim()}**`);
|
|
504
|
+
for (const list of elementChildren(cp).filter((c) => c.tagName === "bullet_list")) {
|
|
505
|
+
out.push(renderList(list, ctx, false));
|
|
506
|
+
}
|
|
190
507
|
}
|
|
508
|
+
return out.filter(Boolean).join("\n\n") || null;
|
|
191
509
|
}
|
|
192
510
|
function renderAdmonition(name, el, ctx, title) {
|
|
193
511
|
const inner = renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
|
|
@@ -198,7 +516,7 @@ function renderAdmonition(name, el, ctx, title) {
|
|
|
198
516
|
function renderGenericAdmonition(el, ctx) {
|
|
199
517
|
// `.. admonition:: Custom title` → first <title> child is the title.
|
|
200
518
|
const titleEl = elementChildren(el).find((c) => c.tagName === "title");
|
|
201
|
-
const title = titleEl ?
|
|
519
|
+
const title = titleEl ? plainText(titleEl) : undefined;
|
|
202
520
|
const body = elementChildren(el).filter((c) => c.tagName !== "title");
|
|
203
521
|
const inner = renderBlocks(body, ctx).join("\n\n").trim();
|
|
204
522
|
const attrs = title ? `{title="${escapeAttr(title)}"}` : "";
|
|
@@ -210,10 +528,14 @@ function renderCodeBlock(el, title) {
|
|
|
210
528
|
// the full text content. Language: `language=` attr (Sphinx) or
|
|
211
529
|
// `classes="code <lang>"` (plain docutils).
|
|
212
530
|
const classes = (el.attribs?.classes ?? "").split(/\s+/);
|
|
213
|
-
|
|
531
|
+
// The language is the first word of the fence's info string: a backtick
|
|
532
|
+
// there makes CommonMark reject the fence (the code turns into live HTML),
|
|
533
|
+
// and a space would add attributes. Only a plain language name is kept.
|
|
534
|
+
const rawLang = el.attribs?.language ?? classes.find((c) => c && c !== "code") ?? "";
|
|
535
|
+
const lang = /^[\w#+.-]+$/.test(rawLang) ? rawLang : "";
|
|
214
536
|
const code = textContent(el).replace(/\n$/, "");
|
|
215
537
|
const fence = pickCodeFence(code);
|
|
216
|
-
const info = title ? `${lang} title="${
|
|
538
|
+
const info = title ? `${lang} title="${fenceTitle(title)}"` : lang;
|
|
217
539
|
return `${fence}${info}\n${code}\n${fence}`;
|
|
218
540
|
}
|
|
219
541
|
/**
|
|
@@ -224,15 +546,118 @@ function renderCodeBlock(el, title) {
|
|
|
224
546
|
*/
|
|
225
547
|
function renderContainer(el, ctx) {
|
|
226
548
|
const classes = el.attribs?.classes ?? "";
|
|
227
|
-
|
|
549
|
+
switch (el.attribs?.design_component) {
|
|
550
|
+
case "tab-set":
|
|
551
|
+
return renderTabSet(el, ctx);
|
|
552
|
+
case "dropdown":
|
|
553
|
+
return renderDropdown(el, ctx);
|
|
554
|
+
case undefined:
|
|
555
|
+
break;
|
|
556
|
+
default: {
|
|
557
|
+
// Other sphinx-design components (grid, card, button): content kept.
|
|
558
|
+
const out = renderBlocks(elementChildren(el), ctx).join("\n\n").trim() || null;
|
|
559
|
+
ctx.ledger.push({ construct: `sphinx-design ${el.attribs.design_component}`, note: "layout dropped, content kept" });
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (classes.split(/\s+/).includes("terminal"))
|
|
564
|
+
return renderTerminal(el);
|
|
565
|
+
if (classes.includes("literal-block-wrapper") || isTrue(el.attribs?.literal_block)) {
|
|
228
566
|
const lit = descendant(el, "literal_block");
|
|
229
567
|
const capEl = elementChildren(el).find((c) => c.tagName === "caption");
|
|
230
|
-
|
|
568
|
+
// A title attribute takes plain text: Markdown escapes would show.
|
|
569
|
+
const caption = capEl ? plainText(capEl) : undefined;
|
|
231
570
|
return lit ? renderCodeBlock(lit, caption) : null;
|
|
232
571
|
}
|
|
233
572
|
const inner = renderBlocks(elementChildren(el), ctx).join("\n\n").trim();
|
|
234
573
|
return inner || null;
|
|
235
574
|
}
|
|
575
|
+
/** sphinx-design `tab-set` → `:::tabs`, each label a term and its content
|
|
576
|
+
* the `:` definition. */
|
|
577
|
+
function renderTabSet(el, ctx) {
|
|
578
|
+
const items = [];
|
|
579
|
+
for (const item of elementChildren(el).filter((c) => c.attribs?.design_component === "tab-item")) {
|
|
580
|
+
const labelEl = elementChildren(item).find((c) => c.tagName === "rubric");
|
|
581
|
+
const label = escapeLineStart(labelEl ? renderInline(kids(labelEl), ctx).trim() : "Tab");
|
|
582
|
+
const content = elementChildren(item).find((c) => c.attribs?.design_component === "tab-content");
|
|
583
|
+
const body = content ? renderBlocks(elementChildren(content), ctx).join("\n\n").trim() : "";
|
|
584
|
+
items.push(definitionItem(label, body || label));
|
|
585
|
+
}
|
|
586
|
+
if (!items.length)
|
|
587
|
+
return null;
|
|
588
|
+
const inner = items.join("\n\n");
|
|
589
|
+
const fence = ":".repeat(3 + colonDepth(inner));
|
|
590
|
+
return `${fence}tabs\n${inner}\n${fence}`;
|
|
591
|
+
}
|
|
592
|
+
/** sphinx-design `dropdown` → `:::details{title}`. */
|
|
593
|
+
function renderDropdown(el, ctx) {
|
|
594
|
+
const children = elementChildren(el);
|
|
595
|
+
const titleEl = isTrue(el.attribs?.has_title) ? children.find((c) => c.tagName === "rubric") : undefined;
|
|
596
|
+
const title = titleEl ? plainText(titleEl) : "Details";
|
|
597
|
+
const body = renderBlocks(children.filter((c) => c !== titleEl), ctx).join("\n\n").trim();
|
|
598
|
+
const fence = ":".repeat(3 + colonDepth(body));
|
|
599
|
+
// `open="true"`, not a bare `open`: a bare attribute parses as "", which
|
|
600
|
+
// format-astro reads as closed.
|
|
601
|
+
const open = isTrue(el.attribs?.opened) ? ` open="true"` : "";
|
|
602
|
+
return `${fence}details{title="${escapeAttr(title)}"${open}}\n${body}\n${fence}`;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* sphinx-terminal → the commands in a `bash` fence, without the prompt (what
|
|
606
|
+
* the copy button copies), and the output in a `text` fence.
|
|
607
|
+
*/
|
|
608
|
+
function renderTerminal(el) {
|
|
609
|
+
const out = [];
|
|
610
|
+
let commands = [];
|
|
611
|
+
let prompt = "";
|
|
612
|
+
const flush = () => {
|
|
613
|
+
if (!commands.length)
|
|
614
|
+
return;
|
|
615
|
+
const code = commands.join("\n");
|
|
616
|
+
const fence = pickCodeFence(code);
|
|
617
|
+
// The prompt says WHERE to run it (`user@server01:~$`, a root `#`) — a
|
|
618
|
+
// server how-to switches machines between blocks. Kept as the block's
|
|
619
|
+
// title, not in the code, so the copy button copies only the command.
|
|
620
|
+
const title = informativePrompt(prompt) ? ` title="${fenceTitle(prompt)}"` : "";
|
|
621
|
+
out.push(`${fence}bash${title}\n${code}\n${fence}`);
|
|
622
|
+
commands = [];
|
|
623
|
+
};
|
|
624
|
+
for (const c of elementChildren(el)) {
|
|
625
|
+
if (c.tagName === "container" && (c.attribs?.classes ?? "").split(/\s+/).includes("input")) {
|
|
626
|
+
const promptEl = elementChildren(c).find((x) => (x.attribs?.classes ?? "").split(/\s+/).includes("prompt"));
|
|
627
|
+
const thisPrompt = promptEl ? textContent(promptEl).replace(/\s+/g, " ").trim() : "";
|
|
628
|
+
if (commands.length && thisPrompt !== prompt)
|
|
629
|
+
flush();
|
|
630
|
+
prompt = thisPrompt;
|
|
631
|
+
const cmd = elementChildren(c).find((x) => (x.attribs?.classes ?? "").split(/\s+/).includes("command"));
|
|
632
|
+
// sphinx-terminal marks every input line after the first with `> `;
|
|
633
|
+
// authors use it for several commands, so each becomes its own line.
|
|
634
|
+
if (cmd)
|
|
635
|
+
commands.push(textContent(cmd).replace(/\n> /g, "\n"));
|
|
636
|
+
}
|
|
637
|
+
else if (c.tagName === "literal_block") {
|
|
638
|
+
flush();
|
|
639
|
+
const code = textContent(c).replace(/\n$/, "");
|
|
640
|
+
if (code.trim()) {
|
|
641
|
+
const fence = pickCodeFence(code);
|
|
642
|
+
out.push(`${fence}text\n${code}\n${fence}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
flush();
|
|
647
|
+
return out.join("\n\n") || null;
|
|
648
|
+
}
|
|
649
|
+
/** sphinx-terminal's defaults; a bare `#` is kept, it says "as root". */
|
|
650
|
+
const UNINFORMATIVE_PROMPTS = ["$", "~$", ">", "user@host:~$"];
|
|
651
|
+
/** A prompt worth showing: a named host or user, a directory, or root.
|
|
652
|
+
* sphinx-terminal's defaults (`$`, `~$`, `user@host:~$`) say nothing. */
|
|
653
|
+
function informativePrompt(prompt) {
|
|
654
|
+
return prompt !== "" && !UNINFORMATIVE_PROMPTS.includes(prompt);
|
|
655
|
+
}
|
|
656
|
+
/** A term and its definition body, in the `:` form tabs use. */
|
|
657
|
+
function definitionItem(term, body) {
|
|
658
|
+
const [first, ...rest] = body.split("\n");
|
|
659
|
+
return [`${term}`, `: ${first}`, ...rest.map((r) => (r ? ` ${r}` : r))].join("\n");
|
|
660
|
+
}
|
|
236
661
|
function renderDoctest(el) {
|
|
237
662
|
const code = textContent(el).replace(/\n$/, "");
|
|
238
663
|
const fence = pickCodeFence(code);
|
|
@@ -315,7 +740,22 @@ function renderDesc(el, ctx) {
|
|
|
315
740
|
const plist = elementChildren(sig).find((c) => c.tagName === "desc_parameterlist");
|
|
316
741
|
const params = plist ? serializeParams(plist) : "";
|
|
317
742
|
const full = `${addname}${shortName}`.trim();
|
|
318
|
-
|
|
743
|
+
// The types: an annotation before the name (`class`, `async`), the
|
|
744
|
+
// return type, and one after it (an attribute's `: NotRequired[int]`, a
|
|
745
|
+
// default `= None`). Only the name and parameters were kept, which lost
|
|
746
|
+
// every type on an API page.
|
|
747
|
+
const kids = elementChildren(sig);
|
|
748
|
+
const nameAt = kids.findIndex((c) => c.tagName === "desc_name");
|
|
749
|
+
const flat = (el) => textContent(el).replace(/\s+/g, " ");
|
|
750
|
+
const prefix = kids.slice(0, Math.max(0, nameAt)).filter((c) => c.tagName === "desc_annotation").map(flat).join("");
|
|
751
|
+
let suffix = "";
|
|
752
|
+
for (const c of kids.slice(nameAt + 1)) {
|
|
753
|
+
if (c.tagName === "desc_returns")
|
|
754
|
+
suffix += ` -> ${flat(c).trim()}`;
|
|
755
|
+
else if (c.tagName === "desc_annotation")
|
|
756
|
+
suffix += flat(c);
|
|
757
|
+
}
|
|
758
|
+
return { full, sig: `${prefix}${full}${plist ? `(${params})` : ""}${suffix}`.replace(/\s+/g, " ").trim() };
|
|
319
759
|
};
|
|
320
760
|
const parsed = sigs.map(signatureText);
|
|
321
761
|
const fullName = parsed.find((p) => p.full)?.full ?? "";
|
|
@@ -330,7 +770,9 @@ function renderDesc(el, ctx) {
|
|
|
330
770
|
const id = sig.attribs?.ids?.split(/\s+/)[0];
|
|
331
771
|
if (id && !seenIds.has(id)) {
|
|
332
772
|
seenIds.add(id);
|
|
333
|
-
|
|
773
|
+
const tag = anchorTag(ctx, id);
|
|
774
|
+
if (tag)
|
|
775
|
+
parts.push(tag);
|
|
334
776
|
}
|
|
335
777
|
}
|
|
336
778
|
const content = elementChildren(el).find((c) => c.tagName === "desc_content");
|
|
@@ -375,15 +817,27 @@ function renderList(el, ctx, ordered) {
|
|
|
375
817
|
function renderDefinitionList(el, ctx) {
|
|
376
818
|
const out = [];
|
|
377
819
|
for (const item of elementChildren(el).filter((c) => c.tagName === "definition_list_item")) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
820
|
+
// A glossary term carries `ids="term-…"`, the target of `:term:` links,
|
|
821
|
+
// and one entry can define several terms ("GPO", "Group Policy Object"):
|
|
822
|
+
// all of them are kept, on one term line.
|
|
823
|
+
const term = elementChildren(item)
|
|
824
|
+
.filter((c) => c.tagName === "term")
|
|
825
|
+
.map((termEl) => {
|
|
826
|
+
const ids = (termEl.attribs?.ids ?? "").split(/\s+/).filter(Boolean);
|
|
827
|
+
return ids.map((id) => anchorTag(ctx, id)).join("") + renderInline(kids(termEl), ctx).trim();
|
|
828
|
+
})
|
|
829
|
+
.join(", ");
|
|
830
|
+
// MyST allows several `:` definitions for one term; each is kept (only
|
|
831
|
+
// the first was, which lost two of three on Ubuntu Server's AI-tools page).
|
|
832
|
+
const defs = elementChildren(item)
|
|
833
|
+
.filter((c) => c.tagName === "definition")
|
|
834
|
+
.map((d) => {
|
|
835
|
+
const [first, ...rest] = renderBlocks(elementChildren(d), ctx).join("\n\n").trim().split("\n");
|
|
836
|
+
// Continuation lines indented under the `:` — in ONE string, so a
|
|
837
|
+
// multi-line definition (a code block) gets no blank lines inside.
|
|
838
|
+
return [`: ${first}`, ...rest.map((r) => (r ? ` ${r}` : r))].join("\n");
|
|
839
|
+
});
|
|
840
|
+
out.push([term, ...(defs.length ? defs : [": "])].join("\n"));
|
|
387
841
|
}
|
|
388
842
|
return out.join("\n\n");
|
|
389
843
|
}
|
|
@@ -407,17 +861,61 @@ function renderBlockQuote(el, ctx) {
|
|
|
407
861
|
.map((l) => (l ? `> ${l}` : ">"))
|
|
408
862
|
.join("\n");
|
|
409
863
|
}
|
|
410
|
-
function renderImage(el) {
|
|
411
|
-
|
|
864
|
+
function renderImage(el, ctx) {
|
|
865
|
+
let uri = el.attribs?.uri ?? "";
|
|
866
|
+
// Sphinx rewrites an image's `uri` relative to the SOURCE ROOT (it keeps
|
|
867
|
+
// the authored one as `original_uri`, and lists `candidates`); plain
|
|
868
|
+
// docutils leaves it relative to the document. Only Sphinx sets
|
|
869
|
+
// `candidates`. Read as page-relative, 45 of Ubuntu Server's images broke.
|
|
870
|
+
const prefix = ctx.hrefPrefix.replace(/\/$/, "");
|
|
871
|
+
if (uri.startsWith("/") && el.attribs?.candidates !== undefined) {
|
|
872
|
+
// An extension may record an ABSOLUTE file path. In the build's output
|
|
873
|
+
// dir: a file the build generated (sphinx-needs' needbar / needpie),
|
|
874
|
+
// copied out by the importer. In the source dir: a source image. Anywhere
|
|
875
|
+
// else: not part of the documentation, and not copied.
|
|
876
|
+
const under = (dir) => (dir && uri.startsWith(dir.replace(/\/$/, "") + "/") ? uri.slice(dir.replace(/\/$/, "").length + 1) : undefined);
|
|
877
|
+
const gen = ctx.buildDirs.map(under).find((x) => x !== undefined);
|
|
878
|
+
const src = under(ctx.srcDir);
|
|
879
|
+
if (gen !== undefined && !gen.split("/").includes("..")) {
|
|
880
|
+
ctx.generated.add(gen);
|
|
881
|
+
uri = `${prefix}/sphinx-generated/${gen}`;
|
|
882
|
+
}
|
|
883
|
+
else if (src !== undefined && !src.split("/").includes("..")) {
|
|
884
|
+
uri = `${prefix}/${src}`;
|
|
885
|
+
}
|
|
886
|
+
else if (ctx.buildDirs.length || ctx.srcDir) {
|
|
887
|
+
ctx.ledger.push({ construct: "image", note: "image outside the documentation source — not copied" });
|
|
888
|
+
return el.attribs?.alt ? escapeText(el.attribs.alt) : "";
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
else if (el.attribs?.candidates !== undefined && uri && !/^[a-z][a-z0-9+.-]*:|^\/|^#/i.test(uri)) {
|
|
892
|
+
if (uri.startsWith("../")) {
|
|
893
|
+
// Outside the source root: Sphinx keeps the `..`, and no site path
|
|
894
|
+
// exists for it. The authored path is kept and the loss ledgered.
|
|
895
|
+
ctx.ledger.push({ construct: `image ${uri}`, note: "image outside the documentation source — not copied" });
|
|
896
|
+
uri = el.attribs?.original_uri ?? uri;
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
uri = `${prefix}/${uri}`;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
412
902
|
const alt = el.attribs?.alt ?? "";
|
|
413
|
-
return ``;
|
|
903
|
+
return `})`;
|
|
414
904
|
}
|
|
415
905
|
function renderFigure(el, ctx) {
|
|
416
906
|
const img = elementChildren(el).find((c) => c.tagName === "image");
|
|
417
907
|
const capEl = elementChildren(el).find((c) => c.tagName === "caption");
|
|
418
|
-
|
|
908
|
+
// A figure around something other than an image (a PlantUML diagram).
|
|
909
|
+
const body = img
|
|
910
|
+
? renderImage(img, ctx)
|
|
911
|
+
: renderBlocks(elementChildren(el).filter((c) => c.tagName !== "caption" && c.tagName !== "legend"), ctx).join("\n\n");
|
|
912
|
+
if (!img && !body.trim()) {
|
|
913
|
+
// sphinx-needs' needflow with the Graphviz engine: Sphinx's XML output
|
|
914
|
+
// carries only the caption, so the diagram cannot be recovered from it.
|
|
915
|
+
ctx.ledger.push({ construct: "figure", note: "figure with no content in Sphinx's XML (e.g. a Graphviz needflow) — caption kept" });
|
|
916
|
+
}
|
|
419
917
|
const cap = capEl ? renderInline(kids(capEl), ctx).trim() : "";
|
|
420
|
-
return cap ?
|
|
918
|
+
return [body, cap ? `_${cap}_` : ""].filter((x) => x.trim()).join("\n\n");
|
|
421
919
|
}
|
|
422
920
|
// ── Tables ──────────────────────────────────────────────────────────────────
|
|
423
921
|
function renderTable(el, ctx) {
|
|
@@ -439,8 +937,7 @@ function renderTable(el, ctx) {
|
|
|
439
937
|
const withPipeCaption = (table) => caption ? `${table}\n\n{caption="${captionAttr(caption)}"}` : table;
|
|
440
938
|
// Fall back to an HTML table when any cell spans columns/rows or holds
|
|
441
939
|
// block content a GFM pipe table can't represent.
|
|
442
|
-
const complex = [...headRows, ...bodyRows].some((r) =>
|
|
443
|
-
.filter((c) => c.tagName === "entry")
|
|
940
|
+
const complex = [...headRows, ...bodyRows].some((r) => entriesOf(r)
|
|
444
941
|
.some((e) => e.attribs?.morecols || e.attribs?.morerows || isBlockCell(e)));
|
|
445
942
|
if (complex)
|
|
446
943
|
return renderHtmlTable(headRows, bodyRows, ctx, caption);
|
|
@@ -459,21 +956,38 @@ function renderTable(el, ctx) {
|
|
|
459
956
|
function captionAttr(text) {
|
|
460
957
|
return text.replace(/"/g, """).replace(/\n+/g, " ").trim();
|
|
461
958
|
}
|
|
959
|
+
/**
|
|
960
|
+
* A table a pipe table cannot hold (spans, block content, several
|
|
961
|
+
* paragraphs in a cell) → Dogsbay MD's HTML table form. Each cell's content
|
|
962
|
+
* is converted to Dogsbay MD, parsed by the canonical parser, and the table
|
|
963
|
+
* is serialized by format-dogsbay-md, so the cells are HTML. Markdown inside
|
|
964
|
+
* an HTML block is not parsed: writing the cells as Markdown showed `name`
|
|
965
|
+
* with its backticks, `*Getting started*` with its asterisks, and links as
|
|
966
|
+
* `[text](url)` (Snapcraft's package-information table).
|
|
967
|
+
*/
|
|
462
968
|
function renderHtmlTable(headRows, bodyRows, ctx, caption = "") {
|
|
463
969
|
const cell = (e, tag) => {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
970
|
+
const props = {};
|
|
971
|
+
if (e.attribs?.morecols)
|
|
972
|
+
props.colspan = parseInt(e.attribs.morecols, 10) + 1;
|
|
973
|
+
if (e.attribs?.morerows)
|
|
974
|
+
props.rowspan = parseInt(e.attribs.morerows, 10) + 1;
|
|
975
|
+
const md = renderBlocks(elementChildren(e), ctx).join("\n\n").trim();
|
|
976
|
+
return { type: tag, props, children: md ? dogsbayMdToTree(md).tree : [] };
|
|
468
977
|
};
|
|
469
|
-
const row = (r, tag) =>
|
|
470
|
-
const
|
|
471
|
-
if (caption)
|
|
472
|
-
parts.push(`<caption>${caption}</caption>`);
|
|
978
|
+
const row = (r, tag) => ({ type: "tr", children: entriesOf(r).map((e) => cell(e, tag)) });
|
|
979
|
+
const children = [];
|
|
473
980
|
if (headRows.length)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
981
|
+
children.push({ type: "thead", children: headRows.map((r) => row(r, "th")) });
|
|
982
|
+
children.push({ type: "tbody", children: bodyRows.map((r) => row(r, "td")) });
|
|
983
|
+
const table = {
|
|
984
|
+
type: "table",
|
|
985
|
+
children,
|
|
986
|
+
// No header rows declared: the first row is data, as Sphinx renders it.
|
|
987
|
+
meta: headRows.length ? undefined : { headerless: true },
|
|
988
|
+
props: caption ? { caption } : undefined,
|
|
989
|
+
};
|
|
990
|
+
return treeToDogsbayMd([table]).trim();
|
|
477
991
|
}
|
|
478
992
|
// ── Inline level ─────────────────────────────────────────────────────────────
|
|
479
993
|
function renderInline(nodes, ctx) {
|
|
@@ -489,26 +1003,49 @@ function renderInline(nodes, ctx) {
|
|
|
489
1003
|
}
|
|
490
1004
|
return out;
|
|
491
1005
|
}
|
|
1006
|
+
/** `*text*` with the text's leading and trailing whitespace OUTSIDE the
|
|
1007
|
+
* markers: `* 100 ms*` is not emphasis in CommonMark, so the asterisks
|
|
1008
|
+
* showed (a sphinx-needs need-part title starts with a space). */
|
|
1009
|
+
function wrapMarker(marker, text) {
|
|
1010
|
+
const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(text);
|
|
1011
|
+
return m[2] ? `${m[1]}${marker}${m[2]}${marker}${m[3]}` : text;
|
|
1012
|
+
}
|
|
492
1013
|
function renderInlineEl(el, ctx) {
|
|
493
1014
|
const tag = el.tagName;
|
|
494
1015
|
const inner = () => renderInline(kids(el), ctx);
|
|
495
1016
|
switch (tag) {
|
|
496
1017
|
case "emphasis":
|
|
497
1018
|
case "title_reference":
|
|
498
|
-
return
|
|
1019
|
+
return wrapMarker("*", inlineOneLine(inner()));
|
|
499
1020
|
case "strong":
|
|
500
|
-
return
|
|
1021
|
+
return wrapMarker("**", inlineOneLine(inner()));
|
|
1022
|
+
case "line":
|
|
1023
|
+
// A `line` nested inside inline content (sphinx-needs' meta row: status
|
|
1024
|
+
// and tags as lines within one line). A hard break inside a line block,
|
|
1025
|
+
// a space anywhere else: `\` there split a table row, ended a heading
|
|
1026
|
+
// or escaped a closing `**`.
|
|
1027
|
+
return `${inner().trim()}${LINE_BREAK}`;
|
|
501
1028
|
case "literal":
|
|
502
1029
|
return `\`${textContent(el)}\``;
|
|
503
1030
|
case "reference": {
|
|
504
1031
|
const raw = el.attribs?.refuri ?? (el.attribs?.refid ? `#${el.attribs.refid}` : "");
|
|
505
|
-
|
|
1032
|
+
// A link into Sphinx's own output folder (`../_images/plantuml-….svg`,
|
|
1033
|
+
// a diagram the HTML builder would have rendered) has no file here.
|
|
1034
|
+
if (/(^|\/)_images\/(?:plantuml|graphviz|mermaid|inheritance)-[0-9a-f]+\.(?:svg|png)(?:#.*)?$/i.test(raw) && !/^[a-z][a-z0-9+.-]*:/i.test(raw))
|
|
1035
|
+
return inner();
|
|
1036
|
+
// sphinx-needs writes its cross-page need links with `refdocname` (the
|
|
1037
|
+
// target's absolute docname) and no `internal`: resolved from that.
|
|
1038
|
+
const docname = el.attribs?.refdocname;
|
|
1039
|
+
const uri = docname && el.attribs?.refuri && !/^[a-z][a-z0-9+.-]*:/i.test(el.attribs.refuri)
|
|
1040
|
+
? resolveRef(`/${docname}${raw.includes("#") ? raw.slice(raw.indexOf("#")) : ""}`, true, ctx)
|
|
1041
|
+
: resolveRef(raw, isTrue(el.attribs?.internal), ctx);
|
|
506
1042
|
const text = inner() || uri;
|
|
507
|
-
return uri ? `[${text}](${uri})` : text;
|
|
1043
|
+
return uri ? `[${text}](${mdDest(uri)})` : text;
|
|
508
1044
|
}
|
|
509
1045
|
case "target":
|
|
510
1046
|
return "";
|
|
511
1047
|
case "footnote_reference":
|
|
1048
|
+
case "citation_reference":
|
|
512
1049
|
return `[^${el.attribs?.refid ?? textContent(el)}]`;
|
|
513
1050
|
case "substitution_reference":
|
|
514
1051
|
return inner();
|
|
@@ -524,6 +1061,11 @@ function renderInlineEl(el, ctx) {
|
|
|
524
1061
|
case "inline":
|
|
525
1062
|
// The "Added in version X:" prefix span of a versionmodified note —
|
|
526
1063
|
// italicise it so the inline version note still reads as a note.
|
|
1064
|
+
if ((el.attribs?.classes ?? "").split(/\s+/).includes("need-part") && el.attribs?.ids) {
|
|
1065
|
+
// A sphinx-needs need part (`REQ_1.a`): links target its id.
|
|
1066
|
+
const anchors = el.attribs.ids.split(/\s+/).filter(Boolean).map((id) => anchorTag(ctx, id)).join("");
|
|
1067
|
+
return anchors + inner();
|
|
1068
|
+
}
|
|
527
1069
|
if ((el.attribs?.classes ?? "").includes("versionmodified")) {
|
|
528
1070
|
const t = inner().trim();
|
|
529
1071
|
return t ? `*${t}* ` : "";
|
|
@@ -532,7 +1074,11 @@ function renderInlineEl(el, ctx) {
|
|
|
532
1074
|
case "generated":
|
|
533
1075
|
return inner();
|
|
534
1076
|
case "image":
|
|
535
|
-
return renderImage(el);
|
|
1077
|
+
return renderImage(el, ctx);
|
|
1078
|
+
case "raw":
|
|
1079
|
+
// Inline HTML in MyST (`<spellexception>master</spellexception>`,
|
|
1080
|
+
// `<kbd>`): the tags go, the words stay.
|
|
1081
|
+
return (el.attribs?.format ?? "").split(/\s+/).includes("html") ? escapeText(rawHtmlText(el)) : "";
|
|
536
1082
|
default:
|
|
537
1083
|
return inner();
|
|
538
1084
|
}
|
|
@@ -564,10 +1110,10 @@ function descendant(el, tag) {
|
|
|
564
1110
|
return undefined;
|
|
565
1111
|
}
|
|
566
1112
|
function rowsOf(el) {
|
|
567
|
-
return elementChildren(el).filter((c) => c.tagName === "row");
|
|
1113
|
+
return elementChildren(el).filter((c) => c.tagName === "row" || c.tagName === "NeedtableRow");
|
|
568
1114
|
}
|
|
569
1115
|
function entriesOf(row) {
|
|
570
|
-
return elementChildren(row).filter((c) => c.tagName === "entry");
|
|
1116
|
+
return elementChildren(row).filter((c) => c.tagName === "entry" || c.tagName === "NeedtableHeader");
|
|
571
1117
|
}
|
|
572
1118
|
function cellsText(row, ctx) {
|
|
573
1119
|
return entriesOf(row).map((e) => {
|
|
@@ -611,7 +1157,7 @@ function resolveRef(raw, internal, ctx) {
|
|
|
611
1157
|
if (!raw)
|
|
612
1158
|
return "";
|
|
613
1159
|
if (raw[0] === "#")
|
|
614
|
-
return raw; // in-page anchor
|
|
1160
|
+
return anchorFor(ctx.docPath, raw.slice(1), ctx); // in-page anchor
|
|
615
1161
|
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("//"))
|
|
616
1162
|
return raw; // URL
|
|
617
1163
|
if (!internal)
|
|
@@ -619,9 +1165,9 @@ function resolveRef(raw, internal, ctx) {
|
|
|
619
1165
|
// Strip a trailing .html Sphinx sometimes emits, split off #fragment.
|
|
620
1166
|
const hashIdx = raw.indexOf("#");
|
|
621
1167
|
let path = (hashIdx === -1 ? raw : raw.slice(0, hashIdx)).replace(/\.html$/i, "");
|
|
622
|
-
const
|
|
1168
|
+
const fragId = hashIdx === -1 ? "" : raw.slice(hashIdx + 1);
|
|
623
1169
|
if (!path)
|
|
624
|
-
return
|
|
1170
|
+
return fragId ? anchorFor(ctx.docPath, fragId, ctx) : ""; // same-page ref
|
|
625
1171
|
// Absolute docname (leading '/') is already root-relative.
|
|
626
1172
|
const segs = path.startsWith("/") ? [] : ctx.docDir ? ctx.docDir.split("/") : [];
|
|
627
1173
|
for (const part of path.replace(/^\//, "").split("/")) {
|
|
@@ -634,7 +1180,15 @@ function resolveRef(raw, internal, ctx) {
|
|
|
634
1180
|
}
|
|
635
1181
|
// Prefix with the site basePath so in-body links match the nav's hrefs.
|
|
636
1182
|
const prefix = ctx.hrefPrefix.replace(/\/$/, "");
|
|
637
|
-
|
|
1183
|
+
const target = segs.join("/");
|
|
1184
|
+
// The served path, as the other importers write it: `how-to/index` is
|
|
1185
|
+
// served at `/how-to`, and `/how-to/index` is a 404 on the built site.
|
|
1186
|
+
const served = target === "index" ? "" : target.replace(/\/index$/, "");
|
|
1187
|
+
return `${prefix}/${served}${fragId ? anchorFor(target, fragId, ctx) : ""}`;
|
|
1188
|
+
}
|
|
1189
|
+
/** `#id`, rewritten to the heading id when the id names a section. */
|
|
1190
|
+
function anchorFor(doc, id, ctx) {
|
|
1191
|
+
return `#${ctx.anchorOf(doc, id) ?? id}`;
|
|
638
1192
|
}
|
|
639
1193
|
// ── Escaping / fences ────────────────────────────────────────────────────────
|
|
640
1194
|
function escapeText(s) {
|
|
@@ -648,7 +1202,56 @@ function escapeText(s) {
|
|
|
648
1202
|
// table pipe. (`<` escaping is safe — markdown renders `\<` as a literal
|
|
649
1203
|
// `<`.) Leaving these unescaped let plain rST prose like `see <http://x>`
|
|
650
1204
|
// or `item [1]` be reinterpreted as HTML / link syntax (review #10).
|
|
651
|
-
|
|
1205
|
+
// Braces too: `{#id}` / `{.class}` at the end of a line is attribute
|
|
1206
|
+
// syntax, and `{{ }}` / `{% %}` / `{# #}` are template syntax the site
|
|
1207
|
+
// build evaluates. In rST they are text. The `%`/`#` after a brace is
|
|
1208
|
+
// escaped as well, since the template scanner looks for `{%` even after
|
|
1209
|
+
// a backslash.
|
|
1210
|
+
return s
|
|
1211
|
+
.replace(/\s+/g, " ")
|
|
1212
|
+
.replace(/([\\`*_[\]<|{}])/g, "\\$1")
|
|
1213
|
+
.replace(/\\\{([%#])/g, "\\{\\$1");
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* The words of a raw HTML fragment: comments, `<script>`/`<style>` content
|
|
1217
|
+
* and tags removed (a block-level tag leaves a space, so cells and
|
|
1218
|
+
* paragraphs do not run together), entities decoded. Only something shaped
|
|
1219
|
+
* like a tag is removed: `1 < 2` keeps its words.
|
|
1220
|
+
*/
|
|
1221
|
+
function rawHtmlText(el) {
|
|
1222
|
+
return textContent(el)
|
|
1223
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
1224
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ")
|
|
1225
|
+
.replace(/<\/?(?:p|div|br|td|th|tr|li|ul|ol|table|thead|tbody|h[1-6]|section|blockquote|pre|hr)\b[^>]*>/gi, " ")
|
|
1226
|
+
.replace(/<\/?[a-zA-Z][^<>]*>/g, "")
|
|
1227
|
+
.replace(/</g, "<")
|
|
1228
|
+
.replace(/>/g, ">")
|
|
1229
|
+
.replace(/"/g, '"')
|
|
1230
|
+
.replace(/'/g, "'")
|
|
1231
|
+
.replace(/&/g, "&");
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* A code fence's `title="…"` value. A backtick in a fence's info string
|
|
1235
|
+
* makes CommonMark reject the fence, so the code becomes paragraph text and
|
|
1236
|
+
* any HTML in it live (a prompt `:user: a`b` did exactly that); quotes would
|
|
1237
|
+
* end the attribute. Both are replaced, and the value is one line.
|
|
1238
|
+
*/
|
|
1239
|
+
function fenceTitle(s) {
|
|
1240
|
+
return s.replace(/`/g, "'").replace(/"/g, "'").replace(/[\r\n]+/g, " ").trim();
|
|
1241
|
+
}
|
|
1242
|
+
/** Text of an element for an attribute value: no markup, no escapes, one
|
|
1243
|
+
* line, and no `"` (the parser does not decode `"`). */
|
|
1244
|
+
function plainText(el) {
|
|
1245
|
+
return textContent(el).replace(/\s+/g, " ").replace(/"/g, "'").trim();
|
|
1246
|
+
}
|
|
1247
|
+
/** A line that would start a list, heading, quote or definition is escaped,
|
|
1248
|
+
* for text placed alone on a line (a tab label). */
|
|
1249
|
+
function escapeLineStart(s) {
|
|
1250
|
+
return s
|
|
1251
|
+
.replace(/^(\d+)([.)])(?=\s|$)/, "$1\\$2")
|
|
1252
|
+
// Every leading colon: `\:::note` still read `::note` as a directive.
|
|
1253
|
+
.replace(/^(:+)/, (m) => m.replace(/:/g, "\\:"))
|
|
1254
|
+
.replace(/^([-+#>])/, "\\$1");
|
|
652
1255
|
}
|
|
653
1256
|
function escapeAttr(s) {
|
|
654
1257
|
return s.replace(/"/g, """);
|
|
@@ -666,10 +1269,16 @@ function colonDepth(text) {
|
|
|
666
1269
|
}
|
|
667
1270
|
return max;
|
|
668
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* A backtick fence longer than ANY backtick run in the code (serialize-core's
|
|
1274
|
+
* rule). Counting only runs at column 0 missed a closer indented 1-3 spaces,
|
|
1275
|
+
* which CommonMark accepts: code holding " ```" ended the block early and
|
|
1276
|
+
* the rest of it became live HTML.
|
|
1277
|
+
*/
|
|
669
1278
|
function pickCodeFence(code) {
|
|
670
1279
|
let longest = 0;
|
|
671
|
-
for (const m of code.matchAll(
|
|
672
|
-
longest = Math.max(longest, m[
|
|
1280
|
+
for (const m of code.matchAll(/`+/g))
|
|
1281
|
+
longest = Math.max(longest, m[0].length);
|
|
673
1282
|
return "`".repeat(Math.max(3, longest + 1));
|
|
674
1283
|
}
|
|
675
1284
|
//# sourceMappingURL=transform.js.map
|