@dogsbay/types 0.2.0-beta.164 → 0.2.0-beta.166
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/format.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/text-map.d.ts +163 -0
- package/dist/text-map.js +559 -0
- package/package.json +1 -1
package/dist/format.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface ExportPage {
|
|
|
24
24
|
* Importers that rewrite links to root-absolute leave it unset.
|
|
25
25
|
*/
|
|
26
26
|
sourcePath?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The file the importer read the page from, as it opened it (absolute): set by the dogsbay-md
|
|
29
|
+
* loader for a page parsed with a source map (`site dev`'s block records), so a site of several
|
|
30
|
+
* sources knows which source a page came from (`sourcePath` alone is the same in each).
|
|
31
|
+
*/
|
|
32
|
+
sourceFile?: string;
|
|
27
33
|
/**
|
|
28
34
|
* The page is its folder's index, served at `/<slug>/` where a leaf is served at `/<slug>`
|
|
29
35
|
* (Docusaurus's category index docs). Written as `<slug>/index.md`, which every Dogsbay MD
|
package/dist/index.d.ts
CHANGED
|
@@ -13,3 +13,5 @@ export { checkFrontmatter, frontmatterPolicyFromConfig } from "./frontmatter-che
|
|
|
13
13
|
export type { FrontmatterFinding } from "./frontmatter-check.js";
|
|
14
14
|
export type { ImportLimits, LimitName, LimitHost } from "./limits.js";
|
|
15
15
|
export { DEFAULT_LIMITS, LIMIT_DESCRIPTIONS, LIMIT_REACHED, resolveLimits, parseLimit, formatLimit, limitMessage, utf8Bytes } from "./limits.js";
|
|
16
|
+
export type { TextMap, TextSegment, CopySegment, ExprSegment, IncludeSegment, GeneratedSegment, TextCondition, TextOrigin, LineSource } from "./text-map.js";
|
|
17
|
+
export { identityTextMap, textMapLines, sliceTextMap, composeTextMaps, lookupTextLine, lookupTextRange, textLineOrigins, rangeOrigin, textMapOfLines, diffTextMap, offsetIncludeMaps, frontMatterLines } from "./text-map.js";
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { STATUS_VALUES, parseMeta } from "./meta.js";
|
|
|
4
4
|
export { WELL_KNOWN_FIELDS, INERT_FIELDS, AGGREGATOR_OWNED, EMPTY_POLICY, typeOf, matchesType, nearMisses, isSafeAutoFix, frontmatterKeyLines, globMatches, contentRelativePath, requiredFieldsFor, } from "./frontmatter-schema.js";
|
|
5
5
|
export { checkFrontmatter, frontmatterPolicyFromConfig } from "./frontmatter-check.js";
|
|
6
6
|
export { DEFAULT_LIMITS, LIMIT_DESCRIPTIONS, LIMIT_REACHED, resolveLimits, parseLimit, formatLimit, limitMessage, utf8Bytes } from "./limits.js";
|
|
7
|
+
export { identityTextMap, textMapLines, sliceTextMap, composeTextMaps, lookupTextLine, lookupTextRange, textLineOrigins, rangeOrigin, textMapOfLines, diffTextMap, offsetIncludeMaps, frontMatterLines } from "./text-map.js";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text maps: where each line of a stage's output came from in that stage's input.
|
|
3
|
+
*
|
|
4
|
+
* Several stages rewrite a page's text between its file and the parser (example expansion, minja
|
|
5
|
+
* templating, includes, data directives; for DITA, the converter). Each returns a map of what it
|
|
6
|
+
* wrote, and the maps compose, so a block the parser places at a line of the final text can be
|
|
7
|
+
* traced back to the file and lines that made it (plans/content-source-maps.md).
|
|
8
|
+
*
|
|
9
|
+
* Lines are 1-based. A map lists its output in order: every output line is in exactly one segment.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A branch of an `{% if %}` or `{% switch %}` the output was rendered under, in the segment's input
|
|
13
|
+
* (its file): another variant of the page may show other text, or none, there.
|
|
14
|
+
*/
|
|
15
|
+
export interface TextCondition {
|
|
16
|
+
branch: "if" | "elif" | "else" | "case";
|
|
17
|
+
/** The condition as written (`product == "acme"`; a case: `subject == value`); none for else */
|
|
18
|
+
test?: string;
|
|
19
|
+
/** Lines of the branch's tag */
|
|
20
|
+
from: number;
|
|
21
|
+
to: number;
|
|
22
|
+
/** Lines of the whole block, its opening tag to its end tag */
|
|
23
|
+
block: {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Output lines copied from the input, one for one: output line k of the segment is input line `from + k - 1`. */
|
|
29
|
+
export interface CopySegment {
|
|
30
|
+
kind: "copy";
|
|
31
|
+
lines: number;
|
|
32
|
+
from: number;
|
|
33
|
+
/** The branches it was rendered under, outermost first */
|
|
34
|
+
conditions?: TextCondition[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Template output (a variable, a filter, lines whitespace control joined) standing for input lines
|
|
38
|
+
* `from`–`to`. The rendered words are not the source's: to change them, edit those source lines
|
|
39
|
+
* (with their `{{ }}` as written), not the output.
|
|
40
|
+
*/
|
|
41
|
+
export interface ExprSegment {
|
|
42
|
+
kind: "expr";
|
|
43
|
+
lines: number;
|
|
44
|
+
from: number;
|
|
45
|
+
to: number;
|
|
46
|
+
conditions?: TextCondition[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Another file's text, included at input lines `from`–`to`. `map` maps these output lines into
|
|
50
|
+
* that file's lines (they may have been rewritten there too); without it, only the include is known.
|
|
51
|
+
*/
|
|
52
|
+
export interface IncludeSegment {
|
|
53
|
+
kind: "include";
|
|
54
|
+
lines: number;
|
|
55
|
+
from: number;
|
|
56
|
+
to: number;
|
|
57
|
+
file: string;
|
|
58
|
+
map?: TextMap;
|
|
59
|
+
/** The branches the include tag was rendered under, in the including text */
|
|
60
|
+
conditions?: TextCondition[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Output with no text of its own to edit: a data table, an example from the examples folder, a
|
|
64
|
+
* DITA related-links list. `from`–`to` is what asked for it (the directive), when anything did.
|
|
65
|
+
*/
|
|
66
|
+
export interface GeneratedSegment {
|
|
67
|
+
kind: "generated";
|
|
68
|
+
lines: number;
|
|
69
|
+
from?: number;
|
|
70
|
+
to?: number;
|
|
71
|
+
conditions?: TextCondition[];
|
|
72
|
+
}
|
|
73
|
+
export type TextSegment = CopySegment | ExprSegment | IncludeSegment | GeneratedSegment;
|
|
74
|
+
export interface TextMap {
|
|
75
|
+
segments: TextSegment[];
|
|
76
|
+
}
|
|
77
|
+
/** Where one output line came from (`lookupTextLine`). */
|
|
78
|
+
export interface TextOrigin {
|
|
79
|
+
kind: TextSegment["kind"];
|
|
80
|
+
/** The file the position is in: undefined for the text the first map was given (the page). */
|
|
81
|
+
file?: string;
|
|
82
|
+
/** The line, for a copied line. */
|
|
83
|
+
line?: number;
|
|
84
|
+
/** The input lines that made it (a copied line: `line` both times); absent for generated output with no source. */
|
|
85
|
+
from?: number;
|
|
86
|
+
to?: number;
|
|
87
|
+
/** For `include` with no map: the file included. */
|
|
88
|
+
included?: string;
|
|
89
|
+
/** The includes it came through, outermost first: each in `file` (undefined: the page) at lines `from`–`to`. */
|
|
90
|
+
via: Array<{
|
|
91
|
+
file?: string;
|
|
92
|
+
from: number;
|
|
93
|
+
to: number;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* The `{% if %}` / `{% switch %}` branches it was rendered under, outermost first, each in `file`
|
|
97
|
+
* (undefined: the page): through an include, the include tag's and then the included file's.
|
|
98
|
+
*/
|
|
99
|
+
conditions?: Array<TextCondition & {
|
|
100
|
+
file?: string;
|
|
101
|
+
}>;
|
|
102
|
+
/**
|
|
103
|
+
* For a range (`rangeOrigin`): some source lines between `from` and `to` are not in it (a false
|
|
104
|
+
* `{% if %}` removed them), so its text is not the source's even where every line is copied.
|
|
105
|
+
*/
|
|
106
|
+
gaps?: true;
|
|
107
|
+
}
|
|
108
|
+
/** A map of text passed through unchanged. */
|
|
109
|
+
export declare function identityTextMap(lines: number): TextMap;
|
|
110
|
+
/** How many output lines a map covers. */
|
|
111
|
+
export declare function textMapLines(map: TextMap): number;
|
|
112
|
+
/** The segments for output lines `start`–`start + count - 1`, cut to fit; lines past the map's end are generated. */
|
|
113
|
+
export declare function sliceTextMap(map: TextMap, start: number, count: number): TextMap;
|
|
114
|
+
/**
|
|
115
|
+
* Two stages as one: `first` maps the first stage's output to its input (the file), `second` maps
|
|
116
|
+
* the second stage's output to ITS input, which is the first stage's output. The result maps the
|
|
117
|
+
* second stage's output to the file.
|
|
118
|
+
*/
|
|
119
|
+
export declare function composeTextMaps(first: TextMap, second: TextMap): TextMap;
|
|
120
|
+
/** Where output line `line` came from; undefined past the map's end. */
|
|
121
|
+
export declare function lookupTextLine(map: TextMap, line: number): TextOrigin | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Every output line's origin, in order: what `lookupTextLine` gives for each, in one walk of the
|
|
124
|
+
* map (a page's blocks look up ranges of it, so a parse stays linear in the page).
|
|
125
|
+
*/
|
|
126
|
+
export declare function textLineOrigins(map: TextMap): TextOrigin[];
|
|
127
|
+
/**
|
|
128
|
+
* One origin for several lines (a block): in the deepest file they all share, from the first
|
|
129
|
+
* line to the last there. Lines that went further into an include count as that include's tag.
|
|
130
|
+
* The kind is the least editable of the lines': generated, then template output, then an include,
|
|
131
|
+
* then copied text. Undefined for no lines.
|
|
132
|
+
*/
|
|
133
|
+
export declare function rangeOrigin(origins: readonly TextOrigin[]): TextOrigin | undefined;
|
|
134
|
+
/** Where output lines `from`–`to` came from, as one origin (`rangeOrigin`). */
|
|
135
|
+
export declare function lookupTextRange(map: TextMap, from: number, to: number): TextOrigin | undefined;
|
|
136
|
+
/** Where one output line came from, for `textMapOfLines`: an input line copied, or a span (or nothing) it stands for. */
|
|
137
|
+
export type LineSource = number | {
|
|
138
|
+
kind: "expr" | "generated";
|
|
139
|
+
from?: number;
|
|
140
|
+
to?: number;
|
|
141
|
+
};
|
|
142
|
+
/** The map of an output whose line k came from `sources[k - 1]`: consecutive copies and equal spans become one segment. */
|
|
143
|
+
export declare function textMapOfLines(sources: readonly LineSource[]): TextMap;
|
|
144
|
+
/**
|
|
145
|
+
* The map of a rewrite nobody recorded (a host's tidy-up pass after templating): lines equal in both
|
|
146
|
+
* are copies, found by a line diff; a changed run stands for the lines it replaced (`expr`), an
|
|
147
|
+
* inserted run for nothing (`generated`). Lines are compared as written.
|
|
148
|
+
*/
|
|
149
|
+
export declare function diffTextMap(before: string, after: string): TextMap;
|
|
150
|
+
/**
|
|
151
|
+
* A map whose include segments counted the lines of text a host's loader changed before the
|
|
152
|
+
* renderer read it, put back in the files' own lines: each included file's segments (their nested
|
|
153
|
+
* maps, recursively) move down by `linesBefore(file)`, the lines the loader removed from the top
|
|
154
|
+
* of that file (its front matter, say). `file` is the name the include segment gives. The page's
|
|
155
|
+
* own segments are left as they are.
|
|
156
|
+
*/
|
|
157
|
+
export declare function offsetIncludeMaps(map: TextMap, linesBefore: (file: string) => number): TextMap;
|
|
158
|
+
/**
|
|
159
|
+
* How far a leading YAML front matter block (`---` … `---`) moves the rest of the text down: line 1
|
|
160
|
+
* of the text after it is line `1 + frontMatterLines(text)` of the text. 0 when there is none. What a
|
|
161
|
+
* loader stripping it removed from the top, for `offsetIncludeMaps`.
|
|
162
|
+
*/
|
|
163
|
+
export declare function frontMatterLines(text: string): number;
|
package/dist/text-map.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text maps: where each line of a stage's output came from in that stage's input.
|
|
3
|
+
*
|
|
4
|
+
* Several stages rewrite a page's text between its file and the parser (example expansion, minja
|
|
5
|
+
* templating, includes, data directives; for DITA, the converter). Each returns a map of what it
|
|
6
|
+
* wrote, and the maps compose, so a block the parser places at a line of the final text can be
|
|
7
|
+
* traced back to the file and lines that made it (plans/content-source-maps.md).
|
|
8
|
+
*
|
|
9
|
+
* Lines are 1-based. A map lists its output in order: every output line is in exactly one segment.
|
|
10
|
+
*/
|
|
11
|
+
/** A map of text passed through unchanged. */
|
|
12
|
+
export function identityTextMap(lines) {
|
|
13
|
+
return { segments: lines > 0 ? [{ kind: "copy", lines, from: 1 }] : [] };
|
|
14
|
+
}
|
|
15
|
+
/** How many output lines a map covers. */
|
|
16
|
+
export function textMapLines(map) {
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (const s of map.segments)
|
|
19
|
+
n += s.lines;
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
/** The segments for output lines `start`–`start + count - 1`, cut to fit; lines past the map's end are generated. */
|
|
23
|
+
export function sliceTextMap(map, start, count) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const end = start + count; // exclusive
|
|
26
|
+
let at = 1;
|
|
27
|
+
for (const s of map.segments) {
|
|
28
|
+
const sEnd = at + s.lines;
|
|
29
|
+
const a = Math.max(at, start);
|
|
30
|
+
const b = Math.min(sEnd, end);
|
|
31
|
+
if (a < b)
|
|
32
|
+
out.push(cut(s, a - at, b - a));
|
|
33
|
+
at = sEnd;
|
|
34
|
+
if (at >= end)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
// Lines before the first (start < 1) and past the end have nothing to trace them to
|
|
38
|
+
const before = Math.max(0, Math.min(1, end) - start);
|
|
39
|
+
if (before > 0)
|
|
40
|
+
out.unshift({ kind: "generated", lines: before });
|
|
41
|
+
const covered = Math.max(0, Math.min(at, end) - Math.max(start, 1));
|
|
42
|
+
if (before + covered < count)
|
|
43
|
+
out.push({ kind: "generated", lines: count - before - covered });
|
|
44
|
+
return { segments: merge(out) };
|
|
45
|
+
}
|
|
46
|
+
/** `skip` lines into a segment, `lines` long. */
|
|
47
|
+
function cut(s, skip, lines) {
|
|
48
|
+
if (s.kind === "copy")
|
|
49
|
+
return { kind: "copy", lines, from: s.from + skip, ...(s.conditions ? { conditions: s.conditions } : {}) };
|
|
50
|
+
if (s.kind === "include" && s.map)
|
|
51
|
+
return { ...s, lines, map: sliceTextMap(s.map, skip + 1, lines) };
|
|
52
|
+
return { ...s, lines };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Joins neighbouring copies that continue each other, and neighbouring pieces of one include (the
|
|
56
|
+
* same file at the same tag, as slicing leaves them), so a range over them stays inside the file.
|
|
57
|
+
*/
|
|
58
|
+
function merge(segments) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const s of segments) {
|
|
61
|
+
if (s.lines <= 0)
|
|
62
|
+
continue;
|
|
63
|
+
const last = out[out.length - 1];
|
|
64
|
+
if (last && !sameConditions(last.conditions, s.conditions))
|
|
65
|
+
out.push(s);
|
|
66
|
+
else if (last?.kind === "copy" && s.kind === "copy" && last.from + last.lines === s.from) {
|
|
67
|
+
out[out.length - 1] = { ...last, lines: last.lines + s.lines };
|
|
68
|
+
}
|
|
69
|
+
else if (last?.kind === "include" && s.kind === "include" && last.map && s.map && last.file === s.file && last.from === s.from && last.to === s.to) {
|
|
70
|
+
// Each piece's map cut to its own lines first, so a map longer or shorter than its piece keeps the next piece in place
|
|
71
|
+
const joined = [...sliceTextMap(last.map, 1, last.lines).segments, ...sliceTextMap(s.map, 1, s.lines).segments];
|
|
72
|
+
out[out.length - 1] = { ...last, lines: last.lines + s.lines, map: { segments: merge(joined) } };
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
out.push(s);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Two stages as one: `first` maps the first stage's output to its input (the file), `second` maps
|
|
81
|
+
* the second stage's output to ITS input, which is the first stage's output. The result maps the
|
|
82
|
+
* second stage's output to the file.
|
|
83
|
+
*/
|
|
84
|
+
export function composeTextMaps(first, second) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const s of second.segments) {
|
|
87
|
+
const { conditions, ...bare } = s;
|
|
88
|
+
// The second stage's branches are in its input: taken back to the file through `first`
|
|
89
|
+
const lifted = conditions ? liftConditions(first, conditions) : [];
|
|
90
|
+
if (bare.kind === "copy") {
|
|
91
|
+
for (const o of sliceTextMap(first, bare.from, bare.lines).segments)
|
|
92
|
+
out.push(underAlso(o, lifted));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// lift keeps the first stage's branches over the range it stands for (inside an include too)
|
|
96
|
+
out.push(underAlso(lift(first, bare), lifted));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { segments: merge(out) };
|
|
100
|
+
}
|
|
101
|
+
/** `s`, also rendered under `more` (after its own) */
|
|
102
|
+
function underAlso(s, more) {
|
|
103
|
+
if (more.length === 0)
|
|
104
|
+
return s;
|
|
105
|
+
return { ...s, conditions: [...(s.conditions ?? []), ...more] };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Conditions in `first`'s output, in `first`'s input: kept where their tag and block lines trace
|
|
109
|
+
* to the input itself (not into an include or generated text), else left out.
|
|
110
|
+
*/
|
|
111
|
+
function liftConditions(first, conditions) {
|
|
112
|
+
const out = [];
|
|
113
|
+
const back = (from, to) => {
|
|
114
|
+
const o = lookupTextRange(first, from, to);
|
|
115
|
+
return o && o.via.length === 0 && o.file === undefined && o.from !== undefined && (o.kind === "copy" || o.kind === "expr") ? { from: o.from, to: o.to ?? o.from } : undefined;
|
|
116
|
+
};
|
|
117
|
+
for (const c of conditions) {
|
|
118
|
+
const tag = back(c.from, c.to);
|
|
119
|
+
const block = back(c.block.from, c.block.to);
|
|
120
|
+
if (tag && block)
|
|
121
|
+
out.push({ ...c, ...tag, block });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function sameCondition(a, b) {
|
|
126
|
+
return a.branch === b.branch && a.test === b.test && a.from === b.from && a.to === b.to && a.block.from === b.block.from && a.block.to === b.block.to;
|
|
127
|
+
}
|
|
128
|
+
function sameConditions(a, b) {
|
|
129
|
+
const x = a ?? [];
|
|
130
|
+
const y = b ?? [];
|
|
131
|
+
return x.length === y.length && x.every((c, k) => sameCondition(c, y[k]));
|
|
132
|
+
}
|
|
133
|
+
/** A segment's conditions as an origin's, in `file` */
|
|
134
|
+
function inFile(conditions, file) {
|
|
135
|
+
return (conditions ?? []).map((c) => (file !== undefined ? { ...c, file } : { ...c }));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A segment whose source range is in `first`'s output, with that range taken back through `first`.
|
|
139
|
+
* A range inside one segment of `first` is expressed in that segment's input (inside an included
|
|
140
|
+
* file, through its map); a range over several is the span of their inputs at `first`'s level.
|
|
141
|
+
*/
|
|
142
|
+
function lift(first, s) {
|
|
143
|
+
if (s.from === undefined)
|
|
144
|
+
return s;
|
|
145
|
+
const to = Math.max(s.to ?? s.from, s.from);
|
|
146
|
+
const pieces = [];
|
|
147
|
+
let at = 1;
|
|
148
|
+
for (const o of first.segments) {
|
|
149
|
+
if (o.lines <= 0)
|
|
150
|
+
continue;
|
|
151
|
+
const end = at + o.lines - 1;
|
|
152
|
+
if (end >= s.from && at <= to)
|
|
153
|
+
pieces.push({ o, a: Math.max(s.from, at) - at, b: Math.min(to, end) - at });
|
|
154
|
+
if (end >= to)
|
|
155
|
+
break;
|
|
156
|
+
at += o.lines;
|
|
157
|
+
}
|
|
158
|
+
// Past the end of `first`: nothing to trace it to
|
|
159
|
+
if (pieces.length === 0)
|
|
160
|
+
return { kind: "generated", lines: s.lines };
|
|
161
|
+
if (pieces.length === 1)
|
|
162
|
+
return through(pieces[0].o, pieces[0].a, pieces[0].b, s);
|
|
163
|
+
let from;
|
|
164
|
+
let last;
|
|
165
|
+
let generated = s.kind === "generated";
|
|
166
|
+
const conditions = [];
|
|
167
|
+
for (const { o } of pieces)
|
|
168
|
+
for (const c of o.conditions ?? [])
|
|
169
|
+
if (!conditions.some((x) => sameCondition(x, c)))
|
|
170
|
+
conditions.push(c);
|
|
171
|
+
const under = conditions.length ? { conditions } : {};
|
|
172
|
+
for (const { o, a, b } of pieces) {
|
|
173
|
+
const range = o.kind === "copy" ? [o.from + a, o.from + b] : o.from !== undefined ? [o.from, o.to ?? o.from] : undefined;
|
|
174
|
+
if (o.kind === "generated")
|
|
175
|
+
generated = true;
|
|
176
|
+
if (!range)
|
|
177
|
+
continue;
|
|
178
|
+
from = from === undefined ? range[0] : Math.min(from, range[0]);
|
|
179
|
+
last = last === undefined ? range[1] : Math.max(last, range[1]);
|
|
180
|
+
}
|
|
181
|
+
if (from === undefined || last === undefined)
|
|
182
|
+
return { kind: "generated", lines: s.lines, ...under };
|
|
183
|
+
if (s.kind === "include")
|
|
184
|
+
return { ...s, from, to: last, ...under };
|
|
185
|
+
return generated ? { kind: "generated", lines: s.lines, from, to: last, ...under } : { kind: "expr", lines: s.lines, from, to: last, ...under };
|
|
186
|
+
}
|
|
187
|
+
/** `s` (its range at offsets `a`–`b` of segment `o`) expressed in `o`'s input. */
|
|
188
|
+
function through(o, a, b, s) {
|
|
189
|
+
return underAlso(throughBare(o, a, b, s), o.conditions ?? []);
|
|
190
|
+
}
|
|
191
|
+
function throughBare(o, a, b, s) {
|
|
192
|
+
if (o.kind === "copy")
|
|
193
|
+
return { ...s, from: o.from + a, to: o.from + b };
|
|
194
|
+
if (o.kind === "include") {
|
|
195
|
+
// The range is text of the included file: express it there, inside an include segment
|
|
196
|
+
if (!o.map) {
|
|
197
|
+
// Where in that file is not known: an include there keeps its own file and map, at this include's tag
|
|
198
|
+
if (s.kind === "include")
|
|
199
|
+
return { ...s, from: o.from, to: o.to };
|
|
200
|
+
return { kind: "include", lines: s.lines, from: o.from, to: o.to, file: o.file };
|
|
201
|
+
}
|
|
202
|
+
return { kind: "include", lines: s.lines, from: o.from, to: o.to, file: o.file, map: { segments: [lift(o.map, { ...s, from: a + 1, to: b + 1 })] } };
|
|
203
|
+
}
|
|
204
|
+
// The range was itself template output or generated: what asked for it is the source, if anything did
|
|
205
|
+
if (o.from === undefined)
|
|
206
|
+
return { kind: "generated", lines: s.lines };
|
|
207
|
+
const range = { from: o.from, to: o.to ?? o.from };
|
|
208
|
+
if (s.kind === "include")
|
|
209
|
+
return { ...s, ...range };
|
|
210
|
+
return s.kind === "generated" || o.kind === "generated" ? { kind: "generated", lines: s.lines, ...range } : { kind: "expr", lines: s.lines, ...range };
|
|
211
|
+
}
|
|
212
|
+
/** Where output line `line` came from; undefined past the map's end. */
|
|
213
|
+
export function lookupTextLine(map, line) {
|
|
214
|
+
const via = [];
|
|
215
|
+
const conditions = [];
|
|
216
|
+
const under = () => (conditions.length ? { conditions } : {});
|
|
217
|
+
let file;
|
|
218
|
+
let current = map;
|
|
219
|
+
let target = line;
|
|
220
|
+
for (;;) {
|
|
221
|
+
let at = 1;
|
|
222
|
+
let found;
|
|
223
|
+
for (const s of current.segments) {
|
|
224
|
+
if (target < at + s.lines) {
|
|
225
|
+
found = { s, k: target - at };
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
at += s.lines;
|
|
229
|
+
}
|
|
230
|
+
if (!found || target < 1) {
|
|
231
|
+
// Inside an include whose map is shorter than it: in that file, with nothing more to trace it to
|
|
232
|
+
return via.length ? { kind: "generated", ...(file !== undefined ? { file } : {}), via, ...under() } : undefined;
|
|
233
|
+
}
|
|
234
|
+
const { s, k } = found;
|
|
235
|
+
conditions.push(...inFile(s.conditions, file));
|
|
236
|
+
if (s.kind === "copy")
|
|
237
|
+
return { kind: "copy", ...(file !== undefined ? { file } : {}), line: s.from + k, from: s.from + k, to: s.from + k, via, ...under() };
|
|
238
|
+
if (s.kind === "include" && s.map) {
|
|
239
|
+
via.push({ ...(file !== undefined ? { file } : {}), from: s.from, to: s.to });
|
|
240
|
+
file = s.file;
|
|
241
|
+
current = s.map;
|
|
242
|
+
target = k + 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind: s.kind,
|
|
247
|
+
...(file !== undefined ? { file } : {}),
|
|
248
|
+
...(s.from !== undefined ? { from: s.from, to: s.to ?? s.from } : {}),
|
|
249
|
+
...(s.kind === "include" ? { included: s.file } : {}),
|
|
250
|
+
via,
|
|
251
|
+
...under(),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Every output line's origin, in order: what `lookupTextLine` gives for each, in one walk of the
|
|
257
|
+
* map (a page's blocks look up ranges of it, so a parse stays linear in the page).
|
|
258
|
+
*/
|
|
259
|
+
export function textLineOrigins(map) {
|
|
260
|
+
const out = [];
|
|
261
|
+
const walk = (m, file, via, limit, outer) => {
|
|
262
|
+
const start = out.length;
|
|
263
|
+
const at = () => ({ kind: "generated", ...(file !== undefined ? { file } : {}), via, ...(outer.length ? { conditions: outer } : {}) });
|
|
264
|
+
for (const s of m.segments) {
|
|
265
|
+
const room = limit - (out.length - start);
|
|
266
|
+
if (room <= 0)
|
|
267
|
+
break;
|
|
268
|
+
const n = Math.min(s.lines, room);
|
|
269
|
+
if (n <= 0)
|
|
270
|
+
continue;
|
|
271
|
+
const conditions = s.conditions?.length ? [...outer, ...inFile(s.conditions, file)] : outer;
|
|
272
|
+
const under = conditions.length ? { conditions } : {};
|
|
273
|
+
if (s.kind === "copy") {
|
|
274
|
+
for (let k = 0; k < n; k++)
|
|
275
|
+
out.push({ kind: "copy", ...(file !== undefined ? { file } : {}), line: s.from + k, from: s.from + k, to: s.from + k, via, ...under });
|
|
276
|
+
}
|
|
277
|
+
else if (s.kind === "include" && s.map) {
|
|
278
|
+
walk(s.map, s.file, [...via, { ...(file !== undefined ? { file } : {}), from: s.from, to: s.to }], n, conditions);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const o = {
|
|
282
|
+
kind: s.kind,
|
|
283
|
+
...(file !== undefined ? { file } : {}),
|
|
284
|
+
...(s.from !== undefined ? { from: s.from, to: s.to ?? s.from } : {}),
|
|
285
|
+
...(s.kind === "include" ? { included: s.file } : {}),
|
|
286
|
+
via,
|
|
287
|
+
...under,
|
|
288
|
+
};
|
|
289
|
+
for (let k = 0; k < n; k++)
|
|
290
|
+
out.push(o);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// An include whose map is shorter than it: the rest is in that file, with nothing to trace it to
|
|
294
|
+
while (via.length > 0 && out.length - start < limit)
|
|
295
|
+
out.push(at());
|
|
296
|
+
};
|
|
297
|
+
walk(map, undefined, [], textMapLines(map), []);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
const RANK = { copy: 0, include: 1, expr: 2, generated: 3 };
|
|
301
|
+
/**
|
|
302
|
+
* One origin for several lines (a block): in the deepest file they all share, from the first
|
|
303
|
+
* line to the last there. Lines that went further into an include count as that include's tag.
|
|
304
|
+
* The kind is the least editable of the lines': generated, then template output, then an include,
|
|
305
|
+
* then copied text. Undefined for no lines.
|
|
306
|
+
*/
|
|
307
|
+
export function rangeOrigin(origins) {
|
|
308
|
+
if (origins.length === 0)
|
|
309
|
+
return undefined;
|
|
310
|
+
const sameStep = (a, b) => a.file === b.file && a.from === b.from && a.to === b.to;
|
|
311
|
+
// The includes every line came through
|
|
312
|
+
let depth = origins[0].via.length;
|
|
313
|
+
for (const o of origins) {
|
|
314
|
+
let d = 0;
|
|
315
|
+
while (d < depth && d < o.via.length && sameStep(o.via[d], origins[0].via[d]))
|
|
316
|
+
d++;
|
|
317
|
+
depth = d;
|
|
318
|
+
}
|
|
319
|
+
const via = origins[0].via.slice(0, depth);
|
|
320
|
+
let file;
|
|
321
|
+
let kind = "copy";
|
|
322
|
+
let from;
|
|
323
|
+
let to;
|
|
324
|
+
const included = new Set();
|
|
325
|
+
const conditions = [];
|
|
326
|
+
const covered = [];
|
|
327
|
+
for (const o of origins) {
|
|
328
|
+
for (const c of o.conditions ?? [])
|
|
329
|
+
if (!conditions.some((x) => x.file === c.file && sameCondition(x, c)))
|
|
330
|
+
conditions.push(c);
|
|
331
|
+
// At the shared depth: the line itself, or the tag of the include it went into from there
|
|
332
|
+
const deeper = o.via.length > depth ? o.via[depth] : undefined;
|
|
333
|
+
const k = deeper ? "include" : o.kind;
|
|
334
|
+
const f = deeper ? deeper.file : o.file;
|
|
335
|
+
const a = deeper ? deeper.from : o.from;
|
|
336
|
+
const b = deeper ? deeper.to : o.to;
|
|
337
|
+
file = f;
|
|
338
|
+
if (RANK[k] > RANK[kind])
|
|
339
|
+
kind = k;
|
|
340
|
+
if (o.kind === "include" && !deeper && o.included)
|
|
341
|
+
included.add(o.included);
|
|
342
|
+
if (a !== undefined)
|
|
343
|
+
from = from === undefined ? a : Math.min(from, a);
|
|
344
|
+
if (b !== undefined)
|
|
345
|
+
to = to === undefined ? b : Math.max(to, b);
|
|
346
|
+
if (a !== undefined)
|
|
347
|
+
covered.push([a, b ?? a]);
|
|
348
|
+
}
|
|
349
|
+
// Source lines inside the range that none of its lines came from
|
|
350
|
+
let gaps = false;
|
|
351
|
+
if (from !== undefined && to !== undefined) {
|
|
352
|
+
covered.sort((x, y) => x[0] - y[0]);
|
|
353
|
+
let reach = from - 1;
|
|
354
|
+
for (const [a, b] of covered) {
|
|
355
|
+
if (a > reach + 1)
|
|
356
|
+
gaps = true;
|
|
357
|
+
reach = Math.max(reach, b);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
kind,
|
|
362
|
+
...(file !== undefined ? { file } : {}),
|
|
363
|
+
...(kind === "copy" && from === to && from !== undefined ? { line: from } : {}),
|
|
364
|
+
...(from !== undefined ? { from, to: to ?? from } : {}),
|
|
365
|
+
...(kind === "include" && included.size === 1 ? { included: [...included][0] } : {}),
|
|
366
|
+
via,
|
|
367
|
+
...(conditions.length ? { conditions } : {}),
|
|
368
|
+
...(gaps ? { gaps: true } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** Where output lines `from`–`to` came from, as one origin (`rangeOrigin`). */
|
|
372
|
+
export function lookupTextRange(map, from, to) {
|
|
373
|
+
const origins = [];
|
|
374
|
+
for (let line = from; line <= to; line++) {
|
|
375
|
+
const o = lookupTextLine(map, line);
|
|
376
|
+
if (o)
|
|
377
|
+
origins.push(o);
|
|
378
|
+
}
|
|
379
|
+
return rangeOrigin(origins);
|
|
380
|
+
}
|
|
381
|
+
/** The map of an output whose line k came from `sources[k - 1]`: consecutive copies and equal spans become one segment. */
|
|
382
|
+
export function textMapOfLines(sources) {
|
|
383
|
+
const segments = [];
|
|
384
|
+
for (const src of sources) {
|
|
385
|
+
const last = segments[segments.length - 1];
|
|
386
|
+
if (typeof src === "number") {
|
|
387
|
+
if (last?.kind === "copy" && last.from + last.lines === src)
|
|
388
|
+
last.lines++;
|
|
389
|
+
else
|
|
390
|
+
segments.push({ kind: "copy", lines: 1, from: src });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (src.kind === "expr" && src.from !== undefined) {
|
|
394
|
+
const to = src.to ?? src.from;
|
|
395
|
+
if (last?.kind === "expr" && last.from === src.from && last.to === to)
|
|
396
|
+
last.lines++;
|
|
397
|
+
else
|
|
398
|
+
segments.push({ kind: "expr", lines: 1, from: src.from, to });
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const range = src.from !== undefined ? { from: src.from, to: src.to ?? src.from } : {};
|
|
402
|
+
if (last?.kind === "generated" && last.from === range.from && last.to === range.to)
|
|
403
|
+
last.lines++;
|
|
404
|
+
else
|
|
405
|
+
segments.push({ kind: "generated", lines: 1, ...range });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { segments };
|
|
409
|
+
}
|
|
410
|
+
/** Line differences beyond which `diffTextMap` stops diffing and calls the changed middle one span */
|
|
411
|
+
const MAX_DIFF = 2000;
|
|
412
|
+
/**
|
|
413
|
+
* The map of a rewrite nobody recorded (a host's tidy-up pass after templating): lines equal in both
|
|
414
|
+
* are copies, found by a line diff; a changed run stands for the lines it replaced (`expr`), an
|
|
415
|
+
* inserted run for nothing (`generated`). Lines are compared as written.
|
|
416
|
+
*/
|
|
417
|
+
export function diffTextMap(before, after) {
|
|
418
|
+
if (before === after)
|
|
419
|
+
return identityTextMap(before.split("\n").length);
|
|
420
|
+
const a = before.split("\n");
|
|
421
|
+
const b = after.split("\n");
|
|
422
|
+
// Common start and end first: tidy-ups change a few lines of a page
|
|
423
|
+
let start = 0;
|
|
424
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
425
|
+
start++;
|
|
426
|
+
let end = 0;
|
|
427
|
+
while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end])
|
|
428
|
+
end++;
|
|
429
|
+
const sources = [];
|
|
430
|
+
for (let k = 0; k < start; k++)
|
|
431
|
+
sources.push(k + 1);
|
|
432
|
+
const pairs = diffLines(a.slice(start, a.length - end), b.slice(start, b.length - end));
|
|
433
|
+
// pairs: for each line of b's middle, the line of a's middle it equals (0-based), else -1
|
|
434
|
+
if (pairs === undefined) {
|
|
435
|
+
const from = start + 1;
|
|
436
|
+
const to = a.length - end;
|
|
437
|
+
for (let k = start; k < b.length - end; k++)
|
|
438
|
+
sources.push(to >= from ? { kind: "expr", from, to } : { kind: "generated" });
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
// A run of unmatched lines stands for the a-lines between the matches around it
|
|
442
|
+
let prevA = -1;
|
|
443
|
+
for (let k = 0; k < pairs.length;) {
|
|
444
|
+
if (pairs[k] !== -1) {
|
|
445
|
+
prevA = pairs[k];
|
|
446
|
+
sources.push(start + pairs[k] + 1);
|
|
447
|
+
k++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
let j = k;
|
|
451
|
+
while (j < pairs.length && pairs[j] === -1)
|
|
452
|
+
j++;
|
|
453
|
+
const nextA = j < pairs.length ? pairs[j] : a.length - start - end;
|
|
454
|
+
const from = start + prevA + 2;
|
|
455
|
+
const to = start + nextA;
|
|
456
|
+
for (; k < j; k++)
|
|
457
|
+
sources.push(to >= from ? { kind: "expr", from, to } : { kind: "generated" });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (let k = end; k > 0; k--)
|
|
461
|
+
sources.push(a.length - k + 1);
|
|
462
|
+
return textMapOfLines(sources);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* For each line of `b`, the index of the line of `a` it is kept as (a longest common subsequence,
|
|
466
|
+
* Myers' O((N+M)D)), else -1. Undefined when the two differ in more than MAX_DIFF lines.
|
|
467
|
+
*/
|
|
468
|
+
function diffLines(a, b) {
|
|
469
|
+
const n = a.length;
|
|
470
|
+
const m = b.length;
|
|
471
|
+
const max = Math.min(n + m, MAX_DIFF);
|
|
472
|
+
const offset = max + 1;
|
|
473
|
+
const v = new Int32Array(2 * max + 3);
|
|
474
|
+
const trace = [];
|
|
475
|
+
let found = -1;
|
|
476
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
477
|
+
trace.push(v.slice());
|
|
478
|
+
for (let k = -d; k <= d; k += 2) {
|
|
479
|
+
let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
|
|
480
|
+
let y = x - k;
|
|
481
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
482
|
+
x++;
|
|
483
|
+
y++;
|
|
484
|
+
}
|
|
485
|
+
v[offset + k] = x;
|
|
486
|
+
if (x >= n && y >= m) {
|
|
487
|
+
found = d;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (found < 0)
|
|
493
|
+
return undefined;
|
|
494
|
+
// Walk back through the trace, recording the diagonal (kept) steps
|
|
495
|
+
const pairs = new Array(m).fill(-1);
|
|
496
|
+
let x = n;
|
|
497
|
+
let y = m;
|
|
498
|
+
for (let d = found; d > 0; d--) {
|
|
499
|
+
const vd = trace[d];
|
|
500
|
+
const k = x - y;
|
|
501
|
+
const prevK = k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1]) ? k + 1 : k - 1;
|
|
502
|
+
const prevX = vd[offset + prevK];
|
|
503
|
+
const prevY = prevX - prevK;
|
|
504
|
+
while (x > prevX && y > prevY) {
|
|
505
|
+
x--;
|
|
506
|
+
y--;
|
|
507
|
+
pairs[y] = x;
|
|
508
|
+
}
|
|
509
|
+
x = prevX;
|
|
510
|
+
y = prevY;
|
|
511
|
+
}
|
|
512
|
+
while (x > 0 && y > 0) {
|
|
513
|
+
x--;
|
|
514
|
+
y--;
|
|
515
|
+
pairs[y] = x;
|
|
516
|
+
}
|
|
517
|
+
return pairs;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A map whose include segments counted the lines of text a host's loader changed before the
|
|
521
|
+
* renderer read it, put back in the files' own lines: each included file's segments (their nested
|
|
522
|
+
* maps, recursively) move down by `linesBefore(file)`, the lines the loader removed from the top
|
|
523
|
+
* of that file (its front matter, say). `file` is the name the include segment gives. The page's
|
|
524
|
+
* own segments are left as they are.
|
|
525
|
+
*/
|
|
526
|
+
export function offsetIncludeMaps(map, linesBefore) {
|
|
527
|
+
const shift = (m, by) => ({
|
|
528
|
+
segments: m.segments.map((seg) => {
|
|
529
|
+
const moved = { ...seg };
|
|
530
|
+
if (by) {
|
|
531
|
+
if (moved.from !== undefined)
|
|
532
|
+
moved.from += by;
|
|
533
|
+
if ("to" in moved && moved.to !== undefined)
|
|
534
|
+
moved.to += by;
|
|
535
|
+
if (moved.conditions)
|
|
536
|
+
moved.conditions = moved.conditions.map((c) => ({ ...c, from: c.from + by, to: c.to + by, block: { from: c.block.from + by, to: c.block.to + by } }));
|
|
537
|
+
}
|
|
538
|
+
if (moved.kind === "include" && moved.map)
|
|
539
|
+
moved.map = shift(moved.map, linesBefore(moved.file));
|
|
540
|
+
return moved;
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
return shift(map, 0);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* How far a leading YAML front matter block (`---` … `---`) moves the rest of the text down: line 1
|
|
547
|
+
* of the text after it is line `1 + frontMatterLines(text)` of the text. 0 when there is none. What a
|
|
548
|
+
* loader stripping it removed from the top, for `offsetIncludeMaps`.
|
|
549
|
+
*/
|
|
550
|
+
export function frontMatterLines(text) {
|
|
551
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n"))
|
|
552
|
+
return 0;
|
|
553
|
+
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
|
|
554
|
+
if (!close)
|
|
555
|
+
return 0;
|
|
556
|
+
const end = 3 + close.index + close[0].length;
|
|
557
|
+
// the newlines it took: line 1 of what is left is that line of the file
|
|
558
|
+
return text.slice(0, end).split("\n").length - 1;
|
|
559
|
+
}
|