learn-loops 0.0.0-stage → 0.4.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/README.md +63 -2
- package/dist/cli.js +250 -0
- package/dist/diagram.js +49 -0
- package/dist/figures.js +200 -0
- package/dist/flows/learn.js +344 -0
- package/dist/flows/onboard.js +124 -0
- package/dist/flows/quiz.js +175 -0
- package/dist/flows/think.js +120 -0
- package/dist/llm.js +87 -0
- package/dist/markdown.js +136 -0
- package/dist/math.js +307 -0
- package/dist/prompts.js +149 -0
- package/dist/srs.js +50 -0
- package/dist/stats.js +48 -0
- package/dist/store.js +88 -0
- package/dist/termimg.js +27 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +185 -0
- package/package.json +45 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { askJson } from "../llm.js";
|
|
3
|
+
import { LENSES, challengePrompt, critiquePrompt, deeperPrompt, diagnoseQuestionsPrompt, diagnoseResultPrompt } from "../prompts.js";
|
|
4
|
+
import { m, must, renderMd, t, withSpinner } from "../ui.js";
|
|
5
|
+
import { runChecks } from "./quiz.js";
|
|
6
|
+
const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
7
|
+
const validateCritique = (x) => ({ strong: arr(x.strong), blindSpots: arr(x.blindSpots), pushback: String(x.pushback ?? ""), feedback: String(x.feedback ?? "") });
|
|
8
|
+
function showCritique(c) {
|
|
9
|
+
p.note([m(c.feedback), "", ...c.strong.map((s) => `${t.ok("✓")} ${m(s)}`), ...c.blindSpots.map((s) => `${t.warn("△ overlooked:")} ${m(s)}`), ...(c.pushback ? ["", `${t.pink("Push back:")} ${m(c.pushback)}`] : [])].join("\n"), "Thinking check");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Trade-offs, failure modes, scale and second-order effects. Round one uses the lesson's own question;
|
|
13
|
+
* further rounds rotate through different lenses so the learner practises seeing a topic from many sides.
|
|
14
|
+
*/
|
|
15
|
+
export async function thinkDeeper(profile, s, title, seed, maxRounds = 3) {
|
|
16
|
+
const rounds = [];
|
|
17
|
+
let lensIdx = Math.floor(Math.random() * LENSES.length);
|
|
18
|
+
let current = seed ? { lens: "Think deeper", ...seed } : undefined;
|
|
19
|
+
for (let r = 0; r < maxRounds; r++) {
|
|
20
|
+
if (!current) {
|
|
21
|
+
const lens = LENSES[lensIdx % LENSES.length];
|
|
22
|
+
const q = await withSpinner(`Finding a new angle (${lens.label.toLowerCase()})...`, () => askJson(profile, deeperPrompt(profile, s, title, lens, rounds.map((x) => x.question)), (x) => ({ question: String(x.question), considerations: arr(x.considerations) })));
|
|
23
|
+
current = { lens: lens.label, ...q };
|
|
24
|
+
}
|
|
25
|
+
p.log.step(t.bold(t.accent(`Think deeper · ${current.lens}`)));
|
|
26
|
+
const answer = must(await p.text({ message: m(current.question), placeholder: "no single right answer: weigh both sides, name what could go wrong (blank to skip)" })).trim();
|
|
27
|
+
if (!answer)
|
|
28
|
+
break;
|
|
29
|
+
const c = await withSpinner("Weighing your reasoning...", () => askJson(profile, critiquePrompt(profile, title, current.question, current.considerations, answer), validateCritique));
|
|
30
|
+
showCritique(c);
|
|
31
|
+
rounds.push({ lens: current.lens, question: current.question, answer, feedback: c.feedback, blindSpots: c.blindSpots });
|
|
32
|
+
// Let them answer the pushback, which is the real second-order step.
|
|
33
|
+
if (c.pushback) {
|
|
34
|
+
const reply = must(await p.text({ message: `${t.pink("Your response to the push back?")}`, placeholder: "optional (blank to skip)" })).trim();
|
|
35
|
+
if (reply)
|
|
36
|
+
rounds[rounds.length - 1].answer += `\n\n[Push back: ${c.pushback}]\n${reply}`;
|
|
37
|
+
}
|
|
38
|
+
if (r === maxRounds - 1)
|
|
39
|
+
break;
|
|
40
|
+
lensIdx++;
|
|
41
|
+
const next = LENSES[lensIdx % LENSES.length];
|
|
42
|
+
const more = must(await p.select({
|
|
43
|
+
message: "Keep stretching your thinking?",
|
|
44
|
+
options: [
|
|
45
|
+
{ value: true, label: `Another angle: ${next.label}` },
|
|
46
|
+
{ value: false, label: "That's enough" },
|
|
47
|
+
],
|
|
48
|
+
}));
|
|
49
|
+
if (!more)
|
|
50
|
+
break;
|
|
51
|
+
current = undefined;
|
|
52
|
+
}
|
|
53
|
+
return rounds;
|
|
54
|
+
}
|
|
55
|
+
/** End-of-area scenario with no single right answer: apply what you learned and weigh trade-offs. */
|
|
56
|
+
export async function applicationChallenge(profile, s, area, covered) {
|
|
57
|
+
const ch = await withSpinner("Designing a real-world challenge...", () => askJson(profile, challengePrompt(profile, s, area, covered), (x) => ({ scenario: String(x.scenario), task: String(x.task), considerations: arr(x.considerations) })));
|
|
58
|
+
p.note(`${m(ch.scenario)}\n\n${t.bold("Your task:")} ${m(ch.task)}`, `Challenge · ${area.title}`);
|
|
59
|
+
const answer = must(await p.text({ message: "What would you do, and why? What could go wrong?", placeholder: "reason it through (blank to skip)" })).trim();
|
|
60
|
+
if (!answer)
|
|
61
|
+
return null;
|
|
62
|
+
const c = await withSpinner("Reviewing your reasoning...", () => askJson(profile, critiquePrompt(profile, area.title, ch.task, ch.considerations, answer), validateCritique));
|
|
63
|
+
showCritique(c);
|
|
64
|
+
return { ...ch, answer, critique: c };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* When a learner keeps missing, find the mental model that's wrong instead of repeating the lesson.
|
|
68
|
+
* Returns the logged mistake (with the learner's corrected idea in their own words).
|
|
69
|
+
*/
|
|
70
|
+
export async function diagnose(profile, title, missed, explanationFeedback) {
|
|
71
|
+
const questions = await withSpinner("Working out where the confusion is...", () => askJson(profile, diagnoseQuestionsPrompt(profile, title, missed.map((m) => m.q), explanationFeedback), (x) => arr(x.questions).slice(0, 2)));
|
|
72
|
+
const qa = [];
|
|
73
|
+
for (const q of questions) {
|
|
74
|
+
const a = must(await p.text({ message: `${t.pink("Quick question")} — ${m(q)}`, placeholder: "just say what you picture or believe (blank to skip)" })).trim();
|
|
75
|
+
qa.push({ q, a });
|
|
76
|
+
}
|
|
77
|
+
const res = await withSpinner("Finding the misconception...", () => askJson(profile, diagnoseResultPrompt(profile, title, qa, missed.map((m) => m.q)), (x) => {
|
|
78
|
+
const rt = x.retest;
|
|
79
|
+
if (!rt || !Array.isArray(rt.options) || !Number.isInteger(rt.answer) || rt.answer < 0 || rt.answer >= rt.options.length)
|
|
80
|
+
throw new Error("bad retest");
|
|
81
|
+
return {
|
|
82
|
+
misconception: String(x.misconception),
|
|
83
|
+
reteach: String(x.reteach),
|
|
84
|
+
retest: { q: String(rt.q), options: rt.options.map(String), answer: rt.answer, explain: String(rt.explain ?? "") },
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
87
|
+
p.note(`${t.warn("Likely snag:")} ${m(res.misconception)}`, "Diagnosis");
|
|
88
|
+
console.log(renderMd(res.reteach) + "\n");
|
|
89
|
+
await runChecks([res.retest]);
|
|
90
|
+
const inMyWords = must(await p.text({ message: `${t.pink("Say the corrected idea in your own words")}`, placeholder: "one or two sentences (blank to skip)" })).trim();
|
|
91
|
+
return {
|
|
92
|
+
lesson: title,
|
|
93
|
+
kind: "diagnosis",
|
|
94
|
+
what: res.misconception,
|
|
95
|
+
correction: res.reteach.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("`")) ?? res.misconception,
|
|
96
|
+
inMyWords: inMyWords || undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Socratic opener: leading questions the learner works through before the explanation. */
|
|
100
|
+
export async function socraticWarmup(items) {
|
|
101
|
+
p.log.message(t.dim("Socratic mode: work toward the idea yourself first. Answers needn't be right."));
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [i, item] of items.entries()) {
|
|
104
|
+
let answer = must(await p.text({ message: `${t.pink(`${i + 1}/${items.length}`)} ${m(item.q)}`, placeholder: "think out loud (type ? for a hint, blank to skip)" })).trim();
|
|
105
|
+
if (answer === "?") {
|
|
106
|
+
p.log.info(`${t.warn("Hint:")} ${m(item.hint)}`);
|
|
107
|
+
answer = must(await p.text({ message: item.q, placeholder: "try again with the hint (blank to skip)" })).trim();
|
|
108
|
+
}
|
|
109
|
+
p.log.message(`${t.accent("→")} ${m(item.idea)}`);
|
|
110
|
+
out.push({ q: item.q, answer });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** Predict-then-reveal: a wrong guess followed by the truth is memorable. */
|
|
115
|
+
export async function predictThenReveal(pred) {
|
|
116
|
+
p.note(m(pred.scenario), t.pink("Predict"));
|
|
117
|
+
const guess = must(await p.text({ message: m(pred.question), placeholder: "commit to a guess before the reveal (blank to skip)" })).trim();
|
|
118
|
+
p.log.message(`${t.accent("Reveal:")} ${m(pred.outcome)}`);
|
|
119
|
+
return guess;
|
|
120
|
+
}
|
package/dist/llm.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export const TUTOR_SYSTEM = [
|
|
6
|
+
"You are an exceptional tutor grounded in learning science: you activate prior knowledge,",
|
|
7
|
+
"teach in small chunks, use concrete examples and diagrams, test retrieval rather than recognition,",
|
|
8
|
+
"and correct misconceptions precisely. You never pad. You are accurate: if unsure, say so.",
|
|
9
|
+
"You output exactly what is asked for, in the requested format, with no preamble.",
|
|
10
|
+
].join(" ");
|
|
11
|
+
function run(cmd, args, input, timeoutMs) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(cmd, args, { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
|
|
14
|
+
let out = "";
|
|
15
|
+
let err = "";
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
child.kill("SIGKILL");
|
|
18
|
+
reject(new Error(`${cmd} timed out after ${timeoutMs / 1000}s`));
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
child.stdout.on("data", (d) => (out += d));
|
|
21
|
+
child.stderr.on("data", (d) => (err += d));
|
|
22
|
+
child.on("error", (e) => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH? (${e.message})`));
|
|
25
|
+
});
|
|
26
|
+
child.on("close", (code) => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
if (code === 0)
|
|
29
|
+
resolve(out);
|
|
30
|
+
else
|
|
31
|
+
reject(new Error(`${cmd} exited with ${code}: ${err.trim().split("\n").slice(-4).join("\n")}`));
|
|
32
|
+
});
|
|
33
|
+
child.stdin.end(input);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function complete(provider, prompt, timeoutMs = 240_000) {
|
|
37
|
+
if (provider === "claude") {
|
|
38
|
+
return run("claude", ["-p", "--tools", "", "--no-session-persistence", "--output-format", "text", "--system-prompt", TUTOR_SYSTEM], prompt, timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
const dir = await mkdtemp(join(tmpdir(), "loops-"));
|
|
41
|
+
const outFile = join(dir, "last.txt");
|
|
42
|
+
try {
|
|
43
|
+
await run("codex", ["exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only", "-o", outFile, "-"], `${TUTOR_SYSTEM}\n\n${prompt}`, timeoutMs);
|
|
44
|
+
return await readFile(outFile, "utf8");
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await rm(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
|
|
52
|
+
* tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
|
|
53
|
+
* known command that collides with a JSON escape, and any lone backslash that isn't a valid escape at all.
|
|
54
|
+
*/
|
|
55
|
+
const COLLIDING = "theta|tau|times|text|textbf|textit|textrm|tan|tanh|tilde|to|top|triangle|therefore|tfrac|thinspace|frac|forall|flat|beta|bar|begin|boldsymbol|bot|binom|big|bigg|Big|Bigg|bmod|bullet|bigcup|bigcap|bigoplus|bf|rho|right|rightarrow|rangle|rbrace|rceil|rfloor|rm|nabla|neq|nu|ne|not|notin|newcommand|ni|neg|nleq|ngeq|nmid|nearrow";
|
|
56
|
+
export function repairLatexEscapes(json) {
|
|
57
|
+
const re = new RegExp(`\\\\\\\\|\\\\(?:(?=(?:${COLLIDING})(?![a-zA-Z]))|u(?![0-9a-fA-F]{4})|(?![\\\\"/bfnrtu]))`, "g");
|
|
58
|
+
return json.replace(re, (m) => (m === "\\\\" ? m : "\\\\" + m.slice(1)));
|
|
59
|
+
}
|
|
60
|
+
export function extractJson(text) {
|
|
61
|
+
// Slice between the outermost braces; an outer ```json fence falls outside them. Don't strip
|
|
62
|
+
// backticks globally: lesson markdown inside the JSON contains ```mermaid fences.
|
|
63
|
+
const start = text.indexOf("{");
|
|
64
|
+
const end = text.lastIndexOf("}");
|
|
65
|
+
if (start === -1 || end <= start)
|
|
66
|
+
throw new Error("no JSON object in model output");
|
|
67
|
+
return JSON.parse(repairLatexEscapes(text.slice(start, end + 1)));
|
|
68
|
+
}
|
|
69
|
+
/** Ask for JSON, validate it, and retry once telling the model what was wrong. */
|
|
70
|
+
export async function askJson(profile, prompt, validate) {
|
|
71
|
+
let lastError = "";
|
|
72
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
73
|
+
const full = attempt === 0 ? prompt : `${prompt}\n\nYour previous reply was rejected: ${lastError}\nReturn ONLY valid JSON this time.`;
|
|
74
|
+
try {
|
|
75
|
+
return validate(extractJson(await complete(profile.provider, full)));
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
lastError = e.message;
|
|
79
|
+
if (attempt === 1 || /not installed|PATH|timed out/.test(lastError))
|
|
80
|
+
throw new Error(lastError);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(lastError);
|
|
84
|
+
}
|
|
85
|
+
export async function askText(profile, prompt) {
|
|
86
|
+
return (await complete(profile.provider, prompt)).trim();
|
|
87
|
+
}
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { dirname, join, relative } from "node:path";
|
|
2
|
+
import { writeText } from "./store.js";
|
|
3
|
+
import { parsePlot, plotToSvg, sanitizeSvg, svgCaption } from "./figures.js";
|
|
4
|
+
/** Strip characters that break mermaid mindmap / quoted labels. */
|
|
5
|
+
export const clean = (s) => s.replace(/["'`()\[\]{}:;#<>]/g, "").replace(/\s+/g, " ").trim();
|
|
6
|
+
export const nodeId = (s) => "n_" + s.replace(/[^a-zA-Z0-9]/g, "_");
|
|
7
|
+
export function mapMarkdown(topic) {
|
|
8
|
+
const { title, map } = topic.state;
|
|
9
|
+
if (!map)
|
|
10
|
+
return "";
|
|
11
|
+
const mind = [
|
|
12
|
+
"```mermaid",
|
|
13
|
+
"mindmap",
|
|
14
|
+
` root((${clean(title)}))`,
|
|
15
|
+
...map.areas.flatMap((a) => [` ${clean(a.title)}`, ...a.concepts.map((c) => ` ${clean(c.name)}`)]),
|
|
16
|
+
"```",
|
|
17
|
+
].join("\n");
|
|
18
|
+
const edges = map.areas.flatMap((a) => a.prereqs.filter((p) => map.areas.some((x) => x.id === p)).map((p) => ` ${nodeId(p)} --> ${nodeId(a.id)}`));
|
|
19
|
+
const flow = [
|
|
20
|
+
"```mermaid",
|
|
21
|
+
"flowchart LR",
|
|
22
|
+
...map.areas.map((a) => ` ${nodeId(a.id)}["${clean(a.title)}"]`),
|
|
23
|
+
...edges,
|
|
24
|
+
"```",
|
|
25
|
+
].join("\n");
|
|
26
|
+
return `# ${title} — map
|
|
27
|
+
|
|
28
|
+
${map.overview}
|
|
29
|
+
|
|
30
|
+
## The territory
|
|
31
|
+
|
|
32
|
+
${mind}
|
|
33
|
+
|
|
34
|
+
## Suggested order
|
|
35
|
+
|
|
36
|
+
Arrows mean "understand this first".
|
|
37
|
+
|
|
38
|
+
${flow}
|
|
39
|
+
|
|
40
|
+
## Areas
|
|
41
|
+
|
|
42
|
+
${map.areas.map((a) => `### ${a.title}\n${a.summary}\n\n${a.concepts.map((c) => `- **${c.name}** — ${c.blurb}`).join("\n")}`).join("\n\n")}
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
export function lessonMarkdown(title, lesson, x = {}) {
|
|
46
|
+
const quote = (s) => s.split("\n").map((l) => "> " + l).join("\n");
|
|
47
|
+
const checks = lesson.checks
|
|
48
|
+
.map((c, i) => `${i + 1}. ${c.q}\n${c.options.map((o, j) => ` - ${j === c.answer ? "✅" : "▫️"} ${o}`).join("\n")}\n - *${c.explain}*`)
|
|
49
|
+
.join("\n");
|
|
50
|
+
const section = (heading, body) => (body.trim() ? `\n## ${heading}\n\n${body}\n` : "");
|
|
51
|
+
const socratic = x.socratic?.length ? section("Questions I worked through first", x.socratic.map((s) => `**${s.q}**\n\n${quote(s.answer || "(skipped)")}`).join("\n\n")) : "";
|
|
52
|
+
const prediction = lesson.prediction ? section("Predict, then reveal", `**${lesson.prediction.scenario}**\n\n${lesson.prediction.question}\n\n${quote(x.prediction || "(skipped)")}\n\n**What happened:** ${lesson.prediction.outcome}`) : "";
|
|
53
|
+
const own = x.attempts?.length
|
|
54
|
+
? section("In my own words", `> ${lesson.teachBack}\n\n` +
|
|
55
|
+
x.attempts.map((o, i) => `**Attempt ${i + 1}** — ${o.grade.score}/3\n\n${quote(o.answer)}\n\n${o.grade.feedback}${o.grade.missing.length ? `\n\nMissing: ${o.grade.missing.join("; ")}` : ""}`).join("\n\n"))
|
|
56
|
+
: "";
|
|
57
|
+
const deeper = x.deeper?.length
|
|
58
|
+
? section("Thinking deeper", x.deeper.map((d) => `**${d.lens}: ${d.question}**\n\n${quote(d.answer)}\n\n${d.feedback}${d.blindSpots.length ? `\n\nBlind spots to revisit:\n${d.blindSpots.map((b) => `- ${b}`).join("\n")}` : ""}`).join("\n\n---\n\n"))
|
|
59
|
+
: "";
|
|
60
|
+
const connection = x.connection ? section("Where I could use this", quote(x.connection)) : "";
|
|
61
|
+
return `# ${title}
|
|
62
|
+
|
|
63
|
+
${lesson.markdown}
|
|
64
|
+
${socratic}${prediction}
|
|
65
|
+
## Key points
|
|
66
|
+
|
|
67
|
+
${lesson.keyPoints.map((k) => `- ${k}`).join("\n")}
|
|
68
|
+
|
|
69
|
+
## Check yourself
|
|
70
|
+
|
|
71
|
+
${checks}
|
|
72
|
+
${x.missed?.length ? `\n**Still shaky:** ${x.missed.join("; ")}\n` : ""}${own}${deeper}${connection}
|
|
73
|
+
## Flashcards
|
|
74
|
+
|
|
75
|
+
${lesson.cards.map((c) => `- **${c.front}** → ${c.back}`).join("\n")}
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
export function mistakesMarkdown(topic) {
|
|
79
|
+
const list = topic.state.mistakes ?? [];
|
|
80
|
+
const kind = { quiz: "Missed a question", overconfident: "Sure, but wrong", explanation: "Gap in my explanation", diagnosis: "Misconception" };
|
|
81
|
+
const byLesson = new Map();
|
|
82
|
+
for (const m of list)
|
|
83
|
+
byLesson.set(m.lesson, [...(byLesson.get(m.lesson) ?? []), m]);
|
|
84
|
+
const body = [...byLesson.entries()]
|
|
85
|
+
.map(([lesson, ms]) => `## ${lesson}\n\n${ms.map((m) => `- **${kind[m.kind]}** (${m.date}): ${m.what}\n - Correct idea: ${m.correction}${m.inMyWords ? `\n - In my words: *${m.inMyWords}*` : ""}`).join("\n")}`)
|
|
86
|
+
.join("\n\n");
|
|
87
|
+
return `# Mistake journal — ${topic.state.title}\n\nThe fastest way to improve is to understand exactly what you got wrong.\n\n${body || "_Nothing yet._"}\n`;
|
|
88
|
+
}
|
|
89
|
+
export async function writeIndex(topic) {
|
|
90
|
+
const { state } = topic;
|
|
91
|
+
const lines = [`# ${state.title}`, "", state.map?.overview ?? "", "", "- [Map of the territory](map.md)", ...(state.mistakes?.length ? ["- [Mistake journal](mistakes.md)"] : []), ""];
|
|
92
|
+
if (state.map) {
|
|
93
|
+
lines.push("## Progress", "");
|
|
94
|
+
for (const a of state.map.areas) {
|
|
95
|
+
const pr = state.progress[a.id];
|
|
96
|
+
const done = pr?.done.length ?? 0;
|
|
97
|
+
const total = pr?.outline?.length ?? 0;
|
|
98
|
+
const surveyed = pr?.surveyed ? "✅" : "⬜";
|
|
99
|
+
lines.push(`- ${surveyed} **${a.title}** — survey: [overview](survey/${a.id}.md)${total ? ` · deep dive ${done}/${total}: ${(pr.outline ?? []).map((l, i) => (pr.done.includes(l.id) ? `[${l.title}](deep/${a.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md)` : l.title)).join(", ")}` : ""}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
lines.push("", `_${state.cards.length} flashcards tracked in topic.json_`, "");
|
|
103
|
+
await writeText(join(topic.dir, "README.md"), lines.join("\n"));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Lessons embed ```svg and ```plot blocks. In the saved markdown they become real .svg files under
|
|
107
|
+
* figures/ and standard image links, which GitHub, VS Code and Obsidian all display. Equations stay as
|
|
108
|
+
* $$ TeX $$ (rendered by those viewers); mermaid stays fenced.
|
|
109
|
+
*/
|
|
110
|
+
export async function materializeFigures(md, topicDir, lessonFile) {
|
|
111
|
+
const base = lessonFile.replace(/\.md$/, "").replace(/[^a-z0-9]+/gi, "-");
|
|
112
|
+
const jobs = [];
|
|
113
|
+
let n = 0;
|
|
114
|
+
const out = md.replace(/```(svg|plot)\n([\s\S]*?)```/g, (_, kind, src) => {
|
|
115
|
+
let svg = null;
|
|
116
|
+
let caption = "";
|
|
117
|
+
if (kind === "svg") {
|
|
118
|
+
svg = sanitizeSvg(src);
|
|
119
|
+
caption = svg ? svgCaption(svg) : "";
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const spec = parsePlot(src);
|
|
123
|
+
svg = spec && plotToSvg(spec);
|
|
124
|
+
caption = spec?.title ?? "";
|
|
125
|
+
}
|
|
126
|
+
if (!svg)
|
|
127
|
+
return "> *(a figure here could not be rendered)*";
|
|
128
|
+
const file = `figures/${base}-${++n}.svg`;
|
|
129
|
+
jobs.push({ token: file, file, svg, caption });
|
|
130
|
+
const rel = relative(dirname(join(topicDir, lessonFile)), join(topicDir, file));
|
|
131
|
+
return ``;
|
|
132
|
+
});
|
|
133
|
+
for (const j of jobs)
|
|
134
|
+
await writeText(join(topicDir, j.file), j.svg);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
package/dist/math.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
let converter;
|
|
5
|
+
/** MathJax in pure Node (no DOM, no browser): TeX with physics, mhchem, ams, cancel. */
|
|
6
|
+
function mathjax() {
|
|
7
|
+
if (converter)
|
|
8
|
+
return converter;
|
|
9
|
+
const { mathjax } = require("mathjax-full/js/mathjax.js");
|
|
10
|
+
const { TeX } = require("mathjax-full/js/input/tex.js");
|
|
11
|
+
const { SVG } = require("mathjax-full/js/output/svg.js");
|
|
12
|
+
const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js");
|
|
13
|
+
const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js");
|
|
14
|
+
// Each TeX package must be loaded (registered) before the TeX input jax can use it.
|
|
15
|
+
for (const cfg of ["ams/AmsConfiguration", "newcommand/NewcommandConfiguration", "physics/PhysicsConfiguration", "mhchem/MhchemConfiguration", "cancel/CancelConfiguration", "boldsymbol/BoldsymbolConfiguration", "noundefined/NoUndefinedConfiguration"]) {
|
|
16
|
+
require(`mathjax-full/js/input/tex/${cfg}.js`);
|
|
17
|
+
}
|
|
18
|
+
const adaptor = liteAdaptor();
|
|
19
|
+
RegisterHTMLHandler(adaptor);
|
|
20
|
+
const doc = mathjax.document("", {
|
|
21
|
+
InputJax: new TeX({ packages: ["base", "ams", "newcommand", "physics", "mhchem", "cancel", "boldsymbol", "noundefined"] }),
|
|
22
|
+
OutputJax: new SVG({ fontCache: "none" }),
|
|
23
|
+
});
|
|
24
|
+
converter = (tex, display) => {
|
|
25
|
+
const html = adaptor.innerHTML(doc.convert(tex, { display }));
|
|
26
|
+
if (/data-mjx-error|<merror/.test(html))
|
|
27
|
+
throw new Error("TeX error");
|
|
28
|
+
return html.replace(/^<mjx-container[^>]*>/, "").replace(/<\/mjx-container>$/, "");
|
|
29
|
+
};
|
|
30
|
+
return converter;
|
|
31
|
+
}
|
|
32
|
+
const PX_PER_EX = 9;
|
|
33
|
+
/** TeX → standalone SVG with pixel sizes (resvg doesn't understand ex), padded on white. */
|
|
34
|
+
export function texToSvg(tex, display = true) {
|
|
35
|
+
const inner = mathjax()(tex, display);
|
|
36
|
+
const w = parseFloat(inner.match(/width="([\d.]+)ex"/)?.[1] ?? "10") * PX_PER_EX;
|
|
37
|
+
const h = parseFloat(inner.match(/height="([\d.]+)ex"/)?.[1] ?? "2") * PX_PER_EX;
|
|
38
|
+
const body = inner
|
|
39
|
+
.replace(/ style="[^"]*"/, "")
|
|
40
|
+
.replace(/width="[\d.]+ex"/, `width="${w}"`)
|
|
41
|
+
.replace(/height="[\d.]+ex"/, `height="${h}"`)
|
|
42
|
+
.replace("<svg ", '<svg color="#1a1a2e" ');
|
|
43
|
+
const pad = 10;
|
|
44
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w + 2 * pad}" height="${h + 2 * pad}" viewBox="0 0 ${w + 2 * pad} ${h + 2 * pad}"><rect width="100%" height="100%" rx="8" fill="#ffffff"/><g transform="translate(${pad} ${pad})">${body}</g></svg>`;
|
|
45
|
+
}
|
|
46
|
+
export function svgToPng(svg, zoom = 2.5) {
|
|
47
|
+
const r = new Resvg(svg, {
|
|
48
|
+
background: "#ffffff",
|
|
49
|
+
fitTo: { mode: "zoom", value: zoom },
|
|
50
|
+
font: { loadSystemFonts: true, defaultFontFamily: "Helvetica" },
|
|
51
|
+
});
|
|
52
|
+
const out = r.render();
|
|
53
|
+
return { png: Buffer.from(out.asPng()), width: Math.round(out.width / zoom) };
|
|
54
|
+
}
|
|
55
|
+
export const texToPng = (tex, display = true) => svgToPng(texToSvg(tex, display), 3);
|
|
56
|
+
// ---------------------------------------------------------------------------------------------
|
|
57
|
+
// Unicode fallback: a small TeX layout engine for terminals without image support.
|
|
58
|
+
// ---------------------------------------------------------------------------------------------
|
|
59
|
+
const unicodeit = require("unicodeit");
|
|
60
|
+
const width = (s) => [...s.replace(/\p{M}/gu, "")].length;
|
|
61
|
+
const pad = (s, n, side = "r") => {
|
|
62
|
+
const gap = Math.max(0, n - width(s));
|
|
63
|
+
return side === "r" ? s + " ".repeat(gap) : side === "l" ? " ".repeat(gap) + s : " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
|
|
64
|
+
};
|
|
65
|
+
const txt = (s) => ({ l: [s], b: 0 });
|
|
66
|
+
const boxW = (b) => Math.max(...b.l.map(width));
|
|
67
|
+
function hcat(boxes) {
|
|
68
|
+
const live = boxes.filter((b) => b.l.length);
|
|
69
|
+
if (!live.length)
|
|
70
|
+
return txt("");
|
|
71
|
+
const above = Math.max(...live.map((b) => b.b));
|
|
72
|
+
const below = Math.max(...live.map((b) => b.l.length - b.b - 1));
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (let r = 0; r < above + below + 1; r++) {
|
|
75
|
+
rows.push(live.map((b) => pad(b.l[r - (above - b.b)] ?? "", boxW(b))).join(""));
|
|
76
|
+
}
|
|
77
|
+
return { l: rows, b: above };
|
|
78
|
+
}
|
|
79
|
+
function frac(n, d) {
|
|
80
|
+
const w = Math.max(boxW(n), boxW(d)) + 2;
|
|
81
|
+
return { l: [...n.l.map((s) => pad(s, w, "c")), "─".repeat(w), ...d.l.map((s) => pad(s, w, "c"))], b: n.l.length };
|
|
82
|
+
}
|
|
83
|
+
function sqrtBox(x) {
|
|
84
|
+
const w = boxW(x);
|
|
85
|
+
const rows = x.l.map((s, i) => (i === 0 ? "√" : " ") + pad(s, w));
|
|
86
|
+
return { l: [" " + "‾".repeat(w), ...rows.map((r, i) => (i === 0 ? "√" + r.slice(1) : r))], b: x.b + 1 };
|
|
87
|
+
}
|
|
88
|
+
const SUP = "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ";
|
|
89
|
+
const SUB = "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓᵢⱼₙₖₗₘₚₛₜ";
|
|
90
|
+
const RELATIONS = new Set(["=", "≈", "≠", "≤", "≥", "→", "←", "↔", "⇒", "∝", "∼", "≡", "∈", "⊂", "⊆"]);
|
|
91
|
+
const BIG_OPS = { sum: "∑", prod: "∏", int: "∫", oint: "∮", lim: "lim" };
|
|
92
|
+
/** Try to write `s` entirely as unicode super/subscript characters. */
|
|
93
|
+
function script(kind, s) {
|
|
94
|
+
const out = unicodeit.replace(`${kind}{${s}}`);
|
|
95
|
+
return out && !/[\^_{}\\]/.test(out) ? out : null;
|
|
96
|
+
}
|
|
97
|
+
class Parser {
|
|
98
|
+
s;
|
|
99
|
+
inline;
|
|
100
|
+
i = 0;
|
|
101
|
+
constructor(s, inline) {
|
|
102
|
+
this.s = s;
|
|
103
|
+
this.inline = inline;
|
|
104
|
+
}
|
|
105
|
+
group() {
|
|
106
|
+
// consumes one TeX argument: {…} or a single token
|
|
107
|
+
this.ws();
|
|
108
|
+
if (this.s[this.i] === "{") {
|
|
109
|
+
this.i++;
|
|
110
|
+
const b = this.seq("}");
|
|
111
|
+
this.i++;
|
|
112
|
+
return b;
|
|
113
|
+
}
|
|
114
|
+
if (this.s[this.i] === "\\")
|
|
115
|
+
return this.token();
|
|
116
|
+
return txt(this.s[this.i++] ?? "");
|
|
117
|
+
}
|
|
118
|
+
raw() {
|
|
119
|
+
// raw text of one argument (for \text, \mathrm, accents …)
|
|
120
|
+
this.ws();
|
|
121
|
+
if (this.s[this.i] !== "{")
|
|
122
|
+
return this.s[this.i++] ?? "";
|
|
123
|
+
let depth = 0;
|
|
124
|
+
const start = this.i + 1;
|
|
125
|
+
for (; this.i < this.s.length; this.i++) {
|
|
126
|
+
if (this.s[this.i] === "{")
|
|
127
|
+
depth++;
|
|
128
|
+
else if (this.s[this.i] === "}" && --depth === 0)
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return this.s.slice(start, this.i++);
|
|
132
|
+
}
|
|
133
|
+
/** Raw TeX of one argument: {balanced}, a \\command, or a single character. */
|
|
134
|
+
rawArg() {
|
|
135
|
+
this.ws();
|
|
136
|
+
if (this.s[this.i] === "{")
|
|
137
|
+
return this.raw();
|
|
138
|
+
const m = this.s.slice(this.i).match(/^\\(?:[a-zA-Z]+|.)/);
|
|
139
|
+
if (m) {
|
|
140
|
+
this.i += m[0].length;
|
|
141
|
+
return m[0];
|
|
142
|
+
}
|
|
143
|
+
return this.s[this.i++] ?? "";
|
|
144
|
+
}
|
|
145
|
+
sub(tex) {
|
|
146
|
+
return new Parser(tex, this.inline).seq();
|
|
147
|
+
}
|
|
148
|
+
ws() {
|
|
149
|
+
while (/\s/.test(this.s[this.i] ?? ""))
|
|
150
|
+
this.i++;
|
|
151
|
+
}
|
|
152
|
+
token() {
|
|
153
|
+
const m = this.s.slice(this.i).match(/^\\([a-zA-Z]+|.)/);
|
|
154
|
+
if (!m)
|
|
155
|
+
return txt(this.s[this.i++]);
|
|
156
|
+
this.i += m[0].length;
|
|
157
|
+
const name = m[1];
|
|
158
|
+
if (name === "frac" || name === "dfrac" || name === "tfrac") {
|
|
159
|
+
const n = this.group();
|
|
160
|
+
const d = this.group();
|
|
161
|
+
return this.inline ? txt(`${flat(n)}/${flat(d)}`.replace(/^(.{2,})\/(.+)$/, (_, a, b) => `${a.length > 1 && /[+\-−]/.test(a) ? `(${a})` : a}/${/[+\-−]/.test(b) ? `(${b})` : b}`)) : frac(n, d);
|
|
162
|
+
}
|
|
163
|
+
if (name === "sqrt") {
|
|
164
|
+
const x = this.group();
|
|
165
|
+
return this.inline ? txt(`√(${flat(x)})`) : sqrtBox(x);
|
|
166
|
+
}
|
|
167
|
+
if (name === "left" || name === "right") {
|
|
168
|
+
const ch = this.s[this.i++];
|
|
169
|
+
return txt(ch === "." ? "" : ch === "\\" ? this.s[this.i++] : ch);
|
|
170
|
+
}
|
|
171
|
+
if (["text", "mathrm", "operatorname", "textbf", "mathbf", "boldsymbol", "mathbb", "mathcal", "vec", "hat", "bar", "dot", "ddot", "tilde", "overline", "mathit", "mathsf", "mathscr", "mathfrak"].includes(name)) {
|
|
172
|
+
return txt(unicodeit.replace(`\\${name}{${this.raw()}}`).replace(/^\\(?:text|mathrm|operatorname|overline)\{(.*)\}$/, "$1").replace(/^\\\w+\{(.*)\}$/, "$1"));
|
|
173
|
+
}
|
|
174
|
+
const phys = this.physics(name);
|
|
175
|
+
if (phys)
|
|
176
|
+
return phys;
|
|
177
|
+
if (name in BIG_OPS)
|
|
178
|
+
return this.bigOp(BIG_OPS[name], name);
|
|
179
|
+
if (name === "\\" || name === "," || name === ";" || name === ":" || name === "!" || name === "quad" || name === "qquad")
|
|
180
|
+
return txt(name === "quad" ? " " : name === "qquad" ? " " : name === "," || name === ";" || name === ":" ? " " : "");
|
|
181
|
+
if (name === "begin" || name === "end") {
|
|
182
|
+
this.raw();
|
|
183
|
+
return txt(name === "begin" ? "" : "");
|
|
184
|
+
}
|
|
185
|
+
if (name === "&")
|
|
186
|
+
return txt(" ");
|
|
187
|
+
const u = unicodeit.replace(`\\${name}`);
|
|
188
|
+
return txt(u.startsWith("\\") ? name : u);
|
|
189
|
+
}
|
|
190
|
+
physics(name) {
|
|
191
|
+
const bold = (x) => unicodeit.replace(`\\mathbf{${x}}`);
|
|
192
|
+
switch (name) {
|
|
193
|
+
case "ket": return txt(`|${flat(this.sub(this.rawArg()))}⟩`);
|
|
194
|
+
case "bra": return txt(`⟨${flat(this.sub(this.rawArg()))}|`);
|
|
195
|
+
case "braket": {
|
|
196
|
+
const a = flat(this.sub(this.rawArg()));
|
|
197
|
+
const b = flat(this.sub(this.rawArg()));
|
|
198
|
+
return txt(`⟨${a}|${b}⟩`);
|
|
199
|
+
}
|
|
200
|
+
case "expval": return txt(`⟨${flat(this.sub(this.rawArg()))}⟩`);
|
|
201
|
+
case "abs": return txt(`|${flat(this.sub(this.rawArg()))}|`);
|
|
202
|
+
case "norm": return txt(`‖${flat(this.sub(this.rawArg()))}‖`);
|
|
203
|
+
case "vb":
|
|
204
|
+
case "vu": return txt(name === "vb" ? bold(this.rawArg()) : unicodeit.replace(`\\hat{${this.rawArg()}}`));
|
|
205
|
+
case "grad": return txt("∇");
|
|
206
|
+
case "curl": return txt("∇×");
|
|
207
|
+
case "div": return txt("∇·");
|
|
208
|
+
case "laplacian": return txt("∇²");
|
|
209
|
+
case "qty": return txt("");
|
|
210
|
+
case "pdv":
|
|
211
|
+
case "dv": {
|
|
212
|
+
const d = name === "pdv" ? "∂" : "d";
|
|
213
|
+
const f = this.rawArg();
|
|
214
|
+
this.ws();
|
|
215
|
+
const g = this.s[this.i] === "{" || this.s[this.i] === "\\" ? this.rawArg() : "";
|
|
216
|
+
return this.inline ? txt(g ? `${d}${flat(this.sub(f))}/${d}${flat(this.sub(g))}` : `${d}/${d}${flat(this.sub(f))}`) : g ? frac(txt(`${d}${flat(this.sub(f))}`), txt(`${d}${flat(this.sub(g))}`)) : frac(txt(d), txt(`${d}${flat(this.sub(f))}`));
|
|
217
|
+
}
|
|
218
|
+
case "ce": return txt(chem(this.raw()));
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
bigOp(sym, name) {
|
|
223
|
+
let lo;
|
|
224
|
+
let hi;
|
|
225
|
+
for (let k = 0; k < 2; k++) {
|
|
226
|
+
this.ws();
|
|
227
|
+
const c = this.s[this.i];
|
|
228
|
+
if (c === "_" && !lo)
|
|
229
|
+
(this.i++, (lo = this.group()));
|
|
230
|
+
else if (c === "^" && !hi)
|
|
231
|
+
(this.i++, (hi = this.group()));
|
|
232
|
+
}
|
|
233
|
+
if (this.inline) {
|
|
234
|
+
const lim = lo || hi ? `[${lo ? flat(lo) : ""}${hi ? `→${flat(hi)}` : ""}]` : "";
|
|
235
|
+
return txt(sym + lim);
|
|
236
|
+
}
|
|
237
|
+
const w = Math.max(width(sym), lo ? boxW(lo) : 0, hi ? boxW(hi) : 0);
|
|
238
|
+
const rows = [...(hi ? hi.l.map((s) => pad(s, w, "c")) : []), pad(sym, w, "c"), ...(lo ? lo.l.map((s) => pad(s, w, "c")) : [])];
|
|
239
|
+
return { l: rows, b: hi ? hi.l.length : 0 };
|
|
240
|
+
}
|
|
241
|
+
seq(stop) {
|
|
242
|
+
const parts = [];
|
|
243
|
+
while (this.i < this.s.length && this.s[this.i] !== stop) {
|
|
244
|
+
const c = this.s[this.i];
|
|
245
|
+
if (/\s/.test(c)) {
|
|
246
|
+
this.i++;
|
|
247
|
+
}
|
|
248
|
+
else if (c === "{") {
|
|
249
|
+
parts.push(this.group());
|
|
250
|
+
}
|
|
251
|
+
else if (c === "^" || c === "_") {
|
|
252
|
+
this.i++;
|
|
253
|
+
const rawTex = this.rawArg();
|
|
254
|
+
const mapped = script(c, rawTex);
|
|
255
|
+
const inner = flat(this.sub(rawTex));
|
|
256
|
+
parts.push(txt(mapped ?? (/^[A-Za-z0-9]+$/.test(inner) ? `${c}${inner}` : `${c}(${inner})`)));
|
|
257
|
+
}
|
|
258
|
+
else if (c === "\\") {
|
|
259
|
+
parts.push(this.token());
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
this.i++;
|
|
263
|
+
const prev = parts[parts.length - 1];
|
|
264
|
+
const before = prev ? flat(prev) : "";
|
|
265
|
+
const binary = (c === "+" || c === "-") && before !== "" && !/[=≈≠≤≥→←(,\[]$/.test(before);
|
|
266
|
+
const rel = RELATIONS.has(c);
|
|
267
|
+
parts.push(txt(rel || binary ? ` ${c === "-" ? "−" : c} ` : c === "," ? ", " : c));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// relations produced by commands (\approx, \to …) also get spacing
|
|
271
|
+
const spaced = parts.map((p) => (p.l.length === 1 && RELATIONS.has(p.l[0]) ? txt(` ${p.l[0]} `) : p));
|
|
272
|
+
return hcat(spaced);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** \\ce{2H2 + O2 -> 2H2O} → 2H₂ + O₂ → 2H₂O */
|
|
276
|
+
const chem = (s) => s
|
|
277
|
+
.replace(/<->|<=>/g, "⇌")
|
|
278
|
+
.replace(/->/g, "→")
|
|
279
|
+
.replace(/<-/g, "←")
|
|
280
|
+
.replace(/([A-Za-z)])(\d+)/g, (_, a, n) => a + [...n].map((d) => SUB[+d]).join(""))
|
|
281
|
+
.replace(/\^\{?([+-]?\d*[+-]?)\}?/g, (_, c) => script("^", c) ?? c);
|
|
282
|
+
const flat = (b) => b.l.join(" ").replace(/\s+/g, " ").trim();
|
|
283
|
+
/** Display equation as multi-line Unicode art. */
|
|
284
|
+
export function texToLines(tex) {
|
|
285
|
+
const clean = tex.replace(/\\(?:label|tag)\{[^}]*\}/g, "").trim();
|
|
286
|
+
const box = new Parser(clean, false).seq();
|
|
287
|
+
return box.l.map((l) => l.replace(/\s+$/, ""));
|
|
288
|
+
}
|
|
289
|
+
/** Inline equation as one line of Unicode. */
|
|
290
|
+
export function texToInline(tex) {
|
|
291
|
+
return flat(new Parser(tex.trim(), true).seq());
|
|
292
|
+
}
|
|
293
|
+
/** Replace $…$, $$…$$, \(…\) in a plain string with Unicode (for prompts, options, flashcards). */
|
|
294
|
+
export function mathToText(s) {
|
|
295
|
+
const conv = (tex) => {
|
|
296
|
+
try {
|
|
297
|
+
return texToInline(tex);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return tex;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
return s
|
|
304
|
+
.replace(/\$\$([\s\S]+?)\$\$/g, (_, t) => conv(t))
|
|
305
|
+
.replace(/\\\(([\s\S]+?)\\\)/g, (_, t) => conv(t))
|
|
306
|
+
.replace(/\$(?!\s)([^$\n]+?)(?<!\s)\$(?!\d)/g, (m, t) => (/^\d/.test(t) && !/[\\^_{}]/.test(t) ? m : conv(t)));
|
|
307
|
+
}
|