lens-content-processor 0.83.0 → 0.84.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/answer-quotes.d.ts +45 -0
- package/dist/answer-quotes.js +183 -0
- package/dist/answer-quotes.js.map +1 -0
- package/dist/bundler/article.d.ts +44 -6
- package/dist/bundler/article.js +167 -22
- package/dist/bundler/article.js.map +1 -1
- package/dist/cli-steps.js +6 -2
- package/dist/cli-steps.js.map +1 -1
- package/dist/course-graph.d.ts +2 -0
- package/dist/course-graph.js +143 -6
- package/dist/course-graph.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +28 -2
- package/dist/index.js.map +1 -1
- package/dist/parser/article-elements.js.map +1 -1
- package/dist/validator/directives.js +12 -5
- package/dist/validator/directives.js.map +1 -1
- package/dist/validator/same-lens-links.d.ts +5 -1
- package/dist/validator/same-lens-links.js +13 -1
- package/dist/validator/same-lens-links.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ContentError } from "./index.js";
|
|
2
|
+
import { type UuidEntry } from "./validator/uuid.js";
|
|
3
|
+
export type AnswerQuoteForm = "container" | "leaf" | "text";
|
|
4
|
+
export declare const ANSWER_QUOTE_ATTRIBUTES: readonly ["id", "fallback"];
|
|
5
|
+
export interface AnswerQuoteProblem {
|
|
6
|
+
kind: "missing-id" | "invalid-id" | "unknown-attribute" | "malformed";
|
|
7
|
+
/** The attribute name (unknown-attribute), the id (invalid-id) or the attribute text (malformed). */
|
|
8
|
+
detail?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AnswerQuote {
|
|
11
|
+
form: AnswerQuoteForm;
|
|
12
|
+
/** The directive as written, from its colons to its closing brace. */
|
|
13
|
+
raw: string;
|
|
14
|
+
/** Offset of `raw` in the text, and its 1-based line there. */
|
|
15
|
+
offset: number;
|
|
16
|
+
line: number;
|
|
17
|
+
/** The id as written (a well-formed one is a UUID). */
|
|
18
|
+
id?: string;
|
|
19
|
+
fallback?: string;
|
|
20
|
+
problems: AnswerQuoteProblem[];
|
|
21
|
+
}
|
|
22
|
+
/** A well-formed quote's question id (lowercased) and where it is. */
|
|
23
|
+
export interface AnswerQuoteRef {
|
|
24
|
+
id: string;
|
|
25
|
+
file: string;
|
|
26
|
+
line: number;
|
|
27
|
+
}
|
|
28
|
+
/** Every answer quote in `text`, in text order. Code spans, code blocks and math are skipped. */
|
|
29
|
+
export declare function findAnswerQuotes(text: string): AnswerQuote[];
|
|
30
|
+
/**
|
|
31
|
+
* The per-file check of the answer quotes in `texts`, the authored text
|
|
32
|
+
* fields of a file whose content is `source`, and the refs of its
|
|
33
|
+
* well-formed quotes. A quote not found in `source` came from another file
|
|
34
|
+
* (an embedded question file) and is that file's to report.
|
|
35
|
+
*/
|
|
36
|
+
export declare function checkAnswerQuotes(texts: string[], file: string, source: string): {
|
|
37
|
+
errors: ContentError[];
|
|
38
|
+
refs: AnswerQuoteRef[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The cross-file check: every quoted id names a question, and not a survey
|
|
42
|
+
* question (survey answers are stored per survey, apart from question
|
|
43
|
+
* answers, so a quote cannot show them).
|
|
44
|
+
*/
|
|
45
|
+
export declare function answerQuoteErrors(refs: AnswerQuoteRef[], uuidEntries: UuidEntry[]): ContentError[];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/answer-quotes.ts
|
|
2
|
+
//
|
|
3
|
+
// Answer quotes: course text that shows the learner's own earlier answer to a
|
|
4
|
+
// question, named by the question's id:: (question ids are global,
|
|
5
|
+
// validator/uuid.ts). remark-directive syntax, in three forms:
|
|
6
|
+
//
|
|
7
|
+
// ::answer{id="<uuid>"} leaf, a line of its own
|
|
8
|
+
// :::answer{id="<uuid>"} ... ::: container (the body is ignored)
|
|
9
|
+
// ... :answer{id="<uuid>"} ... text, inside a sentence
|
|
10
|
+
//
|
|
11
|
+
// `fallback="text"` is shown while the learner has no answer. A `[label]` is
|
|
12
|
+
// allowed and ignored. Values may be double-quoted, single-quoted or bare.
|
|
13
|
+
//
|
|
14
|
+
// One parser serves every check: the per-file syntax check here
|
|
15
|
+
// (checkAnswerQuotes), the cross-file "does that question exist" check
|
|
16
|
+
// (answerQuoteErrors, run by wholeContentStep in index.ts) and the course-order
|
|
17
|
+
// check (course-graph.ts).
|
|
18
|
+
import { maskCode } from "./same-page-links.js";
|
|
19
|
+
import { sourceLocator } from "./validator/same-lens-links.js";
|
|
20
|
+
import { isValidUuid } from "./validator/uuid.js";
|
|
21
|
+
export const ANSWER_QUOTE_ATTRIBUTES = ["id", "fallback"];
|
|
22
|
+
// `[label]` and `{attributes}` are both optional; the name must end there.
|
|
23
|
+
const TAIL = String.raw `(?:\[[^\]\n]*\])?(?:\{([^}\n]*)\})?(?![\w-])`;
|
|
24
|
+
const BLOCK = new RegExp(String.raw `^ {0,3}(:{2,})answer${TAIL}[ \t]*$`, "gm");
|
|
25
|
+
const TEXT = new RegExp(String.raw `(?<![\w:]):answer${TAIL}`, "g");
|
|
26
|
+
// One attribute: name, name=value, name="value" or name='value'.
|
|
27
|
+
const ATTRIBUTE = /\s*([^\s="'{}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`{}]+)))?\s*/y;
|
|
28
|
+
/** id and fallback of an attribute text, and what is wrong with it. */
|
|
29
|
+
function readAttributes(attr) {
|
|
30
|
+
const out = {};
|
|
31
|
+
const problems = [];
|
|
32
|
+
let at = 0;
|
|
33
|
+
const text = attr ?? "";
|
|
34
|
+
while (at < text.length) {
|
|
35
|
+
ATTRIBUTE.lastIndex = at;
|
|
36
|
+
const m = ATTRIBUTE.exec(text);
|
|
37
|
+
if (!m || m[0] === "") {
|
|
38
|
+
problems.push({ kind: "malformed", detail: text.slice(at).trim() });
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
at = ATTRIBUTE.lastIndex;
|
|
42
|
+
const [, key, ...values] = m;
|
|
43
|
+
const value = values.find((v) => v !== undefined);
|
|
44
|
+
if (key === "id" || key === "fallback")
|
|
45
|
+
out[key] = value ?? "";
|
|
46
|
+
else
|
|
47
|
+
problems.push({ kind: "unknown-attribute", detail: key });
|
|
48
|
+
}
|
|
49
|
+
if (!out.id)
|
|
50
|
+
problems.unshift({ kind: "missing-id" });
|
|
51
|
+
else if (!isValidUuid(out.id))
|
|
52
|
+
problems.unshift({ kind: "invalid-id", detail: out.id });
|
|
53
|
+
return { ...out, problems };
|
|
54
|
+
}
|
|
55
|
+
/** Every answer quote in `text`, in text order. Code spans, code blocks and math are skipped. */
|
|
56
|
+
export function findAnswerQuotes(text) {
|
|
57
|
+
if (!text.includes(":answer"))
|
|
58
|
+
return [];
|
|
59
|
+
const masked = maskCode(text);
|
|
60
|
+
const quotes = [];
|
|
61
|
+
const add = (form, m, attr) => {
|
|
62
|
+
const offset = m.index + m[0].indexOf(":");
|
|
63
|
+
const end = m.index + m[0].trimEnd().length;
|
|
64
|
+
quotes.push({
|
|
65
|
+
form,
|
|
66
|
+
raw: text.slice(offset, end),
|
|
67
|
+
offset,
|
|
68
|
+
line: text.slice(0, offset).split("\n").length,
|
|
69
|
+
...readAttributes(attr),
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
for (const m of masked.matchAll(BLOCK))
|
|
73
|
+
add(m[1].length === 2 ? "leaf" : "container", m, m[2]);
|
|
74
|
+
// The lookbehind keeps `::answer` and `:::answer` out of the text form.
|
|
75
|
+
for (const m of masked.matchAll(TEXT))
|
|
76
|
+
add("text", m, m[1]);
|
|
77
|
+
return quotes.sort((a, b) => a.offset - b.offset);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The per-file check of the answer quotes in `texts`, the authored text
|
|
81
|
+
* fields of a file whose content is `source`, and the refs of its
|
|
82
|
+
* well-formed quotes. A quote not found in `source` came from another file
|
|
83
|
+
* (an embedded question file) and is that file's to report.
|
|
84
|
+
*/
|
|
85
|
+
export function checkAnswerQuotes(texts, file, source) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
const refs = [];
|
|
88
|
+
if (!source.includes(":answer"))
|
|
89
|
+
return { errors, refs };
|
|
90
|
+
const locate = sourceLocator(source);
|
|
91
|
+
for (const text of texts) {
|
|
92
|
+
for (const quote of findAnswerQuotes(text)) {
|
|
93
|
+
if (!source.includes(quote.raw))
|
|
94
|
+
continue;
|
|
95
|
+
const line = locate(quote.raw);
|
|
96
|
+
const at = { file, line };
|
|
97
|
+
for (const problem of quote.problems) {
|
|
98
|
+
errors.push({ ...at, ...problemError(problem, quote.raw) });
|
|
99
|
+
}
|
|
100
|
+
if (quote.id && isValidUuid(quote.id)) {
|
|
101
|
+
refs.push({ ...at, id: quote.id.toLowerCase() });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { errors, refs };
|
|
106
|
+
}
|
|
107
|
+
function problemError(problem, raw) {
|
|
108
|
+
switch (problem.kind) {
|
|
109
|
+
case "missing-id":
|
|
110
|
+
return {
|
|
111
|
+
code: "answer-quote.id-missing",
|
|
112
|
+
severity: "error",
|
|
113
|
+
message: `Answer quote '${raw}' has no id`,
|
|
114
|
+
suggestion: 'Add the id:: of the question whose answer to show: :answer{id="<question id>"}',
|
|
115
|
+
};
|
|
116
|
+
case "invalid-id":
|
|
117
|
+
return {
|
|
118
|
+
code: "answer-quote.id-invalid",
|
|
119
|
+
severity: "error",
|
|
120
|
+
message: `Answer quote id '${problem.detail}' is not a UUID`,
|
|
121
|
+
suggestion: "Copy the id:: of the question whose answer to show",
|
|
122
|
+
};
|
|
123
|
+
case "unknown-attribute":
|
|
124
|
+
return {
|
|
125
|
+
code: "answer-quote.attribute-unknown",
|
|
126
|
+
severity: "warning",
|
|
127
|
+
message: `Unknown attribute '${problem.detail}' on answer quote '${raw}'`,
|
|
128
|
+
suggestion: "An answer quote takes only id and fallback",
|
|
129
|
+
};
|
|
130
|
+
case "malformed":
|
|
131
|
+
return {
|
|
132
|
+
code: "answer-quote.attributes-malformed",
|
|
133
|
+
severity: "warning",
|
|
134
|
+
message: `Cannot read the attributes '${problem.detail}' of answer quote '${raw}'`,
|
|
135
|
+
suggestion: 'Write attributes as name="value": id and fallback',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The cross-file check: every quoted id names a question, and not a survey
|
|
141
|
+
* question (survey answers are stored per survey, apart from question
|
|
142
|
+
* answers, so a quote cannot show them).
|
|
143
|
+
*/
|
|
144
|
+
export function answerQuoteErrors(refs, uuidEntries) {
|
|
145
|
+
if (refs.length === 0)
|
|
146
|
+
return [];
|
|
147
|
+
const questions = new Map();
|
|
148
|
+
for (const entry of uuidEntries) {
|
|
149
|
+
const id = entry.uuid.toLowerCase();
|
|
150
|
+
if (!entry.question || questions.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
questions.set(id, entry);
|
|
153
|
+
}
|
|
154
|
+
return refs.flatMap(({ id, file, line }) => {
|
|
155
|
+
const question = questions.get(id);
|
|
156
|
+
if (!question) {
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
file,
|
|
160
|
+
line,
|
|
161
|
+
code: "answer-quote.question-missing",
|
|
162
|
+
severity: "error",
|
|
163
|
+
message: `Quotes an answer to question ${id}, but no question has that id`,
|
|
164
|
+
suggestion: "Copy the id:: of an existing question",
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
if (question.question.context === "survey") {
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
file,
|
|
172
|
+
line,
|
|
173
|
+
code: "answer-quote.survey-question",
|
|
174
|
+
severity: "error",
|
|
175
|
+
message: `Quotes an answer to question ${id}, a survey question (${question.file}): survey answers cannot be quoted`,
|
|
176
|
+
suggestion: "Quote a question of a lens or a test",
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
return [];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=answer-quotes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answer-quotes.js","sourceRoot":"","sources":["../src/answer-quotes.ts"],"names":[],"mappings":"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"}
|
|
@@ -17,7 +17,7 @@ export interface ExcerptInput {
|
|
|
17
17
|
* A point in the cleaned body where the prose is interrupted: an inline
|
|
18
18
|
* element (`::video[[...]]`, `![[../widgets/...]]`, `:::question` block) or
|
|
19
19
|
* the edge of a lifted callout (a `:::callout` that holds an element; one
|
|
20
|
-
* holding only prose stays markdown and
|
|
20
|
+
* holding only prose stays markdown and is an ArticleFence instead).
|
|
21
21
|
*/
|
|
22
22
|
type ArticleMarkerBody = ({
|
|
23
23
|
kind: "element";
|
|
@@ -30,11 +30,36 @@ type ArticleMarkerBody = ({
|
|
|
30
30
|
export type ArticleMarker = {
|
|
31
31
|
offset: number;
|
|
32
32
|
} & ArticleMarkerBody;
|
|
33
|
+
/**
|
|
34
|
+
* A fence line of a container directive that stays markdown in the body
|
|
35
|
+
* (`:::callout` holding only prose, `:::note`, `:::hide`, ...), at
|
|
36
|
+
* body[offset, end). articleText gives a range of the body that starts or
|
|
37
|
+
* ends inside the container its own opener or closer, so an excerpt never
|
|
38
|
+
* loses the box it starts in and never shows a stray `:::` line.
|
|
39
|
+
*/
|
|
40
|
+
type ArticleFenceBody = {
|
|
41
|
+
kind: "open";
|
|
42
|
+
closer: string;
|
|
43
|
+
/** A box (callout, note) is reopened in a range that starts inside
|
|
44
|
+
* it; a hide/collapse container is not. */
|
|
45
|
+
reopens: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "close";
|
|
48
|
+
opener: number;
|
|
49
|
+
};
|
|
50
|
+
export type ArticleFence = {
|
|
51
|
+
offset: number;
|
|
52
|
+
end: number;
|
|
53
|
+
} & ArticleFenceBody;
|
|
33
54
|
export interface ParsedArticleBody {
|
|
34
|
-
/** Publishable body with element lines and lifted callout fences removed.
|
|
55
|
+
/** Publishable body with element lines and lifted callout fences removed.
|
|
56
|
+
* Render a range of it with articleText. */
|
|
35
57
|
body: string;
|
|
36
58
|
/** Markers in document order, with offsets into the cleaned body. */
|
|
37
59
|
markers: ArticleMarker[];
|
|
60
|
+
/** Fences of balanced containers that stay markdown, in document order;
|
|
61
|
+
* a close fence names its opener's index in this list. */
|
|
62
|
+
fences: ArticleFence[];
|
|
38
63
|
}
|
|
39
64
|
/** A piece of an excerpt that contains markers. */
|
|
40
65
|
export type ArticlePart = {
|
|
@@ -83,16 +108,29 @@ export declare function stripFrontmatter(article: string): string;
|
|
|
83
108
|
* stripped, every valid element removed and recorded as a marker with its
|
|
84
109
|
* character offset into the cleaned body, and every callout that holds an
|
|
85
110
|
* element lifted: its fences are removed too and recorded as open/close
|
|
86
|
-
* markers, so the prose and elements inside carry it as a frame.
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
111
|
+
* markers, so the prose and elements inside carry it as a frame. The fences
|
|
112
|
+
* of every other balanced container stay in the body and are recorded as
|
|
113
|
+
* fences. All downstream work (anchor search, excerpt slicing, collapsed
|
|
114
|
+
* text, word counts) uses this cleaned body, so element lines can never leak
|
|
115
|
+
* into reader-visible text and anchors can never collide with element
|
|
116
|
+
* syntax; the text of a range (excerpt, prose part, collapsed text) comes
|
|
117
|
+
* from articleText, which balances the fences it cuts.
|
|
90
118
|
*
|
|
91
119
|
* Malformed element lines, and elements inside hide/collapse containers, are
|
|
92
120
|
* left in place as inert prose — parse-time validation
|
|
93
121
|
* (parser/article-elements.ts) reports them.
|
|
94
122
|
*/
|
|
95
123
|
export declare function parseArticleBody(article: string, file?: string): ParsedArticleBody;
|
|
124
|
+
/**
|
|
125
|
+
* The markdown of body[start, end), with its container fences balanced: an
|
|
126
|
+
* opener for every box open at `start` (outermost first) and a closer for
|
|
127
|
+
* every container still open at `end`. A `:::hide` (or `:::collapse`) the
|
|
128
|
+
* range starts in is not reopened: the lens author chose that text to show.
|
|
129
|
+
* A closer whose opener is not in the text is left out, and so is an added
|
|
130
|
+
* fence that would make a box holding only whitespace. A fence line the range cuts in
|
|
131
|
+
* two stays plain text.
|
|
132
|
+
*/
|
|
133
|
+
export declare function articleText({ body, fences }: ParsedArticleBody, start: number, end: number): string;
|
|
96
134
|
/**
|
|
97
135
|
* Extract content from an article between two anchor texts.
|
|
98
136
|
*
|
package/dist/bundler/article.js
CHANGED
|
@@ -45,10 +45,13 @@ function publishableArticleBody(article) {
|
|
|
45
45
|
* stripped, every valid element removed and recorded as a marker with its
|
|
46
46
|
* character offset into the cleaned body, and every callout that holds an
|
|
47
47
|
* element lifted: its fences are removed too and recorded as open/close
|
|
48
|
-
* markers, so the prose and elements inside carry it as a frame.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* markers, so the prose and elements inside carry it as a frame. The fences
|
|
49
|
+
* of every other balanced container stay in the body and are recorded as
|
|
50
|
+
* fences. All downstream work (anchor search, excerpt slicing, collapsed
|
|
51
|
+
* text, word counts) uses this cleaned body, so element lines can never leak
|
|
52
|
+
* into reader-visible text and anchors can never collide with element
|
|
53
|
+
* syntax; the text of a range (excerpt, prose part, collapsed text) comes
|
|
54
|
+
* from articleText, which balances the fences it cuts.
|
|
52
55
|
*
|
|
53
56
|
* Malformed element lines, and elements inside hide/collapse containers, are
|
|
54
57
|
* left in place as inert prose — parse-time validation
|
|
@@ -63,8 +66,18 @@ export function parseArticleBody(article, file = "") {
|
|
|
63
66
|
for (const index of callouts ?? [])
|
|
64
67
|
lifted.add(index);
|
|
65
68
|
}
|
|
69
|
+
// Containers that stay markdown: every balanced one that is not lifted.
|
|
70
|
+
// An unbalanced fence stays in the text, where the validator reports it.
|
|
71
|
+
const closed = new Set();
|
|
72
|
+
for (const { marker } of scanned) {
|
|
73
|
+
if (marker?.kind === "close" && marker.opener >= 0)
|
|
74
|
+
closed.add(marker.opener);
|
|
75
|
+
}
|
|
66
76
|
const outLines = [];
|
|
67
77
|
const pending = [];
|
|
78
|
+
const pendingFences = [];
|
|
79
|
+
// Scanned line index of each balanced opener -> its index in pendingFences
|
|
80
|
+
const fenceIndex = new Map();
|
|
68
81
|
const swallowBlankAround = (i, endIndex) => {
|
|
69
82
|
// Removing the line(s) would leave the blank lines around them doubled
|
|
70
83
|
// up; swallow one so the prose keeps a single paragraph break.
|
|
@@ -109,6 +122,24 @@ export function parseArticleBody(article, file = "") {
|
|
|
109
122
|
i++;
|
|
110
123
|
continue;
|
|
111
124
|
}
|
|
125
|
+
if (marker?.kind === "open" && closed.has(i)) {
|
|
126
|
+
fenceIndex.set(i, pendingFences.length);
|
|
127
|
+
pendingFences.push({
|
|
128
|
+
kind: "open",
|
|
129
|
+
closer: ":".repeat(marker.colons),
|
|
130
|
+
reopens: marker.name !== "hide" && marker.name !== "collapse",
|
|
131
|
+
outLineIndex: outLines.length,
|
|
132
|
+
length: line.length,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else if (marker?.kind === "close" && closed.has(marker.opener)) {
|
|
136
|
+
pendingFences.push({
|
|
137
|
+
kind: "close",
|
|
138
|
+
opener: fenceIndex.get(marker.opener),
|
|
139
|
+
outLineIndex: outLines.length,
|
|
140
|
+
length: line.length,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
112
143
|
outLines.push(line);
|
|
113
144
|
}
|
|
114
145
|
// Char offset of each retained line in the joined body
|
|
@@ -122,17 +153,120 @@ export function parseArticleBody(article, file = "") {
|
|
|
122
153
|
// Markers at the very start/end leave blank edges behind — trim and shift
|
|
123
154
|
const leading = joined.length - joined.trimStart().length;
|
|
124
155
|
const body = joined.trim();
|
|
125
|
-
const
|
|
126
|
-
const rawOffset =
|
|
127
|
-
? lineStartOffsets[
|
|
156
|
+
const offsetOf = (outLineIndex, delta = 0) => {
|
|
157
|
+
const rawOffset = outLineIndex < lineStartOffsets.length
|
|
158
|
+
? lineStartOffsets[outLineIndex]
|
|
128
159
|
: joined.length;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
return Math.min(Math.max(rawOffset + delta - leading, 0), body.length);
|
|
161
|
+
};
|
|
162
|
+
const markers = pending.map(({ outLineIndex, ...rest }) => ({
|
|
163
|
+
...rest,
|
|
164
|
+
offset: offsetOf(outLineIndex),
|
|
165
|
+
}));
|
|
166
|
+
const fences = pendingFences.map(({ outLineIndex, length, ...rest }) => ({
|
|
167
|
+
...rest,
|
|
168
|
+
offset: offsetOf(outLineIndex),
|
|
169
|
+
end: offsetOf(outLineIndex, length),
|
|
170
|
+
}));
|
|
171
|
+
return { body, markers, fences };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The markdown of body[start, end), with its container fences balanced: an
|
|
175
|
+
* opener for every box open at `start` (outermost first) and a closer for
|
|
176
|
+
* every container still open at `end`. A `:::hide` (or `:::collapse`) the
|
|
177
|
+
* range starts in is not reopened: the lens author chose that text to show.
|
|
178
|
+
* A closer whose opener is not in the text is left out, and so is an added
|
|
179
|
+
* fence that would make a box holding only whitespace. A fence line the range cuts in
|
|
180
|
+
* two stays plain text.
|
|
181
|
+
*/
|
|
182
|
+
export function articleText({ body, fences }, start, end) {
|
|
183
|
+
const fenceLine = (index) => body.slice(fences[index].offset, fences[index].end);
|
|
184
|
+
const close = (stack, opener) => {
|
|
185
|
+
const at = stack.indexOf(opener);
|
|
186
|
+
if (at === -1)
|
|
187
|
+
return false;
|
|
188
|
+
stack.splice(at, 1);
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
// Containers open where the range starts, outermost first
|
|
192
|
+
const before = [];
|
|
193
|
+
let k = 0;
|
|
194
|
+
for (; k < fences.length && fences[k].offset < start; k++) {
|
|
195
|
+
const fence = fences[k];
|
|
196
|
+
if (fence.end > start)
|
|
197
|
+
continue;
|
|
198
|
+
if (fence.kind === "open")
|
|
199
|
+
before.push(k);
|
|
200
|
+
else
|
|
201
|
+
close(before, fence.opener);
|
|
202
|
+
}
|
|
203
|
+
// Containers whose opener is in the text, innermost last
|
|
204
|
+
const open = before.filter((i) => {
|
|
205
|
+
const fence = fences[i];
|
|
206
|
+
return fence.kind === "open" && fence.reopens;
|
|
134
207
|
});
|
|
135
|
-
|
|
208
|
+
const tokens = open.map((i) => ({
|
|
209
|
+
fence: fenceLine(i).trim(),
|
|
210
|
+
opens: true,
|
|
211
|
+
added: true,
|
|
212
|
+
}));
|
|
213
|
+
const pushClose = (line, added) => {
|
|
214
|
+
// Drop the opener of a box with nothing but whitespace in it.
|
|
215
|
+
let j = tokens.length - 1;
|
|
216
|
+
while (j >= 0) {
|
|
217
|
+
const token = tokens[j];
|
|
218
|
+
if (!("text" in token) || token.text.trim())
|
|
219
|
+
break;
|
|
220
|
+
j--;
|
|
221
|
+
}
|
|
222
|
+
const prev = tokens[j];
|
|
223
|
+
if (prev && "fence" in prev && prev.opens && (prev.added || added))
|
|
224
|
+
tokens.length = j;
|
|
225
|
+
else
|
|
226
|
+
tokens.push({ fence: line, opens: false, added });
|
|
227
|
+
};
|
|
228
|
+
let cursor = start;
|
|
229
|
+
// A reopened box starts right at its text, not at the blank lines the
|
|
230
|
+
// range may begin with.
|
|
231
|
+
if (open.length > 0) {
|
|
232
|
+
const stop = Math.min(end, fences[k]?.offset ?? end);
|
|
233
|
+
while (cursor < stop && /\s/.test(body[cursor]))
|
|
234
|
+
cursor++;
|
|
235
|
+
}
|
|
236
|
+
for (; k < fences.length && fences[k].end <= end; k++) {
|
|
237
|
+
const fence = fences[k];
|
|
238
|
+
tokens.push({ text: body.slice(cursor, fence.offset) });
|
|
239
|
+
// Fences inside the range stay as written
|
|
240
|
+
if (fence.kind === "open") {
|
|
241
|
+
tokens.push({ fence: fenceLine(k), opens: true, added: false });
|
|
242
|
+
open.push(k);
|
|
243
|
+
}
|
|
244
|
+
else if (close(open, fence.opener)) {
|
|
245
|
+
pushClose(fenceLine(k), false);
|
|
246
|
+
}
|
|
247
|
+
cursor = body[fence.end] === "\n" ? fence.end + 1 : fence.end;
|
|
248
|
+
}
|
|
249
|
+
tokens.push({ text: body.slice(Math.min(cursor, end), end) });
|
|
250
|
+
for (const index of [...open].reverse()) {
|
|
251
|
+
const fence = fences[index];
|
|
252
|
+
if (fence.kind === "open")
|
|
253
|
+
pushClose(fence.closer, true);
|
|
254
|
+
}
|
|
255
|
+
let out = "";
|
|
256
|
+
let endsWithFence = false;
|
|
257
|
+
for (const token of tokens) {
|
|
258
|
+
if ("text" in token) {
|
|
259
|
+
out += token.text;
|
|
260
|
+
if (token.text)
|
|
261
|
+
endsWithFence = false;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (out && !out.endsWith("\n"))
|
|
265
|
+
out += "\n";
|
|
266
|
+
out += token.fence + "\n";
|
|
267
|
+
endsWithFence = true;
|
|
268
|
+
}
|
|
269
|
+
return endsWithFence ? out.slice(0, -1) : out;
|
|
136
270
|
}
|
|
137
271
|
/**
|
|
138
272
|
* Extract content from an article between two anchor texts.
|
|
@@ -149,7 +283,14 @@ export function parseArticleBody(article, file = "") {
|
|
|
149
283
|
* @returns Extracted content or error
|
|
150
284
|
*/
|
|
151
285
|
export function extractArticleExcerpt(article, fromAnchor, toAnchor, file) {
|
|
152
|
-
|
|
286
|
+
const parsed = parseArticleBody(article, file);
|
|
287
|
+
const result = extractExcerptFromBody(parsed.body, fromAnchor, toAnchor, file);
|
|
288
|
+
if (result.content === undefined)
|
|
289
|
+
return result;
|
|
290
|
+
return {
|
|
291
|
+
...result,
|
|
292
|
+
content: articleText(parsed, result.startIndex, result.endIndex),
|
|
293
|
+
};
|
|
153
294
|
}
|
|
154
295
|
/** Anchor extraction plus the to::-on-heading warning. */
|
|
155
296
|
function extractExcerptFromBody(body, fromAnchor, toAnchor, file) {
|
|
@@ -322,7 +463,9 @@ function extractAnchored(body, fromAnchor, toAnchor, file) {
|
|
|
322
463
|
* @param file - Source file path for error reporting
|
|
323
464
|
*/
|
|
324
465
|
export function resolveArticleSource(article, excerpts, file) {
|
|
325
|
-
const
|
|
466
|
+
const parsed = parseArticleBody(article, file);
|
|
467
|
+
const { body, markers, fences } = parsed;
|
|
468
|
+
const text = (from, to) => articleText(parsed, from, to);
|
|
326
469
|
const extracted = excerpts.map((excerpt) => extractExcerptFromBody(body, excerpt.from, excerpt.to, file));
|
|
327
470
|
// Whole-article embeds (no anchors anywhere in the group) get no collapsed
|
|
328
471
|
// fields — there is nothing elided to show.
|
|
@@ -337,7 +480,7 @@ export function resolveArticleSource(article, excerpts, file) {
|
|
|
337
480
|
}
|
|
338
481
|
const start = excerpt.startIndex;
|
|
339
482
|
const end = excerpt.endIndex;
|
|
340
|
-
const result = { content:
|
|
483
|
+
const result = { content: text(start, end) };
|
|
341
484
|
if (excerpt.warning)
|
|
342
485
|
result.warning = excerpt.warning;
|
|
343
486
|
if (hasAnchors) {
|
|
@@ -346,7 +489,7 @@ export function resolveArticleSource(article, excerpts, file) {
|
|
|
346
489
|
// its range is unknown.
|
|
347
490
|
if (i === 0) {
|
|
348
491
|
if (start > 0) {
|
|
349
|
-
const collapsedBefore =
|
|
492
|
+
const collapsedBefore = text(0, start).trim();
|
|
350
493
|
if (collapsedBefore.length > 0)
|
|
351
494
|
result.collapsed_before = collapsedBefore;
|
|
352
495
|
}
|
|
@@ -354,16 +497,18 @@ export function resolveArticleSource(article, excerpts, file) {
|
|
|
354
497
|
else {
|
|
355
498
|
const prev = extracted[i - 1];
|
|
356
499
|
if (!prev.error && prev.endIndex <= start) {
|
|
357
|
-
const collapsedBefore =
|
|
500
|
+
const collapsedBefore = text(prev.endIndex, start).trim();
|
|
358
501
|
if (collapsedBefore.length > 0)
|
|
359
502
|
result.collapsed_before = collapsedBefore;
|
|
360
|
-
else if (!markers.some((m) => m.offset >= prev.endIndex && m.offset <= start)
|
|
503
|
+
else if (!markers.some((m) => m.offset >= prev.endIndex && m.offset <= start) &&
|
|
504
|
+
// A fence at `start` opens or closes inside this excerpt
|
|
505
|
+
!fences.some((f) => f.offset >= prev.endIndex && f.offset < start))
|
|
361
506
|
result.continuesPrevious = true;
|
|
362
507
|
}
|
|
363
508
|
}
|
|
364
509
|
// collapsed_after: only the last excerpt shows the article's tail
|
|
365
510
|
if (i === extracted.length - 1) {
|
|
366
|
-
const collapsedAfter = body.
|
|
511
|
+
const collapsedAfter = text(end, body.length).trim();
|
|
367
512
|
if (collapsedAfter.length > 0)
|
|
368
513
|
result.collapsed_after = collapsedAfter;
|
|
369
514
|
}
|
|
@@ -388,7 +533,7 @@ export function resolveArticleSource(article, excerpts, file) {
|
|
|
388
533
|
const framed = (part) => frames.length > 0 ? { ...part, frames: [...frames] } : part;
|
|
389
534
|
let cursor = start;
|
|
390
535
|
for (const m of inRange) {
|
|
391
|
-
const prose =
|
|
536
|
+
const prose = text(cursor, m.offset).trim();
|
|
392
537
|
if (prose.length > 0) {
|
|
393
538
|
parts.push(framed({ kind: "prose", content: prose }));
|
|
394
539
|
}
|
|
@@ -405,7 +550,7 @@ export function resolveArticleSource(article, excerpts, file) {
|
|
|
405
550
|
}
|
|
406
551
|
cursor = m.offset;
|
|
407
552
|
}
|
|
408
|
-
const tail =
|
|
553
|
+
const tail = text(cursor, end).trim();
|
|
409
554
|
if (tail.length > 0)
|
|
410
555
|
parts.push(framed({ kind: "prose", content: tail }));
|
|
411
556
|
// Contiguity: a prose part separated from another prose part only by
|