@qnroa/qtype 0.0.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 (110) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CHANGELOG.zh.md +82 -0
  3. package/LICENSE +201 -0
  4. package/README.md +113 -0
  5. package/README.zh.md +114 -0
  6. package/dist/cli/bootstrap.js +190 -0
  7. package/dist/cli/commands/check/action.js +56 -0
  8. package/dist/cli/commands/check/i18n.js +26 -0
  9. package/dist/cli/commands/check/index.js +20 -0
  10. package/dist/cli/commands/config/action.js +236 -0
  11. package/dist/cli/commands/config/i18n.js +64 -0
  12. package/dist/cli/commands/config/index.js +72 -0
  13. package/dist/cli/commands/content/index.js +22 -0
  14. package/dist/cli/commands/content/lint/action.js +102 -0
  15. package/dist/cli/commands/content/lint/i18n.js +32 -0
  16. package/dist/cli/commands/content/lint/index.js +19 -0
  17. package/dist/cli/commands/content/search/action.js +64 -0
  18. package/dist/cli/commands/content/search/i18n.js +20 -0
  19. package/dist/cli/commands/content/search/index.js +24 -0
  20. package/dist/cli/commands/content/validate/action.js +145 -0
  21. package/dist/cli/commands/content/validate/i18n.js +32 -0
  22. package/dist/cli/commands/content/validate/index.js +19 -0
  23. package/dist/cli/commands/index.js +19 -0
  24. package/dist/cli/commands/init/index.js +53 -0
  25. package/dist/cli/commands/pack/i18n.js +20 -0
  26. package/dist/cli/commands/pack/index.js +48 -0
  27. package/dist/cli/commands/plugin/i18n.js +28 -0
  28. package/dist/cli/commands/plugin/index.js +41 -0
  29. package/dist/cli/commands/publish/build/action.js +208 -0
  30. package/dist/cli/commands/publish/build/i18n.js +50 -0
  31. package/dist/cli/commands/publish/build/index.js +24 -0
  32. package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
  33. package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
  34. package/dist/cli/commands/publish/index.js +21 -0
  35. package/dist/cli/commands/publish/init/action.js +74 -0
  36. package/dist/cli/commands/publish/init/i18n.js +22 -0
  37. package/dist/cli/commands/publish/init/index.js +26 -0
  38. package/dist/cli/commands/publish/password/action.js +158 -0
  39. package/dist/cli/commands/publish/password/i18n.js +44 -0
  40. package/dist/cli/commands/publish/password/index.js +35 -0
  41. package/dist/cli/commands/publish/serve/action.js +117 -0
  42. package/dist/cli/commands/publish/serve/i18n.js +32 -0
  43. package/dist/cli/commands/publish/serve/index.js +37 -0
  44. package/dist/cli/commands/repo/index.js +19 -0
  45. package/dist/cli/commands/repo/new/action.js +98 -0
  46. package/dist/cli/commands/repo/new/i18n.js +46 -0
  47. package/dist/cli/commands/repo/new/index.js +57 -0
  48. package/dist/cli/commands/repo/sync/action.js +202 -0
  49. package/dist/cli/commands/repo/sync/i18n.js +44 -0
  50. package/dist/cli/commands/repo/sync/index.js +47 -0
  51. package/dist/cli/commands/repo/templates.js +87 -0
  52. package/dist/cli/i18n/index.js +141 -0
  53. package/dist/cli/i18n/shared.js +142 -0
  54. package/dist/cli/index.js +79 -0
  55. package/dist/cli/registry/command.js +1 -0
  56. package/dist/cli/registry/commander-i18n.js +76 -0
  57. package/dist/cli/registry/help.js +142 -0
  58. package/dist/cli/registry/mount.js +71 -0
  59. package/dist/cli/utils/output.js +92 -0
  60. package/dist/cli/utils/paths.js +13 -0
  61. package/dist/cli/utils/prompt.js +72 -0
  62. package/dist/cli/utils/repoGuard.js +41 -0
  63. package/dist/core/encrypt.js +113 -0
  64. package/dist/core/index.js +8 -0
  65. package/dist/core/keystroke/asciiMap.js +39 -0
  66. package/dist/core/keystroke/chineseMap.js +32 -0
  67. package/dist/core/keystroke/fullwidthMap.js +41 -0
  68. package/dist/core/keystroke/index.js +22 -0
  69. package/dist/core/keystroke/textNormalize.js +68 -0
  70. package/dist/core/keystroke/types.js +1 -0
  71. package/dist/core/parser/frontmatter.js +58 -0
  72. package/dist/core/parser/index.js +2 -0
  73. package/dist/core/parser/parseMaterial.js +42 -0
  74. package/dist/core/types.js +1 -0
  75. package/dist/core/typing/engine.js +112 -0
  76. package/dist/core/typing/index.js +2 -0
  77. package/dist/core/typing/metrics.js +20 -0
  78. package/dist/store/index.js +44 -0
  79. package/dist/store/kv.js +276 -0
  80. package/dist/store/storage/browser.js +61 -0
  81. package/dist/store/storage/json.js +113 -0
  82. package/dist/store/storage/memory.js +24 -0
  83. package/dist/store/types.js +47 -0
  84. package/dist/store/validators.js +71 -0
  85. package/dist/view/assets/index-DBABH3dG.js +111 -0
  86. package/dist/view/assets/index-RTLZ-zOC.css +1 -0
  87. package/dist/view/index.html +14 -0
  88. package/dist/view/qtype-logo.svg +49 -0
  89. package/dist/view/qtype-mark.svg +21 -0
  90. package/docs/README.md +23 -0
  91. package/docs/README.zh.md +20 -0
  92. package/docs/en/authoring.md +138 -0
  93. package/docs/en/cli-reference.md +171 -0
  94. package/docs/zh/authoring.md +131 -0
  95. package/docs/zh/cli-reference.md +162 -0
  96. package/package.json +85 -0
  97. package/templates/en/QTYPE.md +618 -0
  98. package/templates/en/README.md +64 -0
  99. package/templates/en/TODO.md +7 -0
  100. package/templates/en/index.md +7 -0
  101. package/templates/en/log.md +3 -0
  102. package/templates/en/material/.gitkeep +0 -0
  103. package/templates/en/material/example.md +47 -0
  104. package/templates/zh/QTYPE.md +618 -0
  105. package/templates/zh/README.md +64 -0
  106. package/templates/zh/TODO.md +7 -0
  107. package/templates/zh/index.md +7 -0
  108. package/templates/zh/log.md +3 -0
  109. package/templates/zh/material/.gitkeep +0 -0
  110. package/templates/zh/material/example.md +47 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Minimal stdin prompt helpers for CLI commands.
