@qnroa/qtype 0.0.9 → 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.
@@ -1,3 +1,4 @@
1
+ import { computeCardId } from '../cardId.js';
1
2
  import { parseMaterialFrontmatter, parseCardFrontmatter } from './frontmatter.js';
2
3
  import { textToCharPlans } from '../keystroke/index.js';
3
4
  import { validateTypingText } from '../keystroke/textNormalize.js';
@@ -20,13 +21,31 @@ export function parseMaterial(source, filePath = 'inline') {
20
21
  * Split the material body (after frontmatter) into per-card chunks,
21
22
  * one per H1. Each chunk starts with its `# heading` line and includes
22
23
  * everything up to the next H1 (or end-of-file).
24
+ *
25
+ * H1 detection is fence-aware — lines that look like `# ...` while
26
+ * inside a ``` ``` fenced code block are treated as body content (a
27
+ * shell / Python / Ruby comment), not as a new card. Without this,
28
+ * material files that embed code with hash comments (e.g. a regex
29
+ * primer's Python examples) get sliced into dozens of bogus cards
30
+ * whose ids leak downstream through the `<file>#<idx>` scheme.
23
31
  */
24
32
  function splitByH1(body) {
25
33
  const lines = body.split('\n');
26
34
  const chunks = [];
27
35
  let current = null;
36
+ let inFence = false;
28
37
  for (const line of lines) {
29
- if (/^#\s+/.test(line)) {
38
+ // Toggle fence state on any line starting with ``` — matches the
39
+ // opening (with an optional language tag) and the closing (bare)
40
+ // form. Fence lines themselves count as body if we're already
41
+ // tracking a card.
42
+ if (/^```/.test(line)) {
43
+ inFence = !inFence;
44
+ if (current)
45
+ current.push(line);
46
+ continue;
47
+ }
48
+ if (!inFence && /^#\s+/.test(line)) {
30
49
  if (current)
31
50
  chunks.push(current.join('\n').trim());
32
51
  current = [line];
@@ -97,6 +116,12 @@ function parseCard(chunk, defaultType, defaultTags, filePath, index) {
97
116
  return null;
98
117
  card.type = cardType;
99
118
  card.tags = tags;
119
+ card.id = computeCardId({
120
+ materialFile: filePath,
121
+ title: card.title,
122
+ answer: card.answer,
123
+ question: card.question,
124
+ });
100
125
  return card;
101
126
  }
102
127
  function dedupe(tags) {
@@ -126,7 +151,9 @@ function buildAnnotatedCard(title, body, filePath, index) {
126
151
  const h4 = extractHeadingContent(body, 4);
127
152
  const charPlans = textToCharPlans(answer);
128
153
  return {
129
- id: `${filePath}#${index}`,
154
+ id: '', // filled in by parseCard after content is finalised
155
+ materialFile: filePath,
156
+ indexInMaterial: index,
130
157
  title: answer,
131
158
  answer,
132
159
  phonetic: h2 || undefined,
@@ -154,7 +181,9 @@ function buildArticleCard(title, body, filePath, index) {
154
181
  return null;
155
182
  const charPlans = textToCharPlans(answer);
156
183
  return {
157
- id: `${filePath}#${index}`,
184
+ id: '', // filled in by parseCard after content is finalised
185
+ materialFile: filePath,
186
+ indexInMaterial: index,
158
187
  title,
159
188
  answer,
160
189
  // `question` carries the raw body so images render in the card
@@ -188,7 +217,9 @@ function buildQACard(title, body, filePath, index) {
188
217
  return null;
189
218
  const charPlans = textToCharPlans(answer);
190
219
  return {
191
- id: `${filePath}#${index}`,
220
+ id: '', // filled in by parseCard after content is finalised
221
+ materialFile: filePath,
222
+ indexInMaterial: index,
192
223
  title,
193
224
  answer,
194
225
  question,
@@ -1,7 +1,5 @@
1
1
  export const defaultTypingSettings = {
2
2
  stopOnError: false,
3
- forgiveErrors: false,
4
- spaceSkipsWords: false,
5
3
  ignoreCase: false,
6
4
  ignorePunctuation: false,
7
5
  allowBackspace: true,
@@ -1,3 +1,4 @@
1
+ import { round } from '../round.js';
1
2
  import { CharAttr, hasAttr } from './chars.js';
2
3
  const MS_PER_MINUTE = 60_000;
3
4
  export function computeStats(chars, position, completed, now) {
@@ -39,7 +40,3 @@ export function computeStats(chars, position, completed, now) {
39
40
  finishedAt,
40
41
  };
41
42
  }
42
- function round(n, digits = 2) {
43
- const p = 10 ** digits;
44
- return Math.round(n * p) / p;
45
- }
@@ -1,3 +1,4 @@
1
+ import { round } from './round.js';
1
2
  const MS_PER_MINUTE = 60_000;
2
3
  export function computeMetrics(s, now = Date.now()) {
3
4
  const startedAt = s.startedAt ?? now;
@@ -14,7 +15,3 @@ export function computeMetrics(s, now = Date.now()) {
14
15
  elapsedMs,
15
16
  };
16
17
  }
17
- function round(n, digits = 2) {
18
- const p = 10 ** digits;
19
- return Math.round(n * p) / p;
20
- }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Fixed-precision round used by the typing metrics + stats modules.
3
+ *
4
+ * Lives in its own file only because two consumers (metrics.ts /
5
+ * engine/stats.ts) both need it — inlining it in either place forces
6
+ * the other to keep a byte-identical copy, which drifted once and
7
+ * would drift again.
8
+ */
9
+ export function round(n, digits = 2) {
10
+ const p = 10 ** digits;
11
+ return Math.round(n * p) / p;
12
+ }
@@ -41,4 +41,5 @@ export { KvStore, parseKey, getAt, setAt, unsetAt, flatten, } from './kv.js';
41
41
  export { enumValidator, nonEmptyStringValidator, boolValidator, positiveIntValidator, absolutePathValidator, predicateValidator, } from './validators.js';
42
42
  export { JsonStorage } from './storage/json.js';
43
43
  export { BrowserStorage } from './storage/browser.js';
44
+ export { SessionStorage } from './storage/session.js';
44
45
  export { MemoryStorage } from './storage/memory.js';
@@ -58,4 +58,53 @@ export class BrowserStorage {
58
58
  throw new KvError('err.kv.storage.writeFailed', { origin: this.origin, reason: e.message }, `${this.origin}: write failed — ${e.message}`);
59
59
  }
60
60
  }
61
+ // ─── Synchronous variants ───────────────────────────────
62
+ //
63
+ // Under the hood `localStorage` is entirely synchronous. The async
64
+ // signatures above exist to satisfy the shared `Storage` interface
65
+ // (JsonStorage / MemoryStorage), but the browser side does not need
66
+ // to pay the cost of a microtask for every read.
67
+ //
68
+ // React rendering cannot `await`, so the view layer (`view/state/kv`)
69
+ // needs to read a value at render time. Rather than have it drop
70
+ // through to `localStorage.getItem` directly and duplicate all the
71
+ // parse / validate logic below, expose sync counterparts here.
72
+ //
73
+ // Semantics match the async versions except that parse / write
74
+ // failures on the sync path throw plain `Error` instead of `KvError`
75
+ // — the view layer wants "no data / broken data" to fall back to
76
+ // defaults silently, which matches the existing `readBlob` behaviour.
77
+ /** Synchronous read. Returns `{}` on missing / unavailable /
78
+ * malformed data — never throws. */
79
+ readSync() {
80
+ const ls = getLocalStorage();
81
+ if (!ls)
82
+ return {};
83
+ const raw = ls.getItem(this.storageKey);
84
+ if (!raw)
85
+ return {};
86
+ try {
87
+ const data = JSON.parse(raw);
88
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
89
+ return data;
90
+ }
91
+ }
92
+ catch {
93
+ /* fall through */
94
+ }
95
+ return {};
96
+ }
97
+ /** Synchronous write. Silently drops quota-exceeded / private-mode
98
+ * failures — the view layer treats storage as best-effort. */
99
+ writeSync(data) {
100
+ const ls = getLocalStorage();
101
+ if (!ls)
102
+ return;
103
+ try {
104
+ ls.setItem(this.storageKey, JSON.stringify(data));
105
+ }
106
+ catch {
107
+ /* ignore quota / private mode */
108
+ }
109
+ }
61
110
  }
@@ -0,0 +1,92 @@
1
+ import { KvError } from '../types.js';
2
+ function getSessionStorage() {
3
+ try {
4
+ const ss = globalThis
5
+ .sessionStorage;
6
+ return ss ?? null;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export class SessionStorage {
13
+ scope;
14
+ storageKey;
15
+ constructor(scope, storageKey = `qtype.${scope}`) {
16
+ this.scope = scope;
17
+ this.storageKey = storageKey;
18
+ }
19
+ get origin() {
20
+ return `sessionStorage:${this.storageKey}`;
21
+ }
22
+ async available() {
23
+ return getSessionStorage() !== null;
24
+ }
25
+ async read() {
26
+ const ss = getSessionStorage();
27
+ if (!ss)
28
+ return {};
29
+ const raw = ss.getItem(this.storageKey);
30
+ if (!raw)
31
+ return {};
32
+ let data;
33
+ try {
34
+ data = JSON.parse(raw);
35
+ }
36
+ catch (e) {
37
+ throw new KvError('err.kv.storage.parseFailed', { origin: this.origin, reason: e.message }, `${this.origin}: JSON parse error — ${e.message}`);
38
+ }
39
+ if (data == null)
40
+ return {};
41
+ if (typeof data !== 'object' || Array.isArray(data)) {
42
+ throw new KvError('err.kv.storage.parseFailed', {
43
+ origin: this.origin,
44
+ reason: `root must be a JSON object, got ${Array.isArray(data) ? 'array' : typeof data}`,
45
+ }, `${this.origin}: root must be a JSON object`);
46
+ }
47
+ return data;
48
+ }
49
+ async write(data) {
50
+ const ss = getSessionStorage();
51
+ if (!ss) {
52
+ throw new KvError('err.kv.storage.unavailable', { scope: this.scope, origin: this.origin }, `${this.origin}: sessionStorage not available`);
53
+ }
54
+ try {
55
+ ss.setItem(this.storageKey, JSON.stringify(data));
56
+ }
57
+ catch (e) {
58
+ throw new KvError('err.kv.storage.writeFailed', { origin: this.origin, reason: e.message }, `${this.origin}: write failed — ${e.message}`);
59
+ }
60
+ }
61
+ /** Synchronous read. Returns `{}` on missing / malformed data. */
62
+ readSync() {
63
+ const ss = getSessionStorage();
64
+ if (!ss)
65
+ return {};
66
+ const raw = ss.getItem(this.storageKey);
67
+ if (!raw)
68
+ return {};
69
+ try {
70
+ const data = JSON.parse(raw);
71
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
72
+ return data;
73
+ }
74
+ }
75
+ catch {
76
+ /* fall through */
77
+ }
78
+ return {};
79
+ }
80
+ /** Synchronous write. Silently drops quota / private-mode errors. */
81
+ writeSync(data) {
82
+ const ss = getSessionStorage();
83
+ if (!ss)
84
+ return;
85
+ try {
86
+ ss.setItem(this.storageKey, JSON.stringify(data));
87
+ }
88
+ catch {
89
+ /* ignore */
90
+ }
91
+ }
92
+ }