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.0",
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"
@@ -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 = new Set<string>();
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
+ }