@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.
- package/CHANGELOG.md +115 -0
- package/CHANGELOG.zh.md +85 -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-Due6dVBA.js → index-Bp7wq7S1.js} +1 -1
- package/dist/view/assets/{index-B29a4MHP.js → index-D6FqmGLJ.js} +2 -2
- package/dist/view/assets/{index-DI1_zxwA.css → index-_1m9NuFe.css} +1 -1
- 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
|
@@ -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
|
+
}
|