@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.
- package/CHANGELOG.md +97 -0
- package/CHANGELOG.zh.md +82 -0
- package/LICENSE +201 -0
- package/README.md +113 -0
- package/README.zh.md +114 -0
- package/dist/cli/bootstrap.js +190 -0
- package/dist/cli/commands/check/action.js +56 -0
- package/dist/cli/commands/check/i18n.js +26 -0
- package/dist/cli/commands/check/index.js +20 -0
- package/dist/cli/commands/config/action.js +236 -0
- package/dist/cli/commands/config/i18n.js +64 -0
- package/dist/cli/commands/config/index.js +72 -0
- package/dist/cli/commands/content/index.js +22 -0
- package/dist/cli/commands/content/lint/action.js +102 -0
- package/dist/cli/commands/content/lint/i18n.js +32 -0
- package/dist/cli/commands/content/lint/index.js +19 -0
- package/dist/cli/commands/content/search/action.js +64 -0
- package/dist/cli/commands/content/search/i18n.js +20 -0
- package/dist/cli/commands/content/search/index.js +24 -0
- package/dist/cli/commands/content/validate/action.js +145 -0
- package/dist/cli/commands/content/validate/i18n.js +32 -0
- package/dist/cli/commands/content/validate/index.js +19 -0
- package/dist/cli/commands/index.js +19 -0
- package/dist/cli/commands/init/index.js +53 -0
- package/dist/cli/commands/pack/i18n.js +20 -0
- package/dist/cli/commands/pack/index.js +48 -0
- package/dist/cli/commands/plugin/i18n.js +28 -0
- package/dist/cli/commands/plugin/index.js +41 -0
- package/dist/cli/commands/publish/build/action.js +208 -0
- package/dist/cli/commands/publish/build/i18n.js +50 -0
- package/dist/cli/commands/publish/build/index.js +24 -0
- package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
- package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
- package/dist/cli/commands/publish/index.js +21 -0
- package/dist/cli/commands/publish/init/action.js +74 -0
- package/dist/cli/commands/publish/init/i18n.js +22 -0
- package/dist/cli/commands/publish/init/index.js +26 -0
- package/dist/cli/commands/publish/password/action.js +158 -0
- package/dist/cli/commands/publish/password/i18n.js +44 -0
- package/dist/cli/commands/publish/password/index.js +35 -0
- package/dist/cli/commands/publish/serve/action.js +117 -0
- package/dist/cli/commands/publish/serve/i18n.js +32 -0
- package/dist/cli/commands/publish/serve/index.js +37 -0
- package/dist/cli/commands/repo/index.js +19 -0
- package/dist/cli/commands/repo/new/action.js +98 -0
- package/dist/cli/commands/repo/new/i18n.js +46 -0
- package/dist/cli/commands/repo/new/index.js +57 -0
- package/dist/cli/commands/repo/sync/action.js +202 -0
- package/dist/cli/commands/repo/sync/i18n.js +44 -0
- package/dist/cli/commands/repo/sync/index.js +47 -0
- package/dist/cli/commands/repo/templates.js +87 -0
- package/dist/cli/i18n/index.js +141 -0
- package/dist/cli/i18n/shared.js +142 -0
- package/dist/cli/index.js +79 -0
- package/dist/cli/registry/command.js +1 -0
- package/dist/cli/registry/commander-i18n.js +76 -0
- package/dist/cli/registry/help.js +142 -0
- package/dist/cli/registry/mount.js +71 -0
- package/dist/cli/utils/output.js +92 -0
- package/dist/cli/utils/paths.js +13 -0
- package/dist/cli/utils/prompt.js +72 -0
- package/dist/cli/utils/repoGuard.js +41 -0
- package/dist/core/encrypt.js +113 -0
- package/dist/core/index.js +8 -0
- package/dist/core/keystroke/asciiMap.js +39 -0
- package/dist/core/keystroke/chineseMap.js +32 -0
- package/dist/core/keystroke/fullwidthMap.js +41 -0
- package/dist/core/keystroke/index.js +22 -0
- package/dist/core/keystroke/textNormalize.js +68 -0
- package/dist/core/keystroke/types.js +1 -0
- package/dist/core/parser/frontmatter.js +58 -0
- package/dist/core/parser/index.js +2 -0
- package/dist/core/parser/parseMaterial.js +42 -0
- package/dist/core/types.js +1 -0
- package/dist/core/typing/engine.js +112 -0
- package/dist/core/typing/index.js +2 -0
- package/dist/core/typing/metrics.js +20 -0
- package/dist/store/index.js +44 -0
- package/dist/store/kv.js +276 -0
- package/dist/store/storage/browser.js +61 -0
- package/dist/store/storage/json.js +113 -0
- package/dist/store/storage/memory.js +24 -0
- package/dist/store/types.js +47 -0
- package/dist/store/validators.js +71 -0
- package/dist/view/assets/index-DBABH3dG.js +111 -0
- package/dist/view/assets/index-RTLZ-zOC.css +1 -0
- package/dist/view/index.html +14 -0
- package/dist/view/qtype-logo.svg +49 -0
- package/dist/view/qtype-mark.svg +21 -0
- package/docs/README.md +23 -0
- package/docs/README.zh.md +20 -0
- package/docs/en/authoring.md +138 -0
- package/docs/en/cli-reference.md +171 -0
- package/docs/zh/authoring.md +131 -0
- package/docs/zh/cli-reference.md +162 -0
- package/package.json +85 -0
- package/templates/en/QTYPE.md +618 -0
- package/templates/en/README.md +64 -0
- package/templates/en/TODO.md +7 -0
- package/templates/en/index.md +7 -0
- package/templates/en/log.md +3 -0
- package/templates/en/material/.gitkeep +0 -0
- package/templates/en/material/example.md +47 -0
- package/templates/zh/QTYPE.md +618 -0
- package/templates/zh/README.md +64 -0
- package/templates/zh/TODO.md +7 -0
- package/templates/zh/index.md +7 -0
- package/templates/zh/log.md +3 -0
- package/templates/zh/material/.gitkeep +0 -0
- 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,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
|
+
}
|