@qnroa/qtype 0.0.8 → 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.
- package/CHANGELOG.md +146 -0
- package/CHANGELOG.zh.md +111 -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/parser/frontmatter.js +2 -3
- package/dist/core/parser/parseMaterial.js +35 -4
- 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-BY0yuQoY.js +182 -0
- package/dist/view/assets/{index-DFOCJiFs.js → index-Bp7wq7S1.js} +1 -1
- package/dist/view/assets/{index-BkcIwMkg.js → index-D6FqmGLJ.js} +2 -2
- package/dist/view/assets/{index-TxZJkfgF.css → index-_1m9NuFe.css} +1 -1
- package/dist/view/index.html +2 -2
- package/docs/en/cli-reference.md +10 -6
- package/docs/en/deployment.md +2 -1
- package/docs/zh/cli-reference.md +10 -6
- package/docs/zh/deployment.md +2 -1
- package/package.json +2 -1
- package/dist/view/assets/index-BLq7QuZg.js +0 -181
|
@@ -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';
|
|
@@ -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,
|
|
@@ -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
|
+
}
|