@qnroa/qtype 0.0.9 → 0.1.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/CHANGELOG.zh.md +108 -0
  3. package/dist/cli/commands/content/lint/action.js +32 -3
  4. package/dist/cli/commands/content/lint/i18n.js +2 -0
  5. package/dist/cli/commands/publish/build/action.js +26 -11
  6. package/dist/cli/commands/publish/build/mermaidPass.js +29 -9
  7. package/dist/cli/commands/repo/sync/action.js +62 -1
  8. package/dist/cli/utils/prompt.js +7 -3
  9. package/dist/core/cardId.js +101 -0
  10. package/dist/core/index.js +1 -0
  11. package/dist/core/keystroke/textNormalize.js +13 -10
  12. package/dist/core/parser/frontmatter.js +2 -3
  13. package/dist/core/parser/parseMaterial.js +35 -4
  14. package/dist/core/typing/engine/TypingInput.js +8 -2
  15. package/dist/core/typing/engine/normalize.js +87 -0
  16. package/dist/core/typing/engine/settings.js +0 -2
  17. package/dist/core/typing/engine/stats.js +1 -4
  18. package/dist/core/typing/metrics.js +1 -4
  19. package/dist/core/typing/round.js +12 -0
  20. package/dist/store/index.js +1 -0
  21. package/dist/store/storage/browser.js +49 -0
  22. package/dist/store/storage/session.js +92 -0
  23. package/dist/view/assets/index--xiys9ma.js +182 -0
  24. package/dist/view/assets/{index-Due6dVBA.js → index-CpOQ9x3V.js} +1 -1
  25. package/dist/view/assets/{index-DI1_zxwA.css → index-_1m9NuFe.css} +1 -1
  26. package/dist/view/assets/{index-B29a4MHP.js → index-y4VkSsUN.js} +2 -2
  27. package/dist/view/index.html +2 -2
  28. package/docs/en/cli-reference.md +10 -6
  29. package/docs/zh/cli-reference.md +10 -6
  30. package/package.json +2 -1
  31. package/dist/view/assets/index-D2236cNR.js +0 -182
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Content-derived card id.
3
+ *
4
+ * Historically qtype used `<file>#<indexInMaterial>` as the card id. That
5
+ * value doubled as a stable "identity" (for done tracking, filter hit
6
+ * lists, deep links) while also being the physical position pointer —
7
+ * so any time the author inserted / removed / reordered a card, every
8
+ * downstream id in that material shifted, silently invalidating every
9
+ * saved reference.
10
+ *
11
+ * The new id is a SHA-256 prefix over the card's content-defining
12
+ * fields:
13
+ *
14
+ * materialFile + '\n' + title + '\n' + answer + '\n' + question
15
+ *
16
+ * — trimmed to 12 hex chars (48 bits, ~2^24 birthday collision boundary,
17
+ * plenty for a single repo). This makes the id:
18
+ * • Reorder-safe (position doesn't feed the hash).
19
+ * • Human-copy-friendly (short enough to paste in URLs).
20
+ * • Content-change-sensitive (a real edit produces a new id, which is
21
+ * the honest behaviour — the reader treats the pre-edit and post-edit
22
+ * forms as distinct cards).
23
+ *
24
+ * We ship a small sync SHA-256 rather than pulling in `crypto.subtle`
25
+ * (async, needs to be awaited at parse time) — the payload is tiny and
26
+ * the parser stays synchronous.
27
+ */
28
+ import { sha256 } from '@noble/hashes/sha2.js';
29
+ const HEX = '0123456789abcdef';
30
+ function toHex(bytes) {
31
+ let out = '';
32
+ for (let i = 0; i < bytes.length; i++) {
33
+ const b = bytes[i];
34
+ out += HEX[b >>> 4] + HEX[b & 0x0f];
35
+ }
36
+ return out;
37
+ }
38
+ /** Content hash prefix length in hex chars. 12 hex = 48 bits. */
39
+ export const CARD_ID_LEN = 12;
40
+ export function computeCardId(input) {
41
+ const src = [
42
+ input.materialFile,
43
+ input.title,
44
+ input.answer,
45
+ input.question ?? '',
46
+ ].join('\n');
47
+ const bytes = sha256(new TextEncoder().encode(src));
48
+ return toHex(bytes).slice(0, CARD_ID_LEN);
49
+ }
50
+ // ─── URL slug encoding ────────────────────────────────────────────
51
+ //
52
+ // URL shape:
53
+ // /typing/<material-path>/<idx>-<cardId>
54
+ //
55
+ // /typing/qa/algorithms/25-f849bcc10faf
56
+ // /typing/code/algo/array/cpp/3-abc123def456
57
+ //
58
+ // The material path echoes the source `material/*.md` tree with the
59
+ // `.md` extension stripped. `idx` is the card's 0-based position in
60
+ // its material; `cardId` is a 12-hex content hash used as a
61
+ // verification code — if the URL's `idx` no longer points at the
62
+ // card whose content produced `cardId`, the typing route quietly
63
+ // redirects to the current position.
64
+ //
65
+ // Splitting on the LAST dash is deliberate: material names may
66
+ // legitimately contain dashes (`en/idioms-day1`), so a "split on
67
+ // any dash" scheme would misinterpret them. Idx + cardId sit at the
68
+ // end; the material path is whatever comes before the last `/`.
69
+ /**
70
+ * Build the `/typing/...` splat portion (i.e. everything after
71
+ * `/typing/`). Does NOT `.md`-strip or url-encode — callers pass
72
+ * `materialFile` without the extension.
73
+ */
74
+ export function formatCardSlug(materialFile, indexInMaterial, cardId) {
75
+ const stem = materialFile.replace(/\.md$/, '');
76
+ return `${stem}/${indexInMaterial}-${cardId}`;
77
+ }
78
+ export function parseCardSlug(slug) {
79
+ const lastSlash = slug.lastIndexOf('/');
80
+ if (lastSlash < 0)
81
+ return null;
82
+ const stem = slug.slice(0, lastSlash);
83
+ const tail = slug.slice(lastSlash + 1);
84
+ const lastDash = tail.lastIndexOf('-');
85
+ if (lastDash < 0)
86
+ return null;
87
+ const idxStr = tail.slice(0, lastDash);
88
+ const cardId = tail.slice(lastDash + 1);
89
+ const idx = parseInt(idxStr, 10);
90
+ if (!Number.isFinite(idx) || idx < 0)
91
+ return null;
92
+ if (!/^[0-9a-f]{12}$/.test(cardId))
93
+ return null;
94
+ if (!stem)
95
+ return null;
96
+ return {
97
+ materialFile: `${stem}.md`,
98
+ indexInMaterial: idx,
99
+ cardId,
100
+ };
101
+ }
@@ -7,3 +7,4 @@ export * from './typing/index.js';
7
7
  export { charToKeystrokes, textToCharPlans } from './keystroke/index.js';
8
8
  export { normalizeText, validateTypingText } from './keystroke/textNormalize.js';
9
9
  export * from './encrypt.js';
10
+ export { computeCardId, CARD_ID_LEN, formatCardSlug, parseCardSlug, } from './cardId.js';
@@ -1,15 +1,18 @@
1
1
  // Import-time text sanitization + validation.
2
- // Goal: guarantee that every character surviving normalization has a valid
3
- // keystroke plan, so runtime never has to skip or bail on unknown chars.
2
+ //
3
+ // This layer used to rewrite user-visible characters (smart quotes to
4
+ // straight, fullwidth space to half, em-dash to double hyphen, etc.) at
5
+ // import time — which meant the *displayed* text no longer matched what
6
+ // the author wrote. That's the wrong layer: display should be honest
7
+ // about the source; runtime equivalence for typing lives in
8
+ // `typing/engine/normalize.ts` and only affects comparison.
9
+ //
10
+ // So the rewrite table is empty. We still strip characters that are
11
+ // truly untypable (control chars, zero-width, emoji), because those
12
+ // have no keyboard input at all.
4
13
  import { charToKeystrokes } from './index.js';
5
- // Character rewrites: smart quotes, unusual dashes, etc.
6
- const REWRITE = {
7
- '—': '——', // em-dash → double hyphen (matches keystroke plan for 中文 —)
8
- '–': '-', // en-dash → hyphen
9
- '…': '……', // horizontal ellipsis → double per convention (chineseMap uses ……)
10
- ' ': ' ', // non-breaking space → regular space
11
- ' ': ' ', // ideographic space → regular space
12
- };
14
+ // Rewrites are intentionally empty. See file-level comment.
15
+ const REWRITE = {};
13
16
  // Ranges of code points to strip entirely (they can't be typed).
14
17
  const STRIP_RANGES = [
15
18
  [0x0000, 0x0008], // C0 controls before \t
@@ -10,9 +10,8 @@ const FM_RE = /^---\n([\s\S]*?)\n---\n?/;
10
10
  * updated: YYYY-MM-DD
11
11
  * ---
12
12
  *
13
- * Card-level frontmatter no longer existseverything a card carries
14
- * is expressed by its markdown structure (see parseMaterial for the
15
- * per-type rules).
13
+ * `type` and `tags` here are *defaults* cards can override via
14
+ * their own `parseCardFrontmatter` block (see above).
16
15
  */
17
16
  export function parseMaterialFrontmatter(source) {
18
17
  const meta = {};
@@ -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,
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { CharAttr } from './chars.js';
21
21
  import { Feedback } from './feedback.js';
22
+ import { normalizeForCompare } from './normalize.js';
22
23
  import { defaultTypingSettings, } from './settings.js';
23
24
  import { computeStats } from './stats.js';
24
25
  export class TypingInput {
@@ -73,9 +74,14 @@ export class TypingInput {
73
74
  return Feedback.Correct;
74
75
  expected = this.#targetCodepoints[this.#position];
75
76
  }
77
+ // Normalize both sides through the same equivalence table so
78
+ // fullwidth/half-width / smart-quote / CJK-punctuation variants
79
+ // don't cause false misses. Display remains untouched.
80
+ const nCp = normalizeForCompare(codePoint);
81
+ const nExp = normalizeForCompare(expected);
76
82
  const isHit = this.settings.ignoreCase
77
- ? sameLetter(codePoint, expected)
78
- : codePoint === expected;
83
+ ? sameLetter(nCp, nExp)
84
+ : nCp === nExp;
79
85
  // Clone the array so subscribers see a new reference. Only the touched
80
86
  // Char is a new object — the rest are shared.
81
87
  const next = this.#chars.slice();
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runtime input normalization for typing comparison.
3
+ *
4
+ * The engine used to compare `codePoint === expected` strictly, which broke
5
+ * for equivalent-but-different characters that the OS/IME hands out
6
+ * depending on state:
7
+ *
8
+ * - Fullwidth space U+3000 vs regular space U+0020 (Chinese IME)
9
+ * - Non-breaking space U+00A0 vs U+0020
10
+ * - Smart quotes U+201C/U+201D/U+2018/U+2019 vs straight quotes
11
+ * - Fullwidth CJK punctuation (U+FF0C etc.) vs ASCII punctuation
12
+ * - En-dash U+2013 vs hyphen U+002D
13
+ * - Horizontal ellipsis U+2026 vs three dots
14
+ *
15
+ * These are all pairs where the two forms carry the same *intent* — the
16
+ * user is trying to type the character they see, and shouldn't be
17
+ * penalised because their input method emitted a different codepoint.
18
+ *
19
+ * The rule: normalize BOTH the user's keystroke and the expected
20
+ * character through the same table, then compare. Display is untouched.
21
+ *
22
+ * Character classes we do NOT normalize:
23
+ * - Fullwidth Latin letters (U+FF21..U+FF3A / U+FF41..U+FF5A) — if a
24
+ * card contains "A" that's the author's explicit intent; ignoreCase
25
+ * handles the ASCII case elsewhere.
26
+ * - Fullwidth digits (U+FF10..U+FF19) — same reasoning.
27
+ * - Curly braces / angles that carry semantic meaning in Chinese text.
28
+ */
29
+ /** Char → equivalent char used for typing comparison. */
30
+ const EQUIV = {
31
+ // ─── Spaces ───────────────────────────────────────────
32
+ 0x3000: 0x0020, // U+3000 IDEOGRAPHIC SPACE
33
+ 0x00A0: 0x0020, // U+00A0 NO-BREAK SPACE
34
+ // ─── Quotes ────────────────────────────────────────────
35
+ 0x201C: 0x0022, // U+201C LEFT DOUBLE QUOTATION MARK
36
+ 0x201D: 0x0022, // U+201D RIGHT DOUBLE QUOTATION MARK
37
+ 0x201E: 0x0022, // U+201E DOUBLE LOW-9
38
+ 0x2033: 0x0022, // U+2033 DOUBLE PRIME
39
+ 0x2018: 0x0027, // U+2018 LEFT SINGLE QUOTATION MARK
40
+ 0x2019: 0x0027, // U+2019 RIGHT SINGLE QUOTATION MARK
41
+ 0x2032: 0x0027, // U+2032 PRIME
42
+ // ─── Dashes ────────────────────────────────────────────
43
+ 0x2013: 0x002D, // U+2013 EN DASH
44
+ 0x2014: 0x002D, // U+2014 EM DASH (kept: single "-" match; author's "——"
45
+ // double-hyphen convention still works because
46
+ // it's already two ASCII "-" in storage)
47
+ 0x2212: 0x002D, // U+2212 MINUS SIGN
48
+ 0xFF0D: 0x002D, // U+FF0D FULLWIDTH HYPHEN-MINUS
49
+ // ─── CJK punctuation → ASCII ───────────────────────────
50
+ 0xFF0C: 0x002C, // ,
51
+ 0x3002: 0x002E, // 。 (map to `.`; author writing 句号 the user types `.`)
52
+ 0xFF1F: 0x003F, // ?
53
+ 0xFF01: 0x0021, // !
54
+ 0xFF1A: 0x003A, // :
55
+ 0xFF1B: 0x003B, // ;
56
+ 0xFF08: 0x0028, // (
57
+ 0xFF09: 0x0029, // )
58
+ 0x3010: 0x005B, // 【
59
+ 0x3011: 0x005D, // 】
60
+ 0x300A: 0x003C, // 《
61
+ 0x300B: 0x003E, // 》
62
+ 0xFF0F: 0x002F, // /
63
+ 0xFF0B: 0x002B, // +
64
+ 0xFF1D: 0x003D, // =
65
+ 0xFF5E: 0x007E, // ~
66
+ 0xFF03: 0x0023, // #
67
+ 0xFF04: 0x0024, // $
68
+ 0xFF05: 0x0025, // %
69
+ 0xFF06: 0x0026, // &
70
+ 0xFF0A: 0x002A, // *
71
+ 0xFF20: 0x0040, // @
72
+ 0xFF3B: 0x005B, // [
73
+ 0xFF3D: 0x005D, // ]
74
+ 0xFF5B: 0x007B, // {
75
+ 0xFF5D: 0x007D, // }
76
+ // ─── Middle dot / interpunct ───────────────────────────
77
+ 0x00B7: 0x00B7, // U+00B7 keep (rendered as ·)
78
+ 0x2027: 0x00B7, // U+2027 HYPHENATION POINT
79
+ 0x30FB: 0x00B7, // U+30FB KATAKANA MIDDLE DOT
80
+ // ─── Ellipsis ──────────────────────────────────────────
81
+ 0x2026: 0x002E, // U+2026 HORIZONTAL ELLIPSIS -> "." (matched char-by-char
82
+ // if the card stores "..." literally)
83
+ };
84
+ /** Return the canonical form of `codePoint` for typing comparison. */
85
+ export function normalizeForCompare(codePoint) {
86
+ return EQUIV[codePoint] ?? codePoint;
87
+ }
@@ -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
+ }