lens-content-processor 0.1.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 +41 -0
- package/dist/bundler/article.js +240 -0
- package/dist/bundler/article.js.map +1 -0
- package/dist/bundler/article.test.d.ts +1 -0
- package/dist/bundler/article.test.js +212 -0
- package/dist/bundler/article.test.js.map +1 -0
- package/dist/bundler/video.d.ts +50 -0
- package/dist/bundler/video.js +213 -0
- package/dist/bundler/video.js.map +1 -0
- package/dist/bundler/video.test.d.ts +1 -0
- package/dist/bundler/video.test.js +95 -0
- package/dist/bundler/video.test.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +80 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +45 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/flattener/index.d.ts +20 -0
- package/dist/flattener/index.js +707 -0
- package/dist/flattener/index.js.map +1 -0
- package/dist/flattener/index.test.d.ts +1 -0
- package/dist/flattener/index.test.js +547 -0
- package/dist/flattener/index.test.js.map +1 -0
- package/dist/fs/read-vault.d.ts +4 -0
- package/dist/fs/read-vault.js +28 -0
- package/dist/fs/read-vault.js.map +1 -0
- package/dist/fs/read-vault.test.d.ts +1 -0
- package/dist/fs/read-vault.test.js +37 -0
- package/dist/fs/read-vault.test.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/course.d.ts +17 -0
- package/dist/parser/course.js +145 -0
- package/dist/parser/course.js.map +1 -0
- package/dist/parser/course.test.d.ts +1 -0
- package/dist/parser/course.test.js +117 -0
- package/dist/parser/course.test.js.map +1 -0
- package/dist/parser/frontmatter.d.ts +8 -0
- package/dist/parser/frontmatter.js +58 -0
- package/dist/parser/frontmatter.js.map +1 -0
- package/dist/parser/frontmatter.test.d.ts +1 -0
- package/dist/parser/frontmatter.test.js +54 -0
- package/dist/parser/frontmatter.test.js.map +1 -0
- package/dist/parser/learning-outcome.d.ts +21 -0
- package/dist/parser/learning-outcome.js +116 -0
- package/dist/parser/learning-outcome.js.map +1 -0
- package/dist/parser/learning-outcome.test.d.ts +1 -0
- package/dist/parser/learning-outcome.test.js +75 -0
- package/dist/parser/learning-outcome.test.js.map +1 -0
- package/dist/parser/lens.d.ts +51 -0
- package/dist/parser/lens.js +328 -0
- package/dist/parser/lens.js.map +1 -0
- package/dist/parser/lens.test.d.ts +1 -0
- package/dist/parser/lens.test.js +453 -0
- package/dist/parser/lens.test.js.map +1 -0
- package/dist/parser/module.d.ts +21 -0
- package/dist/parser/module.js +128 -0
- package/dist/parser/module.js.map +1 -0
- package/dist/parser/module.test.d.ts +1 -0
- package/dist/parser/module.test.js +99 -0
- package/dist/parser/module.test.js.map +1 -0
- package/dist/parser/sections.d.ts +18 -0
- package/dist/parser/sections.js +128 -0
- package/dist/parser/sections.js.map +1 -0
- package/dist/parser/sections.test.d.ts +1 -0
- package/dist/parser/sections.test.js +297 -0
- package/dist/parser/sections.test.js.map +1 -0
- package/dist/parser/wikilink.d.ts +12 -0
- package/dist/parser/wikilink.js +35 -0
- package/dist/parser/wikilink.js.map +1 -0
- package/dist/parser/wikilink.test.d.ts +1 -0
- package/dist/parser/wikilink.test.js +76 -0
- package/dist/parser/wikilink.test.js.map +1 -0
- package/dist/validator/field-typos.d.ts +10 -0
- package/dist/validator/field-typos.js +96 -0
- package/dist/validator/field-typos.js.map +1 -0
- package/dist/validator/field-typos.test.d.ts +1 -0
- package/dist/validator/field-typos.test.js +41 -0
- package/dist/validator/field-typos.test.js.map +1 -0
- package/dist/validator/field-values.d.ts +11 -0
- package/dist/validator/field-values.js +37 -0
- package/dist/validator/field-values.js.map +1 -0
- package/dist/validator/field-values.test.d.ts +1 -0
- package/dist/validator/field-values.test.js +45 -0
- package/dist/validator/field-values.test.js.map +1 -0
- package/dist/validator/segment-fields.d.ts +12 -0
- package/dist/validator/segment-fields.js +55 -0
- package/dist/validator/segment-fields.js.map +1 -0
- package/dist/validator/segment-fields.test.d.ts +1 -0
- package/dist/validator/segment-fields.test.js +73 -0
- package/dist/validator/segment-fields.test.js.map +1 -0
- package/dist/validator/standalone.test.d.ts +1 -0
- package/dist/validator/standalone.test.js +190 -0
- package/dist/validator/standalone.test.js.map +1 -0
- package/dist/validator/uuid.d.ts +17 -0
- package/dist/validator/uuid.js +46 -0
- package/dist/validator/uuid.js.map +1 -0
- package/dist/validator/uuid.test.d.ts +1 -0
- package/dist/validator/uuid.test.js +229 -0
- package/dist/validator/uuid.test.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/parser/frontmatter.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter';
|
|
4
|
+
describe('parseFrontmatter', () => {
|
|
5
|
+
it('extracts frontmatter fields', () => {
|
|
6
|
+
const content = `---
|
|
7
|
+
slug: my-module
|
|
8
|
+
title: My Module Title
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Content here`;
|
|
12
|
+
const result = parseFrontmatter(content);
|
|
13
|
+
expect(result.frontmatter).toEqual({
|
|
14
|
+
slug: 'my-module',
|
|
15
|
+
title: 'My Module Title',
|
|
16
|
+
});
|
|
17
|
+
expect(result.body).toBe('\n# Content here');
|
|
18
|
+
expect(result.bodyStartLine).toBe(5);
|
|
19
|
+
});
|
|
20
|
+
it('returns error for missing frontmatter', () => {
|
|
21
|
+
const content = '# No frontmatter here';
|
|
22
|
+
const result = parseFrontmatter(content);
|
|
23
|
+
expect(result.error).toBeDefined();
|
|
24
|
+
expect(result.error?.message).toBe('Missing frontmatter');
|
|
25
|
+
expect(result.error?.line).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
it('returns error for unclosed frontmatter', () => {
|
|
28
|
+
const content = `---
|
|
29
|
+
slug: broken
|
|
30
|
+
title: Never Closed`;
|
|
31
|
+
const result = parseFrontmatter(content);
|
|
32
|
+
expect(result.error).toBeDefined();
|
|
33
|
+
expect(result.error?.message).toContain('Unclosed frontmatter');
|
|
34
|
+
});
|
|
35
|
+
it('includes file path in error when provided', () => {
|
|
36
|
+
const content = '# No frontmatter here';
|
|
37
|
+
const result = parseFrontmatter(content, 'modules/test.md');
|
|
38
|
+
expect(result.error).toBeDefined();
|
|
39
|
+
expect(result.error?.file).toBe('modules/test.md');
|
|
40
|
+
});
|
|
41
|
+
it('handles invalid YAML syntax', () => {
|
|
42
|
+
const content = `---
|
|
43
|
+
slug: test
|
|
44
|
+
title: [invalid yaml
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# Content`;
|
|
48
|
+
const result = parseFrontmatter(content);
|
|
49
|
+
expect(result.error).toBeDefined();
|
|
50
|
+
expect(result.error?.message).toContain('Invalid YAML');
|
|
51
|
+
expect(result.error?.line).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=frontmatter.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontmatter.test.js","sourceRoot":"","sources":["../../src/parser/frontmatter.test.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEjD,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,OAAO,GAAG;;;;;eAKL,CAAC;QAEZ,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAEzC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC;YACjC,IAAI,EAAE,WAAW;YACjB,KAAK,EAAE,iBAAiB;SACzB,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;QAC7C,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,OAAO,GAAG,uBAAuB,CAAC;QAExC,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAEzC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC;QACnC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;QAC1D,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QAChD,MAAM,OAAO,GAAG;;oBAEA,CAAC;QAEjB,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAEzC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC;QACnC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,SAAS,CAAC,sBAAsB,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,MAAM,OAAO,GAAG,uBAAuB,CAAC;QAExC,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAC;QAE5D,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC;QACnC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,OAAO,GAAG;;;;;UAKV,CAAC;QAEP,MAAM,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;QAEzC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CAAC;QACnC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;QACxD,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ContentError } from '../index.js';
|
|
2
|
+
export interface ParsedLensRef {
|
|
3
|
+
source: string;
|
|
4
|
+
resolvedPath: string;
|
|
5
|
+
optional: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedTestRef {
|
|
8
|
+
source: string;
|
|
9
|
+
resolvedPath: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ParsedLearningOutcome {
|
|
12
|
+
id: string;
|
|
13
|
+
lenses: ParsedLensRef[];
|
|
14
|
+
test?: ParsedTestRef;
|
|
15
|
+
discussion?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface LearningOutcomeParseResult {
|
|
18
|
+
learningOutcome: ParsedLearningOutcome | null;
|
|
19
|
+
errors: ContentError[];
|
|
20
|
+
}
|
|
21
|
+
export declare function parseLearningOutcome(content: string, file: string): LearningOutcomeParseResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
2
|
+
import { parseSections, LO_SECTION_TYPES } from './sections.js';
|
|
3
|
+
import { parseWikilink, resolveWikilinkPath } from './wikilink.js';
|
|
4
|
+
export function parseLearningOutcome(content, file) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
// Step 1: Parse frontmatter and validate id field
|
|
7
|
+
const frontmatterResult = parseFrontmatter(content, file);
|
|
8
|
+
if (frontmatterResult.error) {
|
|
9
|
+
errors.push(frontmatterResult.error);
|
|
10
|
+
return { learningOutcome: null, errors };
|
|
11
|
+
}
|
|
12
|
+
const { frontmatter, body, bodyStartLine } = frontmatterResult;
|
|
13
|
+
// Validate required id field
|
|
14
|
+
if (!frontmatter.id) {
|
|
15
|
+
errors.push({
|
|
16
|
+
file,
|
|
17
|
+
line: 2,
|
|
18
|
+
message: 'Missing required field: id',
|
|
19
|
+
suggestion: "Add 'id: <uuid>' to frontmatter",
|
|
20
|
+
severity: 'error',
|
|
21
|
+
});
|
|
22
|
+
return { learningOutcome: null, errors };
|
|
23
|
+
}
|
|
24
|
+
// Step 2: Parse sections with H2 level and LO_SECTION_TYPES ('lens', 'test')
|
|
25
|
+
const sectionsResult = parseSections(body, 2, LO_SECTION_TYPES, file);
|
|
26
|
+
// Adjust line numbers to account for frontmatter
|
|
27
|
+
for (const error of sectionsResult.errors) {
|
|
28
|
+
if (error.line) {
|
|
29
|
+
error.line += bodyStartLine - 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
errors.push(...sectionsResult.errors);
|
|
33
|
+
for (const section of sectionsResult.sections) {
|
|
34
|
+
section.line += bodyStartLine - 1;
|
|
35
|
+
}
|
|
36
|
+
// Step 3: Extract lens refs with source field and optional flag
|
|
37
|
+
const lenses = [];
|
|
38
|
+
let testRef;
|
|
39
|
+
for (const section of sectionsResult.sections) {
|
|
40
|
+
if (section.type === 'lens') {
|
|
41
|
+
const source = section.fields.source;
|
|
42
|
+
if (!source) {
|
|
43
|
+
errors.push({
|
|
44
|
+
file,
|
|
45
|
+
line: section.line,
|
|
46
|
+
message: 'Lens section missing source:: field',
|
|
47
|
+
suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' to the lens section",
|
|
48
|
+
severity: 'error',
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Parse wikilink and resolve path
|
|
53
|
+
const wikilink = parseWikilink(source);
|
|
54
|
+
if (!wikilink) {
|
|
55
|
+
errors.push({
|
|
56
|
+
file,
|
|
57
|
+
line: section.line,
|
|
58
|
+
message: `Invalid wikilink format in source:: field: ${source}`,
|
|
59
|
+
suggestion: 'Use format [[../Lenses/filename.md|Display Text]]',
|
|
60
|
+
severity: 'error',
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const resolvedPath = resolveWikilinkPath(wikilink.path, file);
|
|
65
|
+
const optional = section.fields.optional === 'true';
|
|
66
|
+
lenses.push({
|
|
67
|
+
source,
|
|
68
|
+
resolvedPath,
|
|
69
|
+
optional,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (section.type === 'test') {
|
|
73
|
+
const source = section.fields.source;
|
|
74
|
+
// source:: is optional for test sections (tests not fully implemented yet)
|
|
75
|
+
if (!source) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Parse wikilink and resolve path
|
|
79
|
+
const wikilink = parseWikilink(source);
|
|
80
|
+
if (!wikilink) {
|
|
81
|
+
errors.push({
|
|
82
|
+
file,
|
|
83
|
+
line: section.line,
|
|
84
|
+
message: `Invalid wikilink format in source:: field: ${source}`,
|
|
85
|
+
suggestion: 'Use format [[../Tests/filename.md|Display Text]]',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const resolvedPath = resolveWikilinkPath(wikilink.path, file);
|
|
91
|
+
testRef = {
|
|
92
|
+
source,
|
|
93
|
+
resolvedPath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Step 4: Validate at least one lens exists
|
|
98
|
+
if (lenses.length === 0) {
|
|
99
|
+
errors.push({
|
|
100
|
+
file,
|
|
101
|
+
line: bodyStartLine,
|
|
102
|
+
message: 'Learning Outcome must have at least one ## Lens: section',
|
|
103
|
+
suggestion: "Add a '## Lens: <title>' section with a source:: field",
|
|
104
|
+
severity: 'error',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Return result even if there are errors (partial success)
|
|
108
|
+
const learningOutcome = {
|
|
109
|
+
id: frontmatter.id,
|
|
110
|
+
lenses,
|
|
111
|
+
test: testRef,
|
|
112
|
+
discussion: frontmatter.discussion,
|
|
113
|
+
};
|
|
114
|
+
return { learningOutcome, errors };
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=learning-outcome.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"learning-outcome.js","sourceRoot":"","sources":["../../src/parser/learning-outcome.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/parser/learning-outcome.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { parseLearningOutcome } from './learning-outcome.js';
|
|
4
|
+
describe('parseLearningOutcome', () => {
|
|
5
|
+
it('parses LO with multiple lenses', () => {
|
|
6
|
+
const content = `---
|
|
7
|
+
id: 550e8400-e29b-41d4-a716-446655440001
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Lens: First Lens
|
|
11
|
+
source:: [[../Lenses/lens1.md|Lens 1]]
|
|
12
|
+
|
|
13
|
+
## Lens: Second Lens
|
|
14
|
+
source:: [[../Lenses/lens2.md|Lens 2]]
|
|
15
|
+
optional:: true
|
|
16
|
+
|
|
17
|
+
## Test: Knowledge Check
|
|
18
|
+
source:: [[../Tests/test1.md|Test]]
|
|
19
|
+
`;
|
|
20
|
+
const result = parseLearningOutcome(content, 'Learning Outcomes/lo1.md');
|
|
21
|
+
expect(result.learningOutcome?.id).toBe('550e8400-e29b-41d4-a716-446655440001');
|
|
22
|
+
expect(result.learningOutcome?.lenses).toHaveLength(2);
|
|
23
|
+
expect(result.learningOutcome?.lenses[0].source).toBe('[[../Lenses/lens1.md|Lens 1]]');
|
|
24
|
+
expect(result.learningOutcome?.lenses[0].resolvedPath).toBe('Lenses/lens1.md');
|
|
25
|
+
expect(result.learningOutcome?.lenses[0].optional).toBe(false);
|
|
26
|
+
expect(result.learningOutcome?.lenses[1].source).toBe('[[../Lenses/lens2.md|Lens 2]]');
|
|
27
|
+
expect(result.learningOutcome?.lenses[1].resolvedPath).toBe('Lenses/lens2.md');
|
|
28
|
+
expect(result.learningOutcome?.lenses[1].optional).toBe(true);
|
|
29
|
+
expect(result.learningOutcome?.test?.source).toContain('test1.md');
|
|
30
|
+
expect(result.learningOutcome?.test?.resolvedPath).toBe('Tests/test1.md');
|
|
31
|
+
expect(result.errors).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
it('requires id in frontmatter', () => {
|
|
34
|
+
const content = `---
|
|
35
|
+
title: Missing ID
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Lens: Test
|
|
39
|
+
source:: [[../Lenses/lens1.md|Lens]]
|
|
40
|
+
`;
|
|
41
|
+
const result = parseLearningOutcome(content, 'Learning Outcomes/bad.md');
|
|
42
|
+
expect(result.errors).toHaveLength(1);
|
|
43
|
+
expect(result.errors[0].message).toContain('id');
|
|
44
|
+
});
|
|
45
|
+
it('requires at least one lens', () => {
|
|
46
|
+
const content = `---
|
|
47
|
+
id: 550e8400-e29b-41d4-a716-446655440001
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
No lenses here.
|
|
51
|
+
`;
|
|
52
|
+
const result = parseLearningOutcome(content, 'Learning Outcomes/empty.md');
|
|
53
|
+
expect(result.errors.some(e => e.message.includes('Lens'))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('allows test section without source:: field (tests not implemented yet)', () => {
|
|
56
|
+
const content = `---
|
|
57
|
+
id: 550e8400-e29b-41d4-a716-446655440001
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Lens: Main Content
|
|
61
|
+
source:: [[../Lenses/lens1.md|Lens 1]]
|
|
62
|
+
|
|
63
|
+
## Test: Knowledge Check
|
|
64
|
+
`;
|
|
65
|
+
const result = parseLearningOutcome(content, 'Learning Outcomes/lo1.md');
|
|
66
|
+
// Should NOT produce an error for missing source:: in test section
|
|
67
|
+
expect(result.errors).toHaveLength(0);
|
|
68
|
+
// The LO should be parsed successfully
|
|
69
|
+
expect(result.learningOutcome?.id).toBe('550e8400-e29b-41d4-a716-446655440001');
|
|
70
|
+
expect(result.learningOutcome?.lenses).toHaveLength(1);
|
|
71
|
+
// Test should be undefined since no source was provided
|
|
72
|
+
expect(result.learningOutcome?.test).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
//# sourceMappingURL=learning-outcome.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"learning-outcome.test.js","sourceRoot":"","sources":["../../src/parser/learning-outcome.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ContentError } from '../index.js';
|
|
2
|
+
export interface ParsedTextSegment {
|
|
3
|
+
type: 'text';
|
|
4
|
+
content: string;
|
|
5
|
+
optional?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedChatSegment {
|
|
8
|
+
type: 'chat';
|
|
9
|
+
title?: string;
|
|
10
|
+
instructions?: string;
|
|
11
|
+
hidePreviousContentFromUser?: boolean;
|
|
12
|
+
hidePreviousContentFromTutor?: boolean;
|
|
13
|
+
optional?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface ParsedArticleExcerptSegment {
|
|
16
|
+
type: 'article-excerpt';
|
|
17
|
+
fromAnchor?: string;
|
|
18
|
+
toAnchor?: string;
|
|
19
|
+
optional?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ParsedVideoExcerptSegment {
|
|
22
|
+
type: 'video-excerpt';
|
|
23
|
+
fromTimeStr: string;
|
|
24
|
+
toTimeStr: string;
|
|
25
|
+
optional?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export type ParsedLensSegment = ParsedTextSegment | ParsedChatSegment | ParsedArticleExcerptSegment | ParsedVideoExcerptSegment;
|
|
28
|
+
export interface ParsedLensSection {
|
|
29
|
+
type: string;
|
|
30
|
+
title: string;
|
|
31
|
+
source?: string;
|
|
32
|
+
resolvedPath?: string;
|
|
33
|
+
segments: ParsedLensSegment[];
|
|
34
|
+
line: number;
|
|
35
|
+
}
|
|
36
|
+
export interface ParsedLens {
|
|
37
|
+
id: string;
|
|
38
|
+
sections: ParsedLensSection[];
|
|
39
|
+
}
|
|
40
|
+
export interface LensParseResult {
|
|
41
|
+
lens: ParsedLens | null;
|
|
42
|
+
errors: ContentError[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse a lens file into structured lens data.
|
|
46
|
+
*
|
|
47
|
+
* Lens files use:
|
|
48
|
+
* - H3 (`###`) for sections: Text, Article, Video
|
|
49
|
+
* - H4 (`####`) for segments: Text, Chat, Article-excerpt, Video-excerpt
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseLens(content: string, file: string): LensParseResult;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
2
|
+
import { parseSections, LENS_SECTION_TYPES, LENS_OUTPUT_TYPE } from './sections.js';
|
|
3
|
+
import { validateSegmentFields } from '../validator/segment-fields.js';
|
|
4
|
+
import { validateFieldValues } from '../validator/field-values.js';
|
|
5
|
+
// Valid segment types for lens H4 headers
|
|
6
|
+
const LENS_SEGMENT_TYPES = new Set(['text', 'chat', 'article-excerpt', 'video-excerpt']);
|
|
7
|
+
// H4 segment header pattern: #### <type> or #### <type>: <title>
|
|
8
|
+
const SEGMENT_HEADER_PATTERN = /^####\s+(\S+)(?::\s*(.*))?$/i;
|
|
9
|
+
// Field pattern: fieldname:: value
|
|
10
|
+
const FIELD_PATTERN = /^(\w+)::\s*(.*)$/;
|
|
11
|
+
/**
|
|
12
|
+
* Parse H4 segments from a section body.
|
|
13
|
+
* Segments are defined by `#### <type>` headers within a section.
|
|
14
|
+
*/
|
|
15
|
+
function parseSegments(sectionBody, bodyStartLine, file) {
|
|
16
|
+
const lines = sectionBody.split('\n');
|
|
17
|
+
const segments = [];
|
|
18
|
+
const errors = [];
|
|
19
|
+
let currentSegment = null;
|
|
20
|
+
let currentFieldLines = [];
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
const lineNum = bodyStartLine + i;
|
|
24
|
+
const headerMatch = line.match(SEGMENT_HEADER_PATTERN);
|
|
25
|
+
if (headerMatch) {
|
|
26
|
+
// Save previous segment
|
|
27
|
+
if (currentSegment) {
|
|
28
|
+
parseFieldsIntoSegment(currentSegment, currentFieldLines);
|
|
29
|
+
segments.push(currentSegment);
|
|
30
|
+
}
|
|
31
|
+
const rawType = headerMatch[1].trim();
|
|
32
|
+
const normalizedType = rawType.toLowerCase();
|
|
33
|
+
const title = headerMatch[2]?.trim() || undefined;
|
|
34
|
+
if (!LENS_SEGMENT_TYPES.has(normalizedType)) {
|
|
35
|
+
errors.push({
|
|
36
|
+
file,
|
|
37
|
+
line: lineNum,
|
|
38
|
+
message: `Unknown segment type: ${rawType}`,
|
|
39
|
+
suggestion: `Valid types: ${[...LENS_SEGMENT_TYPES].join(', ')}`,
|
|
40
|
+
severity: 'error',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
currentSegment = {
|
|
44
|
+
type: normalizedType,
|
|
45
|
+
title,
|
|
46
|
+
fields: {},
|
|
47
|
+
line: lineNum,
|
|
48
|
+
};
|
|
49
|
+
currentFieldLines = [];
|
|
50
|
+
}
|
|
51
|
+
else if (currentSegment) {
|
|
52
|
+
currentFieldLines.push(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Don't forget last segment
|
|
56
|
+
if (currentSegment) {
|
|
57
|
+
parseFieldsIntoSegment(currentSegment, currentFieldLines);
|
|
58
|
+
segments.push(currentSegment);
|
|
59
|
+
}
|
|
60
|
+
return { segments, errors };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse fields from lines into a segment, handling multiline values.
|
|
64
|
+
* A field continues until the next field or the end of the lines.
|
|
65
|
+
*/
|
|
66
|
+
function parseFieldsIntoSegment(segment, lines) {
|
|
67
|
+
let currentField = null;
|
|
68
|
+
let currentValue = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const match = line.match(FIELD_PATTERN);
|
|
71
|
+
if (match) {
|
|
72
|
+
// Save previous field if any
|
|
73
|
+
if (currentField) {
|
|
74
|
+
segment.fields[currentField] = currentValue.join('\n').trim();
|
|
75
|
+
}
|
|
76
|
+
currentField = match[1];
|
|
77
|
+
const inlineValue = match[2].trim();
|
|
78
|
+
currentValue = inlineValue ? [inlineValue] : [];
|
|
79
|
+
}
|
|
80
|
+
else if (currentField) {
|
|
81
|
+
// Continue multiline value
|
|
82
|
+
currentValue.push(line);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Save final field
|
|
86
|
+
if (currentField) {
|
|
87
|
+
segment.fields[currentField] = currentValue.join('\n').trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Convert a raw segment to a typed ParsedLensSegment.
|
|
92
|
+
*/
|
|
93
|
+
function convertSegment(raw, sectionType, file) {
|
|
94
|
+
const errors = [];
|
|
95
|
+
switch (raw.type) {
|
|
96
|
+
case 'text': {
|
|
97
|
+
const hasContentField = 'content' in raw.fields;
|
|
98
|
+
const content = raw.fields.content;
|
|
99
|
+
if (!hasContentField) {
|
|
100
|
+
// Field completely missing - error
|
|
101
|
+
errors.push({
|
|
102
|
+
file,
|
|
103
|
+
line: raw.line,
|
|
104
|
+
message: 'Text segment missing content:: field',
|
|
105
|
+
suggestion: "Add 'content:: Your text here' to the text segment",
|
|
106
|
+
severity: 'error',
|
|
107
|
+
});
|
|
108
|
+
return { segment: null, errors };
|
|
109
|
+
}
|
|
110
|
+
if (!content || content.trim() === '') {
|
|
111
|
+
// Field present but empty - warning
|
|
112
|
+
errors.push({
|
|
113
|
+
file,
|
|
114
|
+
line: raw.line,
|
|
115
|
+
message: 'Text segment has empty content:: field',
|
|
116
|
+
suggestion: 'Add text content after content::',
|
|
117
|
+
severity: 'warning',
|
|
118
|
+
});
|
|
119
|
+
// Still create the segment, just with empty content
|
|
120
|
+
const segment = {
|
|
121
|
+
type: 'text',
|
|
122
|
+
content: '',
|
|
123
|
+
optional: raw.fields.optional === 'true' ? true : undefined,
|
|
124
|
+
};
|
|
125
|
+
return { segment, errors };
|
|
126
|
+
}
|
|
127
|
+
const segment = {
|
|
128
|
+
type: 'text',
|
|
129
|
+
content,
|
|
130
|
+
optional: raw.fields.optional === 'true' ? true : undefined,
|
|
131
|
+
};
|
|
132
|
+
return { segment, errors };
|
|
133
|
+
}
|
|
134
|
+
case 'chat': {
|
|
135
|
+
const segment = {
|
|
136
|
+
type: 'chat',
|
|
137
|
+
title: raw.title,
|
|
138
|
+
instructions: raw.fields.instructions,
|
|
139
|
+
hidePreviousContentFromUser: raw.fields.hidePreviousContentFromUser === 'true' ? true : undefined,
|
|
140
|
+
hidePreviousContentFromTutor: raw.fields.hidePreviousContentFromTutor === 'true' ? true : undefined,
|
|
141
|
+
optional: raw.fields.optional === 'true' ? true : undefined,
|
|
142
|
+
};
|
|
143
|
+
return { segment, errors };
|
|
144
|
+
}
|
|
145
|
+
case 'article-excerpt': {
|
|
146
|
+
const fromField = raw.fields.from;
|
|
147
|
+
const toField = raw.fields.to;
|
|
148
|
+
// Both from:: and to:: are optional for article-excerpt:
|
|
149
|
+
// - Only from:: → extract from anchor to end of article
|
|
150
|
+
// - Only to:: → extract from start to anchor
|
|
151
|
+
// - Neither → extract entire article
|
|
152
|
+
// Strip quotes from anchor text if present
|
|
153
|
+
const fromAnchor = fromField ? stripQuotes(fromField) : undefined;
|
|
154
|
+
const toAnchor = toField ? stripQuotes(toField) : undefined;
|
|
155
|
+
const segment = {
|
|
156
|
+
type: 'article-excerpt',
|
|
157
|
+
fromAnchor,
|
|
158
|
+
toAnchor,
|
|
159
|
+
optional: raw.fields.optional === 'true' ? true : undefined,
|
|
160
|
+
};
|
|
161
|
+
return { segment, errors };
|
|
162
|
+
}
|
|
163
|
+
case 'video-excerpt': {
|
|
164
|
+
const fromField = raw.fields.from;
|
|
165
|
+
const toField = raw.fields.to;
|
|
166
|
+
// to:: is required, from:: defaults to "0:00"
|
|
167
|
+
if (!toField) {
|
|
168
|
+
errors.push({
|
|
169
|
+
file,
|
|
170
|
+
line: raw.line,
|
|
171
|
+
message: 'Video-excerpt segment missing to:: field',
|
|
172
|
+
suggestion: "Add 'to:: M:SS' or 'to:: H:MM:SS' to the segment",
|
|
173
|
+
severity: 'error',
|
|
174
|
+
});
|
|
175
|
+
return { segment: null, errors };
|
|
176
|
+
}
|
|
177
|
+
const segment = {
|
|
178
|
+
type: 'video-excerpt',
|
|
179
|
+
fromTimeStr: fromField || '0:00', // Default to start of video
|
|
180
|
+
toTimeStr: toField,
|
|
181
|
+
optional: raw.fields.optional === 'true' ? true : undefined,
|
|
182
|
+
};
|
|
183
|
+
return { segment, errors };
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
// Unknown segment type - error already reported during parseSegments
|
|
187
|
+
return { segment: null, errors };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Strip surrounding quotes from a string if present.
|
|
192
|
+
*/
|
|
193
|
+
function stripQuotes(s) {
|
|
194
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
195
|
+
return s.slice(1, -1);
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Check if a segment is empty (has no meaningful fields).
|
|
201
|
+
* Returns a warning if the segment is empty.
|
|
202
|
+
*
|
|
203
|
+
* Note: article-excerpt with no fields is valid (means entire article).
|
|
204
|
+
*/
|
|
205
|
+
function checkEmptySegment(raw, file) {
|
|
206
|
+
// A segment is empty if it has no fields at all
|
|
207
|
+
const fieldCount = Object.keys(raw.fields).length;
|
|
208
|
+
// article-excerpt with no fields is valid - means "include entire article"
|
|
209
|
+
if (raw.type === 'article-excerpt' && fieldCount === 0) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (fieldCount === 0) {
|
|
213
|
+
return {
|
|
214
|
+
file,
|
|
215
|
+
line: raw.line,
|
|
216
|
+
message: `Empty ${raw.type} segment has no fields`,
|
|
217
|
+
suggestion: `Add required fields to the ${raw.type} segment`,
|
|
218
|
+
severity: 'warning',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Parse a lens file into structured lens data.
|
|
225
|
+
*
|
|
226
|
+
* Lens files use:
|
|
227
|
+
* - H3 (`###`) for sections: Text, Article, Video
|
|
228
|
+
* - H4 (`####`) for segments: Text, Chat, Article-excerpt, Video-excerpt
|
|
229
|
+
*/
|
|
230
|
+
export function parseLens(content, file) {
|
|
231
|
+
const errors = [];
|
|
232
|
+
// Step 1: Parse frontmatter and validate id field
|
|
233
|
+
const frontmatterResult = parseFrontmatter(content, file);
|
|
234
|
+
if (frontmatterResult.error) {
|
|
235
|
+
errors.push(frontmatterResult.error);
|
|
236
|
+
return { lens: null, errors };
|
|
237
|
+
}
|
|
238
|
+
const { frontmatter, body, bodyStartLine } = frontmatterResult;
|
|
239
|
+
// Validate required id field
|
|
240
|
+
if (!frontmatter.id) {
|
|
241
|
+
errors.push({
|
|
242
|
+
file,
|
|
243
|
+
line: 2,
|
|
244
|
+
message: 'Missing required field: id',
|
|
245
|
+
suggestion: "Add 'id: <uuid>' to frontmatter",
|
|
246
|
+
severity: 'error',
|
|
247
|
+
});
|
|
248
|
+
return { lens: null, errors };
|
|
249
|
+
}
|
|
250
|
+
// Step 2: Parse H3 sections (Text, Article, Video)
|
|
251
|
+
const sectionsResult = parseSections(body, 3, LENS_SECTION_TYPES, file);
|
|
252
|
+
// Adjust line numbers to account for frontmatter
|
|
253
|
+
for (const error of sectionsResult.errors) {
|
|
254
|
+
if (error.line) {
|
|
255
|
+
error.line += bodyStartLine - 1;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
errors.push(...sectionsResult.errors);
|
|
259
|
+
for (const section of sectionsResult.sections) {
|
|
260
|
+
section.line += bodyStartLine - 1;
|
|
261
|
+
}
|
|
262
|
+
// Step 3: Convert raw sections to ParsedLensSections with segments
|
|
263
|
+
const parsedSections = [];
|
|
264
|
+
for (const rawSection of sectionsResult.sections) {
|
|
265
|
+
// Map section type: 'article' -> 'lens-article', 'video' -> 'lens-video'
|
|
266
|
+
const outputType = LENS_OUTPUT_TYPE[rawSection.type] ?? rawSection.type;
|
|
267
|
+
// For article/video sections, source field is required
|
|
268
|
+
const needsSource = outputType === 'lens-article' || outputType === 'lens-video';
|
|
269
|
+
const source = rawSection.fields.source;
|
|
270
|
+
if (needsSource && !source) {
|
|
271
|
+
errors.push({
|
|
272
|
+
file,
|
|
273
|
+
line: rawSection.line,
|
|
274
|
+
message: `${rawSection.rawType} section missing source:: field`,
|
|
275
|
+
suggestion: `Add 'source:: [[../path/to/file.md|Display]]' to the ${rawSection.rawType.toLowerCase()} section`,
|
|
276
|
+
severity: 'error',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
// Parse H4 segments within this section
|
|
280
|
+
const { segments: rawSegments, errors: segmentErrors } = parseSegments(rawSection.body, rawSection.line + 1, // Segments start after the section header
|
|
281
|
+
file);
|
|
282
|
+
errors.push(...segmentErrors);
|
|
283
|
+
// Convert raw segments to typed segments
|
|
284
|
+
const segments = [];
|
|
285
|
+
for (const rawSeg of rawSegments) {
|
|
286
|
+
// Check for empty segments
|
|
287
|
+
const emptyWarning = checkEmptySegment(rawSeg, file);
|
|
288
|
+
if (emptyWarning) {
|
|
289
|
+
errors.push(emptyWarning);
|
|
290
|
+
}
|
|
291
|
+
// Validate that fields are appropriate for this segment type
|
|
292
|
+
const fieldWarnings = validateSegmentFields(rawSeg.type, rawSeg.fields, file, rawSeg.line);
|
|
293
|
+
errors.push(...fieldWarnings);
|
|
294
|
+
// Validate field values (e.g., boolean fields should have 'true' or 'false')
|
|
295
|
+
const valueWarnings = validateFieldValues(rawSeg.fields, file, rawSeg.line);
|
|
296
|
+
errors.push(...valueWarnings);
|
|
297
|
+
const { segment, errors: conversionErrors } = convertSegment(rawSeg, outputType, file);
|
|
298
|
+
errors.push(...conversionErrors);
|
|
299
|
+
if (segment) {
|
|
300
|
+
segments.push(segment);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Warn if section has no segments
|
|
304
|
+
if (segments.length === 0) {
|
|
305
|
+
errors.push({
|
|
306
|
+
file,
|
|
307
|
+
line: rawSection.line,
|
|
308
|
+
message: `${rawSection.rawType} section has no segments`,
|
|
309
|
+
suggestion: `Add at least one segment (#### Text, #### Chat, etc.) to the ${rawSection.rawType.toLowerCase()} section`,
|
|
310
|
+
severity: 'warning',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const parsedSection = {
|
|
314
|
+
type: outputType,
|
|
315
|
+
title: rawSection.title,
|
|
316
|
+
source: source,
|
|
317
|
+
segments,
|
|
318
|
+
line: rawSection.line,
|
|
319
|
+
};
|
|
320
|
+
parsedSections.push(parsedSection);
|
|
321
|
+
}
|
|
322
|
+
const lens = {
|
|
323
|
+
id: frontmatter.id,
|
|
324
|
+
sections: parsedSections,
|
|
325
|
+
};
|
|
326
|
+
return { lens, errors };
|
|
327
|
+
}
|
|
328
|
+
//# sourceMappingURL=lens.js.map
|