lens-content-processor 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundler/article.d.ts +37 -30
- package/dist/bundler/article.js +67 -80
- package/dist/bundler/article.js.map +1 -1
- package/dist/content-schema.js +15 -7
- package/dist/content-schema.js.map +1 -1
- package/dist/flattener/index.d.ts +11 -1
- package/dist/flattener/index.js +124 -85
- package/dist/flattener/index.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.js +110 -35
- package/dist/index.js.map +1 -1
- package/dist/parser/article.js +4 -4
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/domain.d.ts +49 -0
- package/dist/parser/domain.js +212 -0
- package/dist/parser/domain.js.map +1 -0
- package/dist/parser/learning-outcome.d.ts +5 -4
- package/dist/parser/learning-outcome.js +31 -21
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +1 -0
- package/dist/parser/lens.js +13 -3
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/response-segments.d.ts +2 -0
- package/dist/parser/response-segments.js +13 -1
- package/dist/parser/response-segments.js.map +1 -1
- package/dist/parser/widget.d.ts +8 -0
- package/dist/parser/widget.js +14 -1
- package/dist/parser/widget.js.map +1 -1
- package/dist/reference-graph.js +4 -2
- package/dist/reference-graph.js.map +1 -1
- package/dist/skill-tree.d.ts +70 -17
- package/dist/skill-tree.js +116 -78
- package/dist/skill-tree.js.map +1 -1
- package/dist/validator/article-imports.d.ts +68 -0
- package/dist/validator/article-imports.js +265 -0
- package/dist/validator/article-imports.js.map +1 -0
- package/dist/validator/article-structure.js +71 -27
- package/dist/validator/article-structure.js.map +1 -1
- package/dist/validator/directives.js +1 -1
- package/dist/validator/directives.js.map +1 -1
- package/package.json +1 -1
- package/dist/validator/video-imports.d.ts +0 -35
- package/dist/validator/video-imports.js +0 -183
- package/dist/validator/video-imports.js.map +0 -1
package/dist/skill-tree.js
CHANGED
|
@@ -1,92 +1,97 @@
|
|
|
1
1
|
// src/skill-tree.ts — assembles the skill-tree structure from parsed content.
|
|
2
2
|
//
|
|
3
|
-
// The processor emits relationships (LO ↔
|
|
4
|
-
// decisions live in the frontend. Cards reference each
|
|
5
|
-
//
|
|
6
|
-
import {
|
|
3
|
+
// The processor emits relationships (LO ↔ topic ↔ domain, LO ↔ module ↔
|
|
4
|
+
// course); presentation decisions live in the frontend. Cards reference each
|
|
5
|
+
// other by LO id, topics by slug, and domains by slug.
|
|
6
|
+
import { DOMAIN_METADATA_FILE, fileStem, } from "./parser/domain.js";
|
|
7
7
|
import { findFileWithExtension } from "./parser/wikilink.js";
|
|
8
|
+
/**
|
|
9
|
+
* Frontmatter tag that lets a course or module show up in the skill tree's
|
|
10
|
+
* "Included in" links. A module inherits it from any course that has it
|
|
11
|
+
* (shown inside that course); a module tagged on its own shows standalone.
|
|
12
|
+
* Untagged modules and courses are left out of `taughtBy` entirely.
|
|
13
|
+
*/
|
|
14
|
+
export const SKILL_TREE_VISIBLE_TAG = "visible-in-skilltree";
|
|
15
|
+
/** Kebab-case: apostrophes vanish ("You don't" → "you-dont"), every other
|
|
16
|
+
* non-alphanumeric run becomes one hyphen. */
|
|
8
17
|
export function slugifyTitle(title) {
|
|
9
18
|
return title
|
|
10
19
|
.toLowerCase()
|
|
20
|
+
.replace(/['’]/g, "")
|
|
11
21
|
.replace(/[^a-z0-9]+/g, "-")
|
|
12
22
|
.replace(/^-+|-+$/g, "");
|
|
13
23
|
}
|
|
14
|
-
function
|
|
15
|
-
const base = path.split("/").pop() ?? path;
|
|
16
|
-
return base.replace(/\.md$/, "");
|
|
17
|
-
}
|
|
18
|
-
/** Parse a Domains/ placeholder file: `domain-number:` frontmatter is the
|
|
19
|
-
* only content that matters; the title is the filename. */
|
|
20
|
-
export function parseDomainFile(content, file) {
|
|
24
|
+
export function buildSkillTree(input) {
|
|
21
25
|
const errors = [];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
message: `Domain file must have an integer 'domain-number' field, got: ${JSON.stringify(rawNumber)}`,
|
|
33
|
-
suggestion: "Add domain-number: <n> to the frontmatter",
|
|
34
|
-
severity: "error",
|
|
35
|
-
});
|
|
36
|
-
return { domain: null, errors };
|
|
37
|
-
}
|
|
38
|
-
const rawStart = frontmatterResult.frontmatter["start-stage"];
|
|
39
|
-
let startStage;
|
|
40
|
-
if (rawStart !== undefined && rawStart !== null) {
|
|
41
|
-
if (typeof rawStart === "string" &&
|
|
42
|
-
["beginner", "intermediate", "advanced"].includes(rawStart)) {
|
|
43
|
-
startStage = rawStart;
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
26
|
+
// --- Domains ---
|
|
27
|
+
const numberToDir = new Map();
|
|
28
|
+
const slugToDir = new Map();
|
|
29
|
+
const dirToDomain = new Map();
|
|
30
|
+
const folders = [...input.domainFolders].sort(([a], [b]) => a.localeCompare(b));
|
|
31
|
+
for (const [dir, folder] of folders) {
|
|
32
|
+
const slug = slugifyTitle(folder.title);
|
|
33
|
+
const sameNumber = numberToDir.get(folder.number);
|
|
34
|
+
const sameSlug = slugToDir.get(slug);
|
|
35
|
+
if (sameNumber || sameSlug) {
|
|
46
36
|
errors.push({
|
|
47
|
-
file,
|
|
48
|
-
line:
|
|
49
|
-
message:
|
|
50
|
-
|
|
37
|
+
file: folder.declaredBy,
|
|
38
|
+
line: 1,
|
|
39
|
+
message: sameNumber
|
|
40
|
+
? `Duplicate domain number ${folder.number}: '${dir}' and '${sameNumber}'`
|
|
41
|
+
: `Duplicate domain '${slug}': '${dir}' and '${sameSlug}' have the same title`,
|
|
42
|
+
suggestion: "Give each domain folder a unique number and title",
|
|
51
43
|
severity: "error",
|
|
52
44
|
});
|
|
45
|
+
continue;
|
|
53
46
|
}
|
|
47
|
+
numberToDir.set(folder.number, dir);
|
|
48
|
+
slugToDir.set(slug, dir);
|
|
49
|
+
dirToDomain.set(dir, {
|
|
50
|
+
slug,
|
|
51
|
+
title: folder.title,
|
|
52
|
+
number: folder.number,
|
|
53
|
+
...(folder.metadata?.startStage
|
|
54
|
+
? { startStage: folder.metadata.startStage }
|
|
55
|
+
: {}),
|
|
56
|
+
...(folder.metadata?.description
|
|
57
|
+
? { description: folder.metadata.description }
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
54
60
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// --- Domains ---
|
|
67
|
-
const numberToPath = new Map();
|
|
68
|
-
const pathToDomain = new Map();
|
|
69
|
-
for (const [path, parsed] of input.domainFiles) {
|
|
70
|
-
const existing = numberToPath.get(parsed.number);
|
|
61
|
+
const domains = [...dirToDomain.values()].sort((a, b) => a.number - b.number);
|
|
62
|
+
// --- Topics ---
|
|
63
|
+
const pathToTopic = new Map();
|
|
64
|
+
const slugToTopicPath = new Map();
|
|
65
|
+
const topicEntries = [...input.topicFiles].sort(([a], [b]) => a.localeCompare(b));
|
|
66
|
+
for (const [path, parsed] of topicEntries) {
|
|
67
|
+
const domain = dirToDomain.get(parsed.domainDir);
|
|
68
|
+
if (!domain)
|
|
69
|
+
continue; // the folder itself was rejected above
|
|
70
|
+
const slug = slugifyTitle(parsed.title);
|
|
71
|
+
const existing = slugToTopicPath.get(slug);
|
|
71
72
|
if (existing) {
|
|
72
73
|
errors.push({
|
|
73
74
|
file: path,
|
|
74
|
-
line:
|
|
75
|
-
message: `Duplicate
|
|
76
|
-
suggestion: "
|
|
75
|
+
line: 1,
|
|
76
|
+
message: `Duplicate topic slug '${slug}': '${path}' and '${existing}' have the same title`,
|
|
77
|
+
suggestion: "Topic titles must be unique across all domains — rename one of the files",
|
|
77
78
|
severity: "error",
|
|
78
79
|
});
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
slugToTopicPath.set(slug, path);
|
|
83
|
+
pathToTopic.set(path, {
|
|
84
|
+
id: parsed.id,
|
|
85
|
+
slug,
|
|
84
86
|
title: parsed.title,
|
|
85
|
-
|
|
86
|
-
...(parsed.
|
|
87
|
+
domain: domain.slug,
|
|
88
|
+
...(parsed.order !== undefined ? { order: parsed.order } : {}),
|
|
89
|
+
...(parsed.description ? { description: parsed.description } : {}),
|
|
87
90
|
});
|
|
88
91
|
}
|
|
89
|
-
const
|
|
92
|
+
const domainNumber = new Map(domains.map((d) => [d.slug, d.number]));
|
|
93
|
+
const topics = [...pathToTopic.values()].sort((a, b) => domainNumber.get(a.domain) - domainNumber.get(b.domain) ||
|
|
94
|
+
compareTopicOrder(a, b));
|
|
90
95
|
// --- Cards ---
|
|
91
96
|
// First pass: which LO paths become cards (needed to validate requires
|
|
92
97
|
// edges — an edge must point at another card, not at an excluded or
|
|
@@ -94,38 +99,56 @@ export function buildSkillTree(input) {
|
|
|
94
99
|
const cardPaths = new Map();
|
|
95
100
|
const untriaged = [];
|
|
96
101
|
for (const [path, lo] of input.learningOutcomes) {
|
|
97
|
-
if (lo.
|
|
102
|
+
if (lo.topics === undefined) {
|
|
98
103
|
// Not yet triaged. Reported via skillTree.untriaged (a TODO list for
|
|
99
104
|
// editors), not as a per-file warning — most LOs were written before
|
|
100
105
|
// the skill tree existed and blanket warnings would drown /validate.
|
|
101
106
|
untriaged.push(path);
|
|
102
107
|
continue;
|
|
103
108
|
}
|
|
104
|
-
if (lo.
|
|
109
|
+
if (lo.topics === "none")
|
|
105
110
|
continue;
|
|
106
111
|
cardPaths.set(path, lo);
|
|
107
112
|
}
|
|
108
113
|
untriaged.sort();
|
|
109
114
|
const cards = [];
|
|
110
115
|
for (const [path, lo] of cardPaths) {
|
|
111
|
-
const
|
|
116
|
+
const topicRefs = lo.topics;
|
|
117
|
+
const topicSlugs = [];
|
|
112
118
|
const domainSlugs = [];
|
|
113
|
-
for (const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
if (!
|
|
119
|
+
for (const ref of topicRefs) {
|
|
120
|
+
const topicPath = findFileWithExtension(ref.resolvedPath, input.files);
|
|
121
|
+
const topic = topicPath ? pathToTopic.get(topicPath) : undefined;
|
|
122
|
+
if (!topic) {
|
|
123
|
+
const reason = topicPath && fileStem(topicPath) === fileStem(DOMAIN_METADATA_FILE)
|
|
124
|
+
? "points at a domain's metadata file, not a topic"
|
|
125
|
+
: topicPath
|
|
126
|
+
? "does not resolve to a topic file inside a numbered domain folder"
|
|
127
|
+
: "does not resolve to a file";
|
|
117
128
|
errors.push({
|
|
118
129
|
file: path,
|
|
119
130
|
line: 2,
|
|
120
|
-
message: `
|
|
121
|
-
suggestion:
|
|
131
|
+
message: `'topic' link ${reason}: ${ref.resolvedPath}`,
|
|
132
|
+
suggestion: 'Point at a topic file: topic: "[[../Domains and Topics/<N> <Domain title>/<Topic title>]]"',
|
|
122
133
|
severity: "error",
|
|
123
134
|
});
|
|
124
135
|
continue;
|
|
125
136
|
}
|
|
126
|
-
|
|
137
|
+
if (topicSlugs.includes(topic.slug)) {
|
|
138
|
+
errors.push({
|
|
139
|
+
file: path,
|
|
140
|
+
line: 2,
|
|
141
|
+
message: `Duplicate 'topic' entry: ${ref.source}`,
|
|
142
|
+
suggestion: "List each topic once",
|
|
143
|
+
severity: "error",
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
topicSlugs.push(topic.slug);
|
|
148
|
+
if (!domainSlugs.includes(topic.domain))
|
|
149
|
+
domainSlugs.push(topic.domain);
|
|
127
150
|
}
|
|
128
|
-
if (
|
|
151
|
+
if (topicSlugs.length === 0)
|
|
129
152
|
continue;
|
|
130
153
|
if (lo.stage === undefined)
|
|
131
154
|
continue; // already reported by the parser
|
|
@@ -149,14 +172,22 @@ export function buildSkillTree(input) {
|
|
|
149
172
|
requires.push(target.id);
|
|
150
173
|
}
|
|
151
174
|
const taughtBy = [];
|
|
152
|
-
for (const modulePath of input.moduleLoRefs.get(path) ?? []) {
|
|
175
|
+
for (const [modulePath, submodules] of input.moduleLoRefs.get(path) ?? []) {
|
|
153
176
|
const info = input.moduleInfo.get(modulePath);
|
|
154
177
|
if (!info)
|
|
155
178
|
continue;
|
|
179
|
+
// moduleCourses already holds only tagged courses.
|
|
180
|
+
const courses = input.moduleCourses.get(info.slug) ?? [];
|
|
181
|
+
if (courses.length === 0 && !info.visible)
|
|
182
|
+
continue;
|
|
156
183
|
taughtBy.push({
|
|
157
184
|
moduleSlug: info.slug,
|
|
158
185
|
moduleTitle: info.title,
|
|
159
|
-
courses
|
|
186
|
+
courses,
|
|
187
|
+
submodules: [...submodules].map(([slug, title]) => ({
|
|
188
|
+
slug: `${info.slug}/${slug}`,
|
|
189
|
+
title,
|
|
190
|
+
})),
|
|
160
191
|
});
|
|
161
192
|
}
|
|
162
193
|
taughtBy.sort((a, b) => a.moduleSlug.localeCompare(b.moduleSlug));
|
|
@@ -164,6 +195,8 @@ export function buildSkillTree(input) {
|
|
|
164
195
|
id: lo.id,
|
|
165
196
|
title: fileStem(path),
|
|
166
197
|
domains: domainSlugs,
|
|
198
|
+
topic: topicSlugs[0],
|
|
199
|
+
topics: topicSlugs,
|
|
167
200
|
stage: lo.stage,
|
|
168
201
|
requires,
|
|
169
202
|
...(lo.outcomeStatement ? { outcome: lo.outcomeStatement } : {}),
|
|
@@ -172,6 +205,11 @@ export function buildSkillTree(input) {
|
|
|
172
205
|
});
|
|
173
206
|
}
|
|
174
207
|
cards.sort((a, b) => a.title.localeCompare(b.title));
|
|
175
|
-
return { skillTree: { domains, cards, untriaged }, errors };
|
|
208
|
+
return { skillTree: { domains, topics, cards, untriaged }, errors };
|
|
209
|
+
}
|
|
210
|
+
/** `order` first (lower wins, absent last), then title. */
|
|
211
|
+
export function compareTopicOrder(a, b) {
|
|
212
|
+
return ((a.order ?? Number.POSITIVE_INFINITY) -
|
|
213
|
+
(b.order ?? Number.POSITIVE_INFINITY) || a.title.localeCompare(b.title));
|
|
176
214
|
}
|
|
177
215
|
//# sourceMappingURL=skill-tree.js.map
|
package/dist/skill-tree.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skill-tree.js","sourceRoot":"","sources":["../src/skill-tree.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,EAAE;AACF,
|
|
1
|
+
{"version":3,"file":"skill-tree.js","sourceRoot":"","sources":["../src/skill-tree.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ContentError } from "../index.js";
|
|
2
|
+
export interface VideoImportSyntax {
|
|
3
|
+
kind: "video";
|
|
4
|
+
/** Wikilink target, e.g. "../video_transcripts/name" */
|
|
5
|
+
target: string;
|
|
6
|
+
from?: string;
|
|
7
|
+
to?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface WidgetEmbedSyntax {
|
|
10
|
+
kind: "widget";
|
|
11
|
+
/** Wikilink target, e.g. "../widgets/name" */
|
|
12
|
+
target: string;
|
|
13
|
+
/** `|height=480px` modifier: overrides the widget file's height here. */
|
|
14
|
+
height?: string;
|
|
15
|
+
}
|
|
16
|
+
export type ArticleImportSyntax = VideoImportSyntax | WidgetEmbedSyntax;
|
|
17
|
+
export type ArticleImportParse = {
|
|
18
|
+
kind: "import";
|
|
19
|
+
import: ArticleImportSyntax;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "invalid";
|
|
22
|
+
kindName: "video" | "widget";
|
|
23
|
+
reason: string;
|
|
24
|
+
suggestion: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "none";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Classify a single line of article body text as a ::video import.
|
|
30
|
+
* - "import": a valid own-line ::video import
|
|
31
|
+
* - "invalid": an attempted import with broken syntax
|
|
32
|
+
* - "none": not a video import at all
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseVideoImportLine(line: string): ArticleImportParse;
|
|
35
|
+
/** True when a wikilink target names a file directly under a widgets/ folder. */
|
|
36
|
+
export declare function isWidgetEmbedTarget(target: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Classify a single line of article body text as a widget embed.
|
|
39
|
+
* - "import": a valid own-line ![[../widgets/name]] embed
|
|
40
|
+
* - "invalid": a widget embed with broken syntax or buried in prose
|
|
41
|
+
* - "none": not a widget embed (other ![[...]] embeds included)
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseWidgetEmbedLine(line: string): ArticleImportParse;
|
|
44
|
+
/** Classify a line as any of the inline article imports. */
|
|
45
|
+
export declare function parseArticleImportLine(line: string): ArticleImportParse;
|
|
46
|
+
export interface ScannedArticleLine {
|
|
47
|
+
line: string;
|
|
48
|
+
/** False inside fenced code and on fence/container marker lines. */
|
|
49
|
+
prose: boolean;
|
|
50
|
+
/** Innermost open `:::hide` / `:::collapse` container, if any. */
|
|
51
|
+
hiddenIn?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Walk an article body line by line, tracking fenced code and container
|
|
55
|
+
* directives, so every consumer of import lines skips the same regions.
|
|
56
|
+
*/
|
|
57
|
+
export declare function scanArticleLines(body: string): ScannedArticleLine[];
|
|
58
|
+
/**
|
|
59
|
+
* Parse-time validation of inline imports in an article body.
|
|
60
|
+
* Resolution errors (missing file, bad range) are reported later by the
|
|
61
|
+
* flattener, which has the file map.
|
|
62
|
+
*/
|
|
63
|
+
export declare function validateArticleImports(body: string, file: string, bodyStartLine: number): ContentError[];
|
|
64
|
+
/**
|
|
65
|
+
* Lens text segments do not resolve inline article imports — catch attempts
|
|
66
|
+
* there with a pointer to the supported mechanisms.
|
|
67
|
+
*/
|
|
68
|
+
export declare function validateNoArticleImportsInLensText(content: string, file: string, line: number): ContentError[];
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { parseTimestamp } from "../bundler/video.js";
|
|
2
|
+
import { isValidHeight } from "../parser/widget.js";
|
|
3
|
+
// ::video{attr}[[path|alias]] or ::video[[path|alias]]{attr}, anchored to a
|
|
4
|
+
// full (trimmed) line. Alias is accepted for wikilink-tooling compatibility
|
|
5
|
+
// but unused — the display title comes from the transcript frontmatter.
|
|
6
|
+
const VIDEO_IMPORT_LINE_RE = /^::video(?:\{([^}]*)\})?\[\[([^\]|]+)(?:\|[^\]]+)?\]\](?:\{([^}]*)\})?$/;
|
|
7
|
+
// Anything that looks like an attempted ::video import (used to flag
|
|
8
|
+
// malformed/misplaced usages without false-positives on prose that merely
|
|
9
|
+
// mentions "::video").
|
|
10
|
+
const VIDEO_IMPORT_CANDIDATE_RE = /::video[[{]/;
|
|
11
|
+
// ![[path]] or ![[path|modifier]], anchored to a full (trimmed) line.
|
|
12
|
+
const WIDGET_EMBED_LINE_RE = /^!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]$/;
|
|
13
|
+
// Every ![[...]] on a line; the target decides whether it is a widget embed.
|
|
14
|
+
const WIDGET_EMBED_CANDIDATE_RE = /!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g;
|
|
15
|
+
// A file directly under a widgets/ folder, written relative to the article
|
|
16
|
+
// (`../widgets/name`) or to the vault root (`widgets/name`), no fragment.
|
|
17
|
+
const WIDGET_TARGET_RE = /^(?:\.\.?\/)*widgets\/[^/#|]+$/;
|
|
18
|
+
// Inline code is not prose: an import mentioned in backticks is neither an
|
|
19
|
+
// import nor a mistake.
|
|
20
|
+
const INLINE_CODE_RE = /`[^`]*`/g;
|
|
21
|
+
// CommonMark permits up to three leading spaces before a fenced code block.
|
|
22
|
+
const CODE_FENCE = /^ {0,3}(?:```|~~~)/;
|
|
23
|
+
const CONTAINER_OPEN = /^(:{3,})([\w-]+)/;
|
|
24
|
+
const CONTAINER_CLOSE = /^(:{3,})\s*(?:\{[^}]*\})?\s*$/;
|
|
25
|
+
/** Parse `from="..." to="..."` attribute text. */
|
|
26
|
+
function parseAttrs(attr) {
|
|
27
|
+
if (attr === undefined || attr.trim() === "")
|
|
28
|
+
return {};
|
|
29
|
+
const tokens = [...attr.matchAll(/([\w-]+)\s*=\s*"([^"]*)"/g)];
|
|
30
|
+
const consumed = tokens.map((m) => m[0]).join(" ");
|
|
31
|
+
if (consumed.replace(/\s+/g, " ").trim() !== attr.replace(/\s+/g, " ").trim()) {
|
|
32
|
+
return {
|
|
33
|
+
reason: `invalid attributes '{${attr}}'`,
|
|
34
|
+
suggestion: 'Use key="value" attributes: from and/or to',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const token of tokens) {
|
|
39
|
+
const key = token[1];
|
|
40
|
+
const value = token[2];
|
|
41
|
+
if (key !== "from" && key !== "to") {
|
|
42
|
+
return {
|
|
43
|
+
reason: `unrecognized attribute '${key}'`,
|
|
44
|
+
suggestion: "Only from and to attributes are supported",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (result[key] !== undefined) {
|
|
48
|
+
return {
|
|
49
|
+
reason: `duplicate attribute '${key}'`,
|
|
50
|
+
suggestion: `Keep only one ${key} attribute`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (parseTimestamp(value) === null) {
|
|
54
|
+
return {
|
|
55
|
+
reason: `invalid timestamp in '${key}="${value}"'`,
|
|
56
|
+
suggestion: "Use M:SS (e.g. 1:30) or H:MM:SS (e.g. 1:30:00)",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
result[key] = value;
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Classify a single line of article body text as a ::video import.
|
|
65
|
+
* - "import": a valid own-line ::video import
|
|
66
|
+
* - "invalid": an attempted import with broken syntax
|
|
67
|
+
* - "none": not a video import at all
|
|
68
|
+
*/
|
|
69
|
+
export function parseVideoImportLine(line) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!VIDEO_IMPORT_CANDIDATE_RE.test(trimmed.replace(INLINE_CODE_RE, ""))) {
|
|
72
|
+
return { kind: "none" };
|
|
73
|
+
}
|
|
74
|
+
const invalid = (reason, suggestion) => ({
|
|
75
|
+
kind: "invalid",
|
|
76
|
+
kindName: "video",
|
|
77
|
+
reason,
|
|
78
|
+
suggestion,
|
|
79
|
+
});
|
|
80
|
+
const match = trimmed.match(VIDEO_IMPORT_LINE_RE);
|
|
81
|
+
if (!match) {
|
|
82
|
+
if (trimmed.startsWith("::video")) {
|
|
83
|
+
return invalid("malformed ::video import", 'Use ::video[[../video_transcripts/name]] with optional {from="1:30" to="4:10"}, alone on its own line');
|
|
84
|
+
}
|
|
85
|
+
// ::video appears mid-line — an import buried in prose.
|
|
86
|
+
return invalid("::video import must be alone on its own line", "Move the ::video[[...]] import to its own line");
|
|
87
|
+
}
|
|
88
|
+
const preAttr = match[1];
|
|
89
|
+
const target = match[2].trim();
|
|
90
|
+
const postAttr = match[3];
|
|
91
|
+
if (preAttr !== undefined && postAttr !== undefined) {
|
|
92
|
+
return invalid("attributes given both before and after the wikilink", "Use a single {from=... to=...} block after the ]]");
|
|
93
|
+
}
|
|
94
|
+
const attrs = parseAttrs(preAttr ?? postAttr);
|
|
95
|
+
if ("reason" in attrs)
|
|
96
|
+
return invalid(attrs.reason, attrs.suggestion);
|
|
97
|
+
if (target === "") {
|
|
98
|
+
return invalid("empty wikilink target", "Reference a transcript: ::video[[../video_transcripts/name]]");
|
|
99
|
+
}
|
|
100
|
+
return { kind: "import", import: { kind: "video", target, ...attrs } };
|
|
101
|
+
}
|
|
102
|
+
/** True when a wikilink target names a file directly under a widgets/ folder. */
|
|
103
|
+
export function isWidgetEmbedTarget(target) {
|
|
104
|
+
return WIDGET_TARGET_RE.test(target.trim());
|
|
105
|
+
}
|
|
106
|
+
/** True when a line mentions a widget embed anywhere outside inline code. */
|
|
107
|
+
function hasWidgetEmbedCandidate(line) {
|
|
108
|
+
return [
|
|
109
|
+
...line.replace(INLINE_CODE_RE, "").matchAll(WIDGET_EMBED_CANDIDATE_RE),
|
|
110
|
+
].some((m) => isWidgetEmbedTarget(m[1]));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Classify a single line of article body text as a widget embed.
|
|
114
|
+
* - "import": a valid own-line ![[../widgets/name]] embed
|
|
115
|
+
* - "invalid": a widget embed with broken syntax or buried in prose
|
|
116
|
+
* - "none": not a widget embed (other ![[...]] embeds included)
|
|
117
|
+
*/
|
|
118
|
+
export function parseWidgetEmbedLine(line) {
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
if (!hasWidgetEmbedCandidate(trimmed))
|
|
121
|
+
return { kind: "none" };
|
|
122
|
+
const invalid = (reason, suggestion) => ({
|
|
123
|
+
kind: "invalid",
|
|
124
|
+
kindName: "widget",
|
|
125
|
+
reason,
|
|
126
|
+
suggestion,
|
|
127
|
+
});
|
|
128
|
+
const match = trimmed.match(WIDGET_EMBED_LINE_RE);
|
|
129
|
+
if (!match || !isWidgetEmbedTarget(match[1])) {
|
|
130
|
+
return invalid("widget embed must be alone on its own line", "Move the ![[../widgets/name]] embed to its own line");
|
|
131
|
+
}
|
|
132
|
+
const target = match[1].trim();
|
|
133
|
+
const modifier = match[2]?.trim();
|
|
134
|
+
if (modifier === undefined || modifier === "") {
|
|
135
|
+
return { kind: "import", import: { kind: "widget", target } };
|
|
136
|
+
}
|
|
137
|
+
const heightMatch = modifier.match(/^height\s*=\s*(\S+)$/);
|
|
138
|
+
if (!heightMatch || !isValidHeight(heightMatch[1])) {
|
|
139
|
+
return invalid(`invalid modifier '|${modifier}'`, 'The only modifier is height, e.g. ![[../widgets/name|height=480px]] ("auto" or a CSS length)');
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
kind: "import",
|
|
143
|
+
import: { kind: "widget", target, height: heightMatch[1] },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Classify a line as any of the inline article imports. */
|
|
147
|
+
export function parseArticleImportLine(line) {
|
|
148
|
+
const video = parseVideoImportLine(line);
|
|
149
|
+
return video.kind === "none" ? parseWidgetEmbedLine(line) : video;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Walk an article body line by line, tracking fenced code and container
|
|
153
|
+
* directives, so every consumer of import lines skips the same regions.
|
|
154
|
+
*/
|
|
155
|
+
export function scanArticleLines(body) {
|
|
156
|
+
const out = [];
|
|
157
|
+
let inCodeBlock = false;
|
|
158
|
+
// Names of open container directives, innermost last.
|
|
159
|
+
const containerStack = [];
|
|
160
|
+
for (const line of body.split("\n")) {
|
|
161
|
+
if (CODE_FENCE.test(line)) {
|
|
162
|
+
inCodeBlock = !inCodeBlock;
|
|
163
|
+
out.push({ line, prose: false });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (inCodeBlock) {
|
|
167
|
+
out.push({ line, prose: false });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const openMatch = line.match(CONTAINER_OPEN);
|
|
171
|
+
if (openMatch) {
|
|
172
|
+
containerStack.push(openMatch[2]);
|
|
173
|
+
out.push({ line, prose: false });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (CONTAINER_CLOSE.test(line)) {
|
|
177
|
+
containerStack.pop();
|
|
178
|
+
out.push({ line, prose: false });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const hiddenIn = containerStack.find((name) => name === "hide" || name === "collapse");
|
|
182
|
+
out.push(hiddenIn ? { line, prose: true, hiddenIn } : { line, prose: true });
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
const IMPORT_LABEL = {
|
|
187
|
+
video: { code: "video-import", label: "::video import" },
|
|
188
|
+
widget: { code: "widget-embed", label: "widget embed" },
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Parse-time validation of inline imports in an article body.
|
|
192
|
+
* Resolution errors (missing file, bad range) are reported later by the
|
|
193
|
+
* flattener, which has the file map.
|
|
194
|
+
*/
|
|
195
|
+
export function validateArticleImports(body, file, bodyStartLine) {
|
|
196
|
+
const errors = [];
|
|
197
|
+
const scanned = scanArticleLines(body);
|
|
198
|
+
for (let i = 0; i < scanned.length; i++) {
|
|
199
|
+
const { line, prose, hiddenIn } = scanned[i];
|
|
200
|
+
if (!prose)
|
|
201
|
+
continue;
|
|
202
|
+
const absLine = bodyStartLine + i;
|
|
203
|
+
const parsed = parseArticleImportLine(line);
|
|
204
|
+
if (parsed.kind === "none")
|
|
205
|
+
continue;
|
|
206
|
+
if (parsed.kind === "invalid") {
|
|
207
|
+
const { code, label } = IMPORT_LABEL[parsed.kindName];
|
|
208
|
+
const notOwnLine = parsed.reason.includes("own line");
|
|
209
|
+
errors.push({
|
|
210
|
+
file,
|
|
211
|
+
line: absLine,
|
|
212
|
+
code: `article.${code}-${notOwnLine ? "not-own-line" : "invalid"}`,
|
|
213
|
+
message: `Invalid ${label}: ${parsed.reason}`,
|
|
214
|
+
suggestion: parsed.suggestion,
|
|
215
|
+
severity: "error",
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Imports inside hide/collapse would silently vanish from rendered output
|
|
220
|
+
// (the collapsed string strips them), so they are rejected outright.
|
|
221
|
+
if (hiddenIn) {
|
|
222
|
+
const { code, label } = IMPORT_LABEL[parsed.import.kind];
|
|
223
|
+
errors.push({
|
|
224
|
+
file,
|
|
225
|
+
line: absLine,
|
|
226
|
+
code: `article.${code}-in-hide`,
|
|
227
|
+
message: `${label} inside a ':::${hiddenIn}' container`,
|
|
228
|
+
suggestion: `Move the import outside the hidden block — hidden text is collapsed and the ${parsed.import.kind} would never render`,
|
|
229
|
+
severity: "error",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return errors;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Lens text segments do not resolve inline article imports — catch attempts
|
|
237
|
+
* there with a pointer to the supported mechanisms.
|
|
238
|
+
*/
|
|
239
|
+
export function validateNoArticleImportsInLensText(content, file, line) {
|
|
240
|
+
const errors = [];
|
|
241
|
+
const lines = content.split("\n");
|
|
242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
243
|
+
const prose = lines[i].replace(INLINE_CODE_RE, "");
|
|
244
|
+
if (VIDEO_IMPORT_CANDIDATE_RE.test(prose)) {
|
|
245
|
+
errors.push({
|
|
246
|
+
file,
|
|
247
|
+
line: line + i,
|
|
248
|
+
message: "::video imports are only supported in article bodies",
|
|
249
|
+
suggestion: "Use a '#### Video' segment in the lens, or move the import into the article file",
|
|
250
|
+
severity: "error",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (hasWidgetEmbedCandidate(prose)) {
|
|
254
|
+
errors.push({
|
|
255
|
+
file,
|
|
256
|
+
line: line + i,
|
|
257
|
+
message: "Widget embeds (![[../widgets/...]]) are only supported in article bodies",
|
|
258
|
+
suggestion: "Use a '#### Widget' segment in the lens, or move the embed into the article file",
|
|
259
|
+
severity: "error",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return errors;
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=article-imports.js.map
|