handzon-core 0.16.0 → 0.16.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/collections.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
20
20
|
import type { Loader } from "astro/loaders";
|
|
21
21
|
import { glob } from "astro/loaders";
|
|
22
22
|
import { createHeroMediaSchema } from "./lib/heroMedia";
|
|
23
|
+
import { collectProgressItemIds, validateProgressItemIds } from "./lib/progress/progressItems";
|
|
23
24
|
import { createTutorialIconSchema } from "./lib/tutorialIcon";
|
|
24
25
|
|
|
25
26
|
const TUTORIALS_REL = "src/content/tutorials";
|
|
@@ -219,7 +220,6 @@ export function stepsLoader(): Loader {
|
|
|
219
220
|
name: "handzon-steps",
|
|
220
221
|
load: async (args) => {
|
|
221
222
|
await inner.load(args);
|
|
222
|
-
const checkpointRe = /<Checkpoint\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
|
|
223
223
|
const trackIdCache = new Map<string, Promise<string[]>>();
|
|
224
224
|
for (const value of args.store.values()) {
|
|
225
225
|
const { tutorialSlug } = parseStepCollectionId(value.id);
|
|
@@ -229,17 +229,11 @@ export function stepsLoader(): Loader {
|
|
|
229
229
|
const trackIds = await trackIdsPromise;
|
|
230
230
|
const body = value.body ?? "";
|
|
231
231
|
validateTrackIdsInBody({ body, entryId: value.id, trackIds });
|
|
232
|
+
validateProgressItemIds({ body, entryId: value.id });
|
|
232
233
|
|
|
233
234
|
const verify = (value.data as { verify?: TrackScoped<VerifySpec> } | undefined)?.verify;
|
|
234
235
|
if (!verify) continue;
|
|
235
|
-
const ids =
|
|
236
|
-
checkpointRe.lastIndex = 0;
|
|
237
|
-
for (;;) {
|
|
238
|
-
const m = checkpointRe.exec(body);
|
|
239
|
-
if (m === null) break;
|
|
240
|
-
const id = m[1] ?? m[2] ?? m[3];
|
|
241
|
-
if (id) ids.add(id);
|
|
242
|
-
}
|
|
236
|
+
const ids = collectProgressItemIds(body).Checkpoint;
|
|
243
237
|
|
|
244
238
|
if (isTrackMap(verify, isVerifySpec)) {
|
|
245
239
|
validateTrackMapCoverage({
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const PROGRESS_ITEM_COMPONENTS = ["Checkpoint", "Quiz"] as const;
|
|
2
|
+
|
|
3
|
+
type ProgressItemComponent = (typeof PROGRESS_ITEM_COMPONENTS)[number];
|
|
4
|
+
|
|
5
|
+
export type ProgressItemIds = Record<ProgressItemComponent, Set<string>>;
|
|
6
|
+
|
|
7
|
+
const emptyProgressItemIds = (): ProgressItemIds => ({
|
|
8
|
+
Checkpoint: new Set<string>(),
|
|
9
|
+
Quiz: new Set<string>(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function stripFencedCodeBlocks(body: string): string {
|
|
13
|
+
return body.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1\s*$/gm, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readAttribute(tag: string, attribute: string): string | undefined {
|
|
17
|
+
const attrRe = new RegExp(`\\b${attribute}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\`([^\`]+)\`\\})`);
|
|
18
|
+
const match = attrRe.exec(tag);
|
|
19
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readTag(source: string, start: number): { tag: string; end: number } | null {
|
|
23
|
+
let quote: '"' | "'" | "`" | null = null;
|
|
24
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
25
|
+
const char = source[i];
|
|
26
|
+
if (quote) {
|
|
27
|
+
if (char === quote) quote = null;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
31
|
+
quote = char;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (char === ">") return { tag: source.slice(start, i + 1), end: i + 1 };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findProgressItemTags(body: string) {
|
|
40
|
+
const source = stripFencedCodeBlocks(body);
|
|
41
|
+
const tags: Array<{ component: ProgressItemComponent; tag: string }> = [];
|
|
42
|
+
for (let i = 0; i < source.length; i++) {
|
|
43
|
+
if (source[i] !== "<" || !/[A-Za-z]/.test(source[i + 1] ?? "")) continue;
|
|
44
|
+
const tag = readTag(source, i);
|
|
45
|
+
if (!tag) continue;
|
|
46
|
+
const match = /^<(Checkpoint|Quiz)\b/.exec(tag.tag);
|
|
47
|
+
if (match) tags.push({ component: match[1] as ProgressItemComponent, tag: tag.tag });
|
|
48
|
+
i = tag.end - 1;
|
|
49
|
+
}
|
|
50
|
+
return tags;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateProgressItemIds({ body, entryId }: { body: string; entryId: string }) {
|
|
54
|
+
for (const { component, tag } of findProgressItemTags(body)) {
|
|
55
|
+
if (readAttribute(tag, "id")) continue;
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[handzon] step ${entryId}: <${component}> must include an explicit id because it contributes to progress. Use id="<step-area>/<concept>" so learner progress survives content edits.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function collectProgressItemIds(body: string): ProgressItemIds {
|
|
63
|
+
const ids = emptyProgressItemIds();
|
|
64
|
+
for (const { component, tag } of findProgressItemTags(body)) {
|
|
65
|
+
const id = readAttribute(tag, "id");
|
|
66
|
+
if (id) ids[component].add(id);
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|