@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.
- package/CHANGELOG.md +146 -0
- package/CHANGELOG.zh.md +108 -0
- package/dist/cli/commands/content/lint/action.js +32 -3
- package/dist/cli/commands/content/lint/i18n.js +2 -0
- package/dist/cli/commands/publish/build/action.js +26 -11
- package/dist/cli/commands/publish/build/mermaidPass.js +29 -9
- package/dist/cli/commands/repo/sync/action.js +62 -1
- package/dist/cli/utils/prompt.js +7 -3
- package/dist/core/cardId.js +101 -0
- package/dist/core/index.js +1 -0
- package/dist/core/keystroke/textNormalize.js +13 -10
- package/dist/core/parser/frontmatter.js +2 -3
- package/dist/core/parser/parseMaterial.js +35 -4
- package/dist/core/typing/engine/TypingInput.js +8 -2
- package/dist/core/typing/engine/normalize.js +87 -0
- package/dist/core/typing/engine/settings.js +0 -2
- package/dist/core/typing/engine/stats.js +1 -4
- package/dist/core/typing/metrics.js +1 -4
- package/dist/core/typing/round.js +12 -0
- package/dist/store/index.js +1 -0
- package/dist/store/storage/browser.js +49 -0
- package/dist/store/storage/session.js +92 -0
- package/dist/view/assets/index--xiys9ma.js +182 -0
- package/dist/view/assets/{index-Due6dVBA.js → index-CpOQ9x3V.js} +1 -1
- package/dist/view/assets/{index-DI1_zxwA.css → index-_1m9NuFe.css} +1 -1
- package/dist/view/assets/{index-B29a4MHP.js → index-y4VkSsUN.js} +2 -2
- package/dist/view/index.html +2 -2
- package/docs/en/cli-reference.md +10 -6
- package/docs/zh/cli-reference.md +10 -6
- package/package.json +2 -1
- 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
|
+
}
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
78
|
-
:
|
|
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,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
|
+
}
|
package/dist/store/index.js
CHANGED
|
@@ -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
|
+
}
|