3
+ *
4
+ * `promptLine` reads until the first newline with echo (visible input).
5
+ * `promptPassword` does the same with echo suppressed on TTYs — falls back
6
+ * to plain echo on non-TTY (e.g. piped stdin) since there's no terminal to
7
+ * mute anyway.
8
+ */
9
+ export function promptLine(prompt) {
10
+ if (prompt)
11
+ process.stdout.write(prompt);
12
+ return new Promise((resolve) => {
13
+ let data = '';
14
+ process.stdin.setEncoding('utf8');
15
+ const onData = (chunk) => {
16
+ data += chunk;
17
+ const nl = data.indexOf('\n');
18
+ if (nl !== -1) {
19
+ process.stdin.off('data', onData);
20
+ resolve(data.slice(0, nl).trimEnd());
21
+ }
22
+ };
23
+ process.stdin.on('data', onData);
24
+ });
25
+ }
26
+ export function promptPassword(prompt) {
27
+ if (prompt)
28
+ process.stdout.write(prompt);
29
+ const stdin = process.stdin;
30
+ const isTty = stdin.isTTY === true;
31
+ if (isTty && typeof stdin.setRawMode === 'function') {
32
+ stdin.setRawMode(true);
33
+ }
34
+ stdin.resume();
35
+ stdin.setEncoding('utf8');
36
+ return new Promise((resolve, reject) => {
37
+ let data = '';
38
+ const finish = (value) => {
39
+ stdin.off('data', onData);
40
+ if (isTty && typeof stdin.setRawMode === 'function')
41
+ stdin.setRawMode(false);
42
+ stdin.pause();
43
+ process.stdout.write('\n');
44
+ resolve(value);
45
+ };
46
+ const onData = (chunk) => {
47
+ for (const ch of chunk) {
48
+ // Ctrl-C in raw mode: abort with a clear error rather than letting
49
+ // the byte fall into the password buffer.
50
+ if (ch === '') {
51
+ if (isTty && typeof stdin.setRawMode === 'function')
52
+ stdin.setRawMode(false);
53
+ stdin.pause();
54
+ process.stdout.write('\n');
55
+ stdin.off('data', onData);
56
+ reject(new Error('aborted'));
57
+ return;
58
+ }
59
+ if (ch === '\n' || ch === '\r') {
60
+ finish(data);
61
+ return;
62
+ }
63
+ if (ch === '' || ch === '\b') {
64
+ data = data.slice(0, -1);
65
+ continue;
66
+ }
67
+ data += ch;
68
+ }
69
+ };
70
+ stdin.on('data', onData);
71
+ });
72
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Repo guard: ensure a path is a qtype repo before running the command.
3
+ *
4
+ * A qtype repo is identified by **the protocol file `QTYPE.md` (or any
5
+ * legacy `QTYPE.<lang>.md`) at the root**, plus the `material/` directory the
6
+ * spec mandates. Earlier versions checked for `.qtype/`, but that's
7
+ * PUBLISH-only configuration metadata — fresh repos created via
8
+ * `qtype repo new` don't have it until the user runs `publish init`,
9
+ * which made content lint/validate refuse to run on perfectly valid
10
+ * just-created repos. Anchoring on the protocol file matches the QTYPE
11
+ * spec's own definition of "what is a qtype repo".
12
+ *
13
+ * Commands that read/write repo content (publish build, content lint/validate,
14
+ * edit, etc.) should call this first — otherwise they happily succeed on
15
+ * arbitrary directories like /tmp, producing meaningless output and dist/
16
+ * directories scattered everywhere.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fatal } from './output.js';
21
+ import { t } from '../i18n/index.js';
22
+ const PROTOCOL_FILES = ['QTYPE.md', 'QTYPE.zh.md', 'QTYPE.en.md'];
23
+ /**
24
+ * Throw via `fatal()` if `repoRoot` is not a qtype repo.
25
+ *
26
+ * Returns the absolute path on success.
27
+ */
28
+ export function ensureQtypeRepo(repoRoot) {
29
+ const abs = path.resolve(repoRoot);
30
+ if (!fs.existsSync(abs)) {
31
+ fatal(t('err.path.notExists', { path: abs }));
32
+ }
33
+ // Either form of the protocol file marks a qtype repo. We check both
34
+ // the canonical `QTYPE.md` and the older language-suffixed names for
35
+ // backward compatibility with repos created before the rename.
36
+ const hasProtocol = PROTOCOL_FILES.some((f) => fs.existsSync(path.join(abs, f)));
37
+ if (!hasProtocol) {
38
+ fatal(t('err.notQtypeRepo', { path: abs }), t('hint.runRepoNew'));
39
+ }
40
+ return abs;
41
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Password-based content encryption for the published static site.
3
+ *
4
+ * Threat model: prevent casual viewing by anyone who has the URL but not
5
+ * the password (Staticrypt-style). NOT a defence against a motivated
6
+ * attacker who downloads the site and brute-forces offline — since the
7
+ * ciphertext + KDF parameters travel to the browser together, that's
8
+ * unavoidable for a static site with no server component.
9
+ *
10
+ * Design:
11
+ * - argon2id(password, aesSalt) → 32-byte AES-256 key.
12
+ * Params fixed at 64 MiB / t=3 / p=1 (same as the interactive-login
13
+ * profile used by `publish password set` for hash verification).
14
+ * - AES-GCM with a fresh random 12-byte IV per ciphertext blob.
15
+ * Auth tag (16 bytes) is appended to the ciphertext by SubtleCrypto,
16
+ * so tampering fails decryption loudly.
17
+ * - Runs identically under Node 20+ (`globalThis.crypto.subtle`) and in
18
+ * the browser — one implementation, one code path.
19
+ *
20
+ * NB: the argon2 hash stored under `publish.password.hash` is used ONLY
21
+ * to verify "did the user type the right password?" before deriving the
22
+ * AES key. It does NOT participate in AES key derivation — that comes
23
+ * from a separately stored salt (`publish.password.aesSalt`) so the KDF
24
+ * path stays clean (plaintext → argon2id → raw bytes → AES key).
25
+ */
26
+ import { argon2id } from '@noble/hashes/argon2.js';
27
+ export const ARGON2_MEMORY = 64 * 1024;
28
+ export const ARGON2_TIME = 3;
29
+ export const ARGON2_PARALLEL = 1;
30
+ export const AES_KEY_LEN = 32;
31
+ export const AES_SALT_LEN = 16;
32
+ export const AES_IV_LEN = 12;
33
+ export const DEFAULT_ARGON2_PARAMS = {
34
+ m: ARGON2_MEMORY,
35
+ t: ARGON2_TIME,
36
+ p: ARGON2_PARALLEL,
37
+ };
38
+ function toB64(u8) {
39
+ let s = '';
40
+ for (let i = 0; i < u8.length; i++)
41
+ s += String.fromCharCode(u8[i]);
42
+ return typeof btoa === 'function'
43
+ ? btoa(s)
44
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ globalThis.Buffer.from(u8).toString('base64');
46
+ }
47
+ function fromB64(s) {
48
+ if (typeof atob === 'function') {
49
+ const bin = atob(s);
50
+ const out = new Uint8Array(bin.length);
51
+ for (let i = 0; i < bin.length; i++)
52
+ out[i] = bin.charCodeAt(i);
53
+ return out;
54
+ }
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ return new Uint8Array(globalThis.Buffer.from(s, 'base64'));
57
+ }
58
+ /** Random bytes via WebCrypto (available in Node 20+ and every browser). */
59
+ export function randomBytes(len) {
60
+ const out = new Uint8Array(len);
61
+ globalThis.crypto.getRandomValues(out);
62
+ return out;
63
+ }
64
+ /** Fresh 16-byte salt for AES key derivation. */
65
+ export function generateAesSalt() {
66
+ return toB64(randomBytes(AES_SALT_LEN));
67
+ }
68
+ /**
69
+ * Derive a 32-byte AES-256 key from (password, salt) using argon2id.
70
+ * Deterministic — same inputs always produce the same key, which lets
71
+ * `publish build` re-encrypt content on every run without a stored key file.
72
+ */
73
+ export async function deriveAesKey(password, saltB64, params = DEFAULT_ARGON2_PARAMS) {
74
+ const salt = fromB64(saltB64);
75
+ const raw = argon2id(password, salt, {
76
+ t: params.t,
77
+ m: params.m,
78
+ p: params.p,
79
+ dkLen: AES_KEY_LEN,
80
+ });
81
+ return globalThis.crypto.subtle.importKey('raw', toBufferSource(raw), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
82
+ }
83
+ // Bridge helper: WebCrypto's DOM BufferSource type demands
84
+ // Uint8Array<ArrayBuffer>, while noble/hashes returns Uint8Array<ArrayBufferLike>
85
+ // (which includes SharedArrayBuffer). Copying into a fresh ArrayBuffer-backed
86
+ // view keeps the API surface clean and works identically in Node and browser.
87
+ function toBufferSource(u8) {
88
+ const buf = new ArrayBuffer(u8.byteLength);
89
+ const view = new Uint8Array(buf);
90
+ view.set(u8);
91
+ return view;
92
+ }
93
+ /** Encrypt a UTF-8 string. Returns base64-encoded {ct, iv}. */
94
+ export async function encryptString(key, plaintext) {
95
+ const iv = randomBytes(AES_IV_LEN);
96
+ const data = new TextEncoder().encode(plaintext);
97
+ const ctBuf = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: toBufferSource(iv) }, key, toBufferSource(data));
98
+ return { ct: toB64(new Uint8Array(ctBuf)), iv: toB64(iv) };
99
+ }
100
+ /** Decrypt an EncryptedBlob back to its original UTF-8 string. */
101
+ export async function decryptString(key, blob) {
102
+ const iv = fromB64(blob.iv);
103
+ const ct = fromB64(blob.ct);
104
+ const ptBuf = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: toBufferSource(iv) }, key, toBufferSource(ct));
105
+ return new TextDecoder().decode(ptBuf);
106
+ }
107
+ /** Type guard so runtime code can safely distinguish plaintext vs encrypted. */
108
+ export function isEncryptedBlob(v) {
109
+ return (typeof v === 'object' &&
110
+ v !== null &&
111
+ typeof v.ct === 'string' &&
112
+ typeof v.iv === 'string');
113
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './parser/index.js';
3
+ export * from './typing/index.js';
4
+ // Runtime helpers from keystroke module. Types (CharPlan / Keystroke) are
5
+ // re-declared in types.ts so callers can import them from '@core/index'
6
+ // without a duplicate export conflict.
7
+ export { charToKeystrokes, textToCharPlans } from './keystroke/index.js';
8
+ export { normalizeText, validateTypingText } from './keystroke/textNormalize.js';
@@ -0,0 +1,39 @@
1
+ // Unshifted characters (single key press, no shift).
2
+ const UNSHIFTED = {
3
+ // letters
4
+ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f', g: 'g', h: 'h', i: 'i',
5
+ j: 'j', k: 'k', l: 'l', m: 'm', n: 'n', o: 'o', p: 'p', q: 'q', r: 'r',
6
+ s: 's', t: 't', u: 'u', v: 'v', w: 'w', x: 'x', y: 'y', z: 'z',
7
+ // digits
8
+ '0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
9
+ '5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
10
+ // punctuation on their base key
11
+ '`': '`', '-': '-', '=': '=',
12
+ '[': '[', ']': ']', '\\': '\\',
13
+ ';': ';', "'": "'",
14
+ ',': ',', '.': '.', '/': '/',
15
+ // whitespace
16
+ ' ': 'space',
17
+ '\n': 'enter',
18
+ '\t': 'tab',
19
+ };
20
+ // Shifted characters.
21
+ const SHIFTED = {
22
+ // uppercase letters
23
+ A: 'a', B: 'b', C: 'c', D: 'd', E: 'e', F: 'f', G: 'g', H: 'h', I: 'i',
24
+ J: 'j', K: 'k', L: 'l', M: 'm', N: 'n', O: 'o', P: 'p', Q: 'q', R: 'r',
25
+ S: 's', T: 't', U: 'u', V: 'v', W: 'w', X: 'x', Y: 'y', Z: 'z',
26
+ // shifted punctuation
27
+ '~': '`', '!': '1', '@': '2', '#': '3', $: '4', '%': '5',
28
+ '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', _: '-', '+': '=',
29
+ '{': '[', '}': ']', '|': '\\',
30
+ ':': ';', '"': "'",
31
+ '<': ',', '>': '.', '?': '/',
32
+ };
33
+ export function asciiToKeystrokes(ch) {
34
+ if (ch in UNSHIFTED)
35
+ return [{ key: UNSHIFTED[ch], imeMode: 'en' }];
36
+ if (ch in SHIFTED)
37
+ return [{ key: SHIFTED[ch], shift: true, imeMode: 'en' }];
38
+ return null;
39
+ }
@@ -0,0 +1,32 @@
1
+ // Chinese-character → pinyin → keystrokes (quanpin, space-commit).
2
+ // Uses pinyin-pro to get the pinyin for a single CJK ideograph, then emits
3
+ // one lowercase-letter keystroke per pinyin letter followed by a space to
4
+ // commit the character.
5
+ import { pinyin } from 'pinyin-pro';
6
+ // Match ANY CJK Unified Ideograph (not just BMP block).
7
+ // Covers CJK Unified Ideographs + Extension A + Compatibility Ideographs.
8
+ export function isChineseChar(ch) {
9
+ const code = ch.codePointAt(0);
10
+ if (code === undefined)
11
+ return false;
12
+ return ((code >= 0x4e00 && code <= 0x9fff) || // CJK Unified
13
+ (code >= 0x3400 && code <= 0x4dbf) || // Extension A
14
+ (code >= 0xf900 && code <= 0xfaff) // Compatibility
15
+ );
16
+ }
17
+ // Returns null if pinyin lookup fails (unknown character).
18
+ export function chineseCharToKeystrokes(ch) {
19
+ const py = pinyin(ch, {
20
+ toneType: 'none',
21
+ type: 'string',
22
+ v: true, // treat ü as v (typical IME input)
23
+ });
24
+ if (!py || !/^[a-z]+$/.test(py))
25
+ return null;
26
+ const keys = py.split('').map((c) => ({
27
+ key: c,
28
+ imeMode: 'zh',
29
+ }));
30
+ keys.push({ key: 'space', imeMode: 'zh' });
31
+ return keys;
32
+ }
@@ -0,0 +1,41 @@
1
+ const K = (key, shift) => ({
2
+ key,
3
+ ...(shift ? { shift: true } : {}),
4
+ imeMode: 'zh',
5
+ });
6
+ // key: single-char (or 2-char for ellipsis/dashes) source char in text.
7
+ // value: keystroke sequence.
8
+ const MAP = {
9
+ // Sentence punctuation
10
+ ',': [K(',')],
11
+ '。': [K('.')],
12
+ '!': [K('1', true)],
13
+ '?': [K('/', true)],
14
+ ':': [K(';', true)],
15
+ ';': [K(';')],
16
+ '、': [K('\\')], // Chinese ideographic comma via backslash in most IMEs
17
+ // Quotes (curly / fullwidth)
18
+ '“': [K("'", true)], // "
19
+ '”': [K("'", true)], // "
20
+ '‘': [K("'")], // '
21
+ '’': [K("'")], // '
22
+ // Brackets
23
+ '(': [K('9', true)],
24
+ ')': [K('0', true)],
25
+ '【': [K('[')],
26
+ '】': [K(']')],
27
+ '《': [K(',', true)],
28
+ '》': [K('.', true)],
29
+ '「': [K('[')], // some IMEs
30
+ '」': [K(']')],
31
+ // Dashes & ellipsis (single char even though rendered wide)
32
+ '—': [K('-', true), K('-', true)], // em-dash → often typed as ——
33
+ '…': [K('6', true), K('6', true)], // horizontal ellipsis → typed as ……
34
+ '·': [K('`')], // middle dot on backtick key in zh mode
35
+ // Currency & math (basic)
36
+ '¥': [K('4', true)], // fullwidth yuan
37
+ };
38
+ export function fullwidthToKeystrokes(ch) {
39
+ return MAP[ch] ?? null;
40
+ }
41
+ export const FULLWIDTH_CHARS = new Set(Object.keys(MAP));
@@ -0,0 +1,22 @@
1
+ import { asciiToKeystrokes } from './asciiMap.js';
2
+ import { fullwidthToKeystrokes } from './fullwidthMap.js';
3
+ import { chineseCharToKeystrokes, isChineseChar } from './chineseMap.js';
4
+ // Resolve one character (post-normalization) to its physical keystrokes.
5
+ // Returns null if the character cannot be typed (should be caught by import
6
+ // validation so users never encounter this at runtime).
7
+ export function charToKeystrokes(ch) {
8
+ return (asciiToKeystrokes(ch) ??
9
+ fullwidthToKeystrokes(ch) ??
10
+ (isChineseChar(ch) ? chineseCharToKeystrokes(ch) : null));
11
+ }
12
+ // Expand a full string into a flat list of CharPlans.
13
+ export function textToCharPlans(text) {
14
+ const plans = [];
15
+ for (const ch of text) {
16
+ // `for..of` iterates by code point, so surrogate pairs stay together.
17
+ const ks = charToKeystrokes(ch);
18
+ if (ks)
19
+ plans.push({ char: ch, keystrokes: ks });
20
+ }
21
+ return plans;
22
+ }
@@ -0,0 +1,68 @@
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.
4
+ 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
+ };
13
+ // Ranges of code points to strip entirely (they can't be typed).
14
+ const STRIP_RANGES = [
15
+ [0x0000, 0x0008], // C0 controls before \t
16
+ [0x000B, 0x001F], // remaining C0 controls (\t=0x09, \n=0x0A kept)
17
+ [0x007F, 0x009F], // DEL + C1 controls
18
+ [0x200B, 0x200F], // zero-width, LTR/RTL marks
19
+ [0x202A, 0x202E], // bidi override marks
20
+ [0x2060, 0x2064], // word joiner + invisible ops
21
+ [0xFEFF, 0xFEFF], // BOM
22
+ [0x1F300, 0x1FAFF], // emoji: symbols & pictographs, transport, etc.
23
+ [0x1F600, 0x1F64F], // emoji: emoticons
24
+ [0x2600, 0x27BF], // misc symbols + dingbats
25
+ [0x1F900, 0x1F9FF], // supplemental symbols & pictographs
26
+ ];
27
+ function shouldStrip(cp) {
28
+ for (const [lo, hi] of STRIP_RANGES)
29
+ if (cp >= lo && cp <= hi)
30
+ return true;
31
+ return false;
32
+ }
33
+ // Normalize text: rewrite smart chars and strip untypable ones.
34
+ export function normalizeText(input) {
35
+ const removed = [];
36
+ let out = '';
37
+ for (const ch of input) {
38
+ // Rewrite first
39
+ if (ch in REWRITE) {
40
+ out += REWRITE[ch];
41
+ continue;
42
+ }
43
+ // Strip
44
+ const cp = ch.codePointAt(0) ?? 0;
45
+ if (shouldStrip(cp)) {
46
+ removed.push(ch);
47
+ continue;
48
+ }
49
+ out += ch;
50
+ }
51
+ return { text: out, removed };
52
+ }
53
+ // Validate a piece of text for typing: normalize, then check each remaining
54
+ // character has a keystroke plan. `unknowns` lists any that don't.
55
+ export function validateTypingText(input) {
56
+ const { text, removed } = normalizeText(input);
57
+ const unknowns = [];
58
+ for (const ch of text) {
59
+ if (!charToKeystrokes(ch))
60
+ unknowns.push(ch);
61
+ }
62
+ return {
63
+ text,
64
+ removed,
65
+ unknowns,
66
+ valid: unknowns.length === 0,
67
+ };
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ const FM_RE = /^---\n([\s\S]*?)\n---\n?/;
2
+ export function parseFrontmatter(block) {
3
+ const meta = { tags: [] };
4
+ const match = block.match(FM_RE);
5
+ if (!match)
6
+ return { meta, rest: block };
7
+ const body = match[1];
8
+ const rest = block.slice(match[0].length);
9
+ for (const line of body.split('\n')) {
10
+ const kv = line.match(/^(\w+)\s*:\s*(.+)$/);
11
+ if (!kv)
12
+ continue;
13
+ const [, key, rawValue] = kv;
14
+ const value = rawValue.trim();
15
+ if (key === 'tags') {
16
+ meta.tags = parseTagList(value);
17
+ }
18
+ else if (key === 'difficulty') {
19
+ const n = Number(value);
20
+ if (!Number.isNaN(n))
21
+ meta.difficulty = n;
22
+ }
23
+ else if (key === 'hint') {
24
+ const v = value;
25
+ if (v === 'none' || v === 'ghost')
26
+ meta.hint = v;
27
+ }
28
+ else if (key === 'type') {
29
+ if (value === 'word' || value === 'sentence' || value === 'normal') {
30
+ meta.type = value;
31
+ }
32
+ }
33
+ else if (key === 'phonetic') {
34
+ meta.phonetic = value;
35
+ }
36
+ else if (key === 'gloss') {
37
+ meta.gloss = value;
38
+ }
39
+ else if (key === 'translation') {
40
+ meta.translation = value;
41
+ }
42
+ }
43
+ return { meta, rest };
44
+ }
45
+ function parseTagList(value) {
46
+ const trimmed = value.trim();
47
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
48
+ return trimmed
49
+ .slice(1, -1)
50
+ .split(',')
51
+ .map((s) => s.trim())
52
+ .filter(Boolean);
53
+ }
54
+ return trimmed
55
+ .split(/[\s,]+/)
56
+ .map((s) => s.trim())
57
+ .filter(Boolean);
58
+ }
@@ -0,0 +1,2 @@
1
+ export * from './parseMaterial.js';
2
+ export * from './frontmatter.js';
@@ -0,0 +1,42 @@
1
+ import { parseFrontmatter } from './frontmatter.js';
2
+ import { textToCharPlans } from '../keystroke/index.js';
3
+ import { validateTypingText } from '../keystroke/textNormalize.js';
4
+ const CARD_SEPARATOR = /\n\n---\n\n/;
5
+ export function parseMaterial(source, filePath = 'inline') {
6
+ const normalized = source.replace(/\r\n/g, '\n').trim() + '\n';
7
+ const blocks = normalized
8
+ .split(CARD_SEPARATOR)
9
+ .map((s) => s.trim())
10
+ .filter(Boolean);
11
+ const cards = [];
12
+ for (let i = 0; i < blocks.length; i++) {
13
+ const card = parseCard(blocks[i], filePath, i);
14
+ if (card)
15
+ cards.push(card);
16
+ }
17
+ return { filePath, cards };
18
+ }
19
+ function parseCard(block, filePath, index) {
20
+ const { meta, rest } = parseFrontmatter(block);
21
+ const qMatch = rest.match(/^##\s*Q:?\s*([\s\S]*?)(?=\n##\s*A:?|$)/m);
22
+ const aMatch = rest.match(/\n##\s*A:?\s*([\s\S]*)$/m);
23
+ if (!qMatch || !aMatch)
24
+ return null;
25
+ const question = qMatch[1].trim();
26
+ const rawAnswer = aMatch[1].trim();
27
+ if (!question || !rawAnswer)
28
+ return null;
29
+ // Normalize the answer for typing: smart quotes/dashes rewritten, emoji &
30
+ // zero-width chars stripped. Compute the keystroke plan and list any chars
31
+ // that still don't have one so the UI can warn.
32
+ const validated = validateTypingText(rawAnswer);
33
+ const charPlans = textToCharPlans(validated.text);
34
+ return {
35
+ id: `${filePath}#${index}`,
36
+ question,
37
+ answer: validated.text,
38
+ meta,
39
+ charPlans,
40
+ unknownChars: validated.unknowns,
41
+ };
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ export function createState(target) {
2
+ return {
3
+ target,
4
+ typed: '',
5
+ cursor: 0,
6
+ correctCount: 0,
7
+ wrongCount: 0,
8
+ startedAt: null,
9
+ finishedAt: null,
10
+ composing: false,
11
+ };
12
+ }
13
+ export function beginComposition(s, now = Date.now()) {
14
+ return {
15
+ ...s,
16
+ startedAt: s.startedAt ?? now,
17
+ composing: true,
18
+ };
19
+ }
20
+ export function endComposition(s, committed, now = Date.now()) {
21
+ const next = commitChars(s, committed, now);
22
+ return { ...next, composing: false };
23
+ }
24
+ export function typeChar(s, ch, now = Date.now()) {
25
+ if (s.composing)
26
+ return s;
27
+ return commitChars(s, ch, now);
28
+ }
29
+ export function backspace(s) {
30
+ if (s.composing || s.cursor === 0)
31
+ return s;
32
+ const removed = s.typed[s.cursor - 1];
33
+ const expected = s.target[s.cursor - 1];
34
+ const wasCorrect = removed === expected;
35
+ return {
36
+ ...s,
37
+ typed: s.typed.slice(0, -1),
38
+ cursor: s.cursor - 1,
39
+ correctCount: wasCorrect ? s.correctCount - 1 : s.correctCount,
40
+ wrongCount: wasCorrect ? s.wrongCount : s.wrongCount - 1,
41
+ finishedAt: null,
42
+ };
43
+ }
44
+ function commitChars(s, chunk, now) {
45
+ if (!chunk)
46
+ return s;
47
+ let { cursor, correctCount, wrongCount, typed } = s;
48
+ const startedAt = s.startedAt ?? now;
49
+ const chars = Array.from(chunk);
50
+ for (const ch of chars) {
51
+ const expected = s.target[cursor];
52
+ if (expected === undefined)
53
+ break;
54
+ if (ch === expected)
55
+ correctCount++;
56
+ else
57
+ wrongCount++;
58
+ typed += ch;
59
+ cursor++;
60
+ }
61
+ const finishedAt = cursor >= s.target.length ? now : null;
62
+ return {
63
+ ...s,
64
+ typed,
65
+ cursor,
66
+ correctCount,
67
+ wrongCount,
68
+ startedAt,
69
+ finishedAt,
70
+ };
71
+ }
72
+ export function charStatus(s, index) {
73
+ if (index < s.cursor) {
74
+ return s.typed[index] === s.target[index] ? 'correct' : 'wrong';
75
+ }
76
+ if (index === s.cursor)
77
+ return 'current';
78
+ return 'pending';
79
+ }
80
+ // Sync the state to a full "typed so far" string (read from the contenteditable
81
+ // editor on every input event, dazidazi-style). Recomputes correct/wrong counts
82
+ // from scratch based on positional character comparison, so the caller doesn't
83
+ // need to track incremental changes.
84
+ export function syncTyped(s, typed, now = Date.now()) {
85
+ const chars = Array.from(typed);
86
+ let correctCount = 0;
87
+ let wrongCount = 0;
88
+ for (let i = 0; i < chars.length; i++) {
89
+ const expected = s.target[i];
90
+ if (expected === undefined) {
91
+ // Overflow — extra characters past target length count as wrong.
92
+ wrongCount++;
93
+ continue;
94
+ }
95
+ if (chars[i] === expected)
96
+ correctCount++;
97
+ else
98
+ wrongCount++;
99
+ }
100
+ const cursor = chars.length;
101
+ const startedAt = s.startedAt ?? (typed.length > 0 ? now : null);
102
+ const finished = cursor >= s.target.length && correctCount === s.target.length;
103
+ return {
104
+ ...s,
105
+ typed,
106
+ cursor,
107
+ correctCount,
108
+ wrongCount,
109
+ startedAt,
110
+ finishedAt: finished ? (s.finishedAt ?? now) : null,
111
+ };
112
+ }
@@ -0,0 +1,2 @@
1
+ export * from './engine.js';
2
+ export * from './metrics.js';