prehello 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +515 -2
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
- package/assets/fonts/Inter-Regular.subset.ttf +0 -0
- package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
- package/assets/fonts/OFL.txt +94 -0
- package/dist/adapters/claude-code.js +480 -0
- package/dist/adapters/codex.js +184 -0
- package/dist/adapters/common.js +157 -0
- package/dist/adapters/cursor.js +257 -0
- package/dist/adapters/skills.js +155 -0
- package/dist/adapters/types.js +2 -0
- package/dist/analyze/findings.js +82 -0
- package/dist/analyze/grade.js +55 -0
- package/dist/analyze/savings.js +111 -0
- package/dist/analyze/summary.js +57 -0
- package/dist/analyze/totals.js +21 -0
- package/dist/cli.js +454 -0
- package/dist/context.js +331 -0
- package/dist/fixtures.js +52 -0
- package/dist/fs-safe.js +222 -0
- package/dist/mcp/catalog.js +44 -0
- package/dist/mcp/configs.js +42 -0
- package/dist/model.js +11 -0
- package/dist/platform.js +115 -0
- package/dist/redact.js +39 -0
- package/dist/render/card.js +305 -0
- package/dist/render/fix.js +22 -0
- package/dist/render/json.js +97 -0
- package/dist/render/markdown.js +182 -0
- package/dist/render/png.js +162 -0
- package/dist/render/sarif.js +472 -0
- package/dist/render/terminal.js +329 -0
- package/dist/render/treemap.js +58 -0
- package/dist/safe-write.js +120 -0
- package/dist/scan.js +104 -0
- package/dist/spec.js +27 -0
- package/dist/text/comments.js +60 -0
- package/dist/text/frontmatter.js +90 -0
- package/dist/text/imports.js +81 -0
- package/dist/tokens/counter.js +41 -0
- package/dist/tokens/families.js +47 -0
- package/dist/util/fmt.js +102 -0
- package/dist/util/secrets.js +35 -0
- package/package.json +66 -4
- package/spec/claude-code.json +22 -0
- package/spec/codex.json +14 -0
- package/spec/cursor.json +15 -0
- package/spec/mcp-weights.json +62 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mdCode = mdCode;
|
|
4
|
+
exports.fixtureNote = fixtureNote;
|
|
5
|
+
exports.renderMarkdown = renderMarkdown;
|
|
6
|
+
const card_1 = require("./card");
|
|
7
|
+
const savings_1 = require("../analyze/savings");
|
|
8
|
+
const terminal_1 = require("./terminal");
|
|
9
|
+
const fmt_1 = require("../util/fmt");
|
|
10
|
+
const MAX_NAME = 60;
|
|
11
|
+
const ZERO_WIDTH = /[\u200b-\u200f\u2028\u2029\u2060\ufeff]/g;
|
|
12
|
+
/** A user-controlled name as an inline code span. Never throws, never returns an empty span, safe inside a table cell when `table`. */
|
|
13
|
+
function mdCode(name, table = false) {
|
|
14
|
+
let t = (0, fmt_1.sanitize)(name.replace(/[\r\n\t\v\f]+/g, " "), 400).replace(ZERO_WIDTH, "").replace(/\s+/g, " ").trim();
|
|
15
|
+
if (t.length > MAX_NAME)
|
|
16
|
+
t = t.slice(0, MAX_NAME - 3) + "...";
|
|
17
|
+
if (!t)
|
|
18
|
+
t = "(unnamed)";
|
|
19
|
+
if (table)
|
|
20
|
+
t = t.replace(/\|/g, "\\|");
|
|
21
|
+
const longest = Math.max(0, ...(t.match(/`+/g) ?? []).map((r) => r.length));
|
|
22
|
+
const fence = "`".repeat(longest + 1);
|
|
23
|
+
const pad = /^`|`$/.test(t) ? " " : "";
|
|
24
|
+
return `${fence}${pad}${t}${pad}${fence}`;
|
|
25
|
+
}
|
|
26
|
+
/** One-line note (a count, no paths) when fixture folders holding agent files were skipped; null otherwise. */
|
|
27
|
+
function fixtureNote(r) {
|
|
28
|
+
const n = r.scan.fixturesSkipped;
|
|
29
|
+
return n ? `Skipped ${n} fixture folder${n > 1 ? "s" : ""} with agent files (use --include-fixtures to count them).` : null;
|
|
30
|
+
}
|
|
31
|
+
const ascii = (s) => s.replace(/±/g, "+/-").replace(/−/g, "-");
|
|
32
|
+
const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
|
|
33
|
+
function alwaysSentence(a, window) {
|
|
34
|
+
const t = a.totals.always;
|
|
35
|
+
if (!t.measuredItems)
|
|
36
|
+
return t.unmeasuredItems ? `${plural(t.unmeasuredItems, "always-loaded item")} could not be measured: token cost unknown (not zero).` : "nothing that loads up front was found (this is not a score of 0; the agent still has its own system prompt and tools).";
|
|
37
|
+
const lb = a.lowerBound ? `${card_1.AT_LEAST} ` : "";
|
|
38
|
+
const pct = (t.est / window) * 100;
|
|
39
|
+
const share = pct < 0.1 ? (a.lowerBound ? "under 0.1% so far" : "under 0.1%") : `${a.lowerBound ? card_1.AT_LEAST + " " : "about "}${(0, fmt_1.fmtPct)(pct)}`;
|
|
40
|
+
return `${lb}~${(0, fmt_1.fmtTok)(t.est)} tokens always on, ${share} of a ${(0, fmt_1.fmtWindow)(window)} window (range ~${(0, fmt_1.fmtTok)(t.low)}-${(0, fmt_1.fmtTok)(t.high)}).`;
|
|
41
|
+
}
|
|
42
|
+
function gradeLine(a) {
|
|
43
|
+
const g = a.grade;
|
|
44
|
+
if (!g)
|
|
45
|
+
return a.gradeNote ? `Context Grade: none (${ascii((0, fmt_1.sanitize)(a.gradeNote, 200)).replace(/^no grade: /, "")}).` : undefined;
|
|
46
|
+
const why = g.provisional ? ` (provisional${g.cappedAt ? `, capped at ${g.cappedAt}` : ""}: some always-loaded items are unmeasured${a.totals.always.unmeasuredItems ? "" : " or the scan hit its budget"})` : "";
|
|
47
|
+
return `Context Grade: **${g.letter}**${why}. A heuristic, not a standard.`;
|
|
48
|
+
}
|
|
49
|
+
function unmeasuredList(a, o) {
|
|
50
|
+
const names = a.mcpServers.filter((m) => !m.measured).map((m) => m.name);
|
|
51
|
+
if (!names.length)
|
|
52
|
+
return "";
|
|
53
|
+
if (o.anonymize)
|
|
54
|
+
return ` (${plural(names.length, "MCP server")}, names hidden)`;
|
|
55
|
+
return ` (${names.slice(0, 6).map((n) => mdCode(n)).join(", ")}${names.length > 6 ? `, +${names.length - 6} more` : ""})`;
|
|
56
|
+
}
|
|
57
|
+
function agentSection(r, a, o) {
|
|
58
|
+
const out = [`### ${a.name}`, ""];
|
|
59
|
+
const lead = alwaysSentence(a, r.scan.window);
|
|
60
|
+
out.push(lead.charAt(0).toUpperCase() + lead.slice(1));
|
|
61
|
+
const gl = gradeLine(a);
|
|
62
|
+
if (gl)
|
|
63
|
+
out.push("", gl);
|
|
64
|
+
const t = a.totals.always;
|
|
65
|
+
const tiles = (0, savings_1.cardTiles)(a.items, o.anonymize);
|
|
66
|
+
const unmeasuredServers = a.mcpServers.filter((m) => !m.measured);
|
|
67
|
+
if (tiles.length || unmeasuredServers.length) {
|
|
68
|
+
out.push("", "| What loads before hello | ~Tokens | Share of measured |", "|---|--:|--:|");
|
|
69
|
+
const total = tiles.reduce((s, x) => s + x.tokens, 0) || 1;
|
|
70
|
+
const MAX = 10;
|
|
71
|
+
for (const x of tiles.slice(0, MAX))
|
|
72
|
+
out.push(`| ${x.named ? mdCode(x.label, true) : x.label} | ~${(0, fmt_1.fmtTok)(x.tokens)} | ${(0, fmt_1.fmtPct)((x.tokens / total) * 100)} |`);
|
|
73
|
+
if (tiles.length > MAX) {
|
|
74
|
+
const rest = tiles.slice(MAX).reduce((s, x) => s + x.tokens, 0);
|
|
75
|
+
out.push(`| ${tiles.length - MAX} smaller groups | ~${(0, fmt_1.fmtTok)(rest)} | ${(0, fmt_1.fmtPct)((rest / total) * 100)} |`);
|
|
76
|
+
}
|
|
77
|
+
unmeasuredServers.slice(0, 8).forEach((m, i) => out.push(`| ${o.anonymize ? `unmeasured MCP server ${i + 1}` : `MCP server ${mdCode(m.name, true)}`} | not measured | - |`));
|
|
78
|
+
if (unmeasuredServers.length > 8)
|
|
79
|
+
out.push(`| ${unmeasuredServers.length - 8} more unmeasured MCP servers | not measured | - |`);
|
|
80
|
+
if (a.lowerBound)
|
|
81
|
+
out.push("", `The total is a lower bound: items marked "not measured" are not counted, and are never shown as 0.`);
|
|
82
|
+
}
|
|
83
|
+
if (a.savings) {
|
|
84
|
+
const sv = a.savings;
|
|
85
|
+
out.push("", `**What to cut** (estimates, not a promise): removing the top ${sv.n === 1 ? "group" : sv.n + " groups"} would save ~${(0, fmt_1.fmtTok)(sv.topTokens)} tokens (${(0, fmt_1.fmtPct)(sv.pctOfWindow)} of a ${(0, fmt_1.fmtWindow)(r.scan.window)} window).`, "");
|
|
86
|
+
const byCut = new Map(tiles.filter((x) => x.cut !== undefined).map((x) => [x.cut, x]));
|
|
87
|
+
sv.top.forEach((c, i) => {
|
|
88
|
+
const x = byCut.get(i);
|
|
89
|
+
const label = x ? (x.named ? mdCode(x.label) : x.label) : "group";
|
|
90
|
+
const cnt = c.kind === "skills" && c.count > 1 && c.label.startsWith("plugin ") ? `, ${c.count} skills` : "";
|
|
91
|
+
out.push(`${i + 1}. ${label}: ~${(0, fmt_1.fmtTok)(c.tokens)} (${(0, fmt_1.fmtPct)((c.tokens / (t.est || 1)) * 100)} of always-on${cnt})`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const c = a.coverage;
|
|
95
|
+
const totalAlways = c.alwaysMeasuredItems + c.alwaysUnmeasuredItems;
|
|
96
|
+
if (!totalAlways)
|
|
97
|
+
return [...out, ""];
|
|
98
|
+
out.push("", "**Measured vs not measured** (always-on items)", "");
|
|
99
|
+
{
|
|
100
|
+
out.push(`- Measured ${c.alwaysMeasuredItems} of ${totalAlways}${c.alwaysFraction !== null ? ` (${Math.round(c.alwaysFraction * 100)}%)` : ""}${c.alwaysMeasuredItems ? `: ${(0, terminal_1.kinds)(c.measuredKinds)}` : ""}`);
|
|
101
|
+
if (c.alwaysUnmeasuredItems)
|
|
102
|
+
out.push(`- NOT measured: ${(0, terminal_1.kinds)(c.unmeasuredKinds)}${c.unmeasuredKinds["mcp-server"] ? unmeasuredList(a, o) : ""}. No token count; never shown as 0.`);
|
|
103
|
+
}
|
|
104
|
+
const other = ["deferred", "conditional", "on-demand"].filter((m) => a.totals[m].measuredItems);
|
|
105
|
+
if (other.length)
|
|
106
|
+
out.push(`- Not always on: ${other.map((m) => `${m} ~${(0, fmt_1.fmtTok)(a.totals[m].est)}`).join(", ")} (loaded only when triggered or invoked)`);
|
|
107
|
+
const nf = r.findings.filter((f) => f.agent === a.id).length;
|
|
108
|
+
if (nf)
|
|
109
|
+
out.push(`- ${plural(nf, "finding")} (duplicates, over-long or ignored files): details name files, so they are left out of this report. Run prehello locally to see them.`);
|
|
110
|
+
out.push("");
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** The headline sentence of the report (top agent), with the same guards as the card. */
|
|
114
|
+
function headlineOf(r, o) {
|
|
115
|
+
const agent = (0, card_1.pickCardAgent)(r);
|
|
116
|
+
if (!agent)
|
|
117
|
+
return null;
|
|
118
|
+
const h = (0, card_1.buildHeadline)(agent, r.scan.window, o.headline) ?? (0, card_1.buildHeadline)(agent, r.scan.window, "total");
|
|
119
|
+
if (!h)
|
|
120
|
+
return null;
|
|
121
|
+
(0, card_1.assertHeadline)(h, agent, r.scan.window);
|
|
122
|
+
return { agent, hero: h.hero, sub: h.sub, id: h.id };
|
|
123
|
+
}
|
|
124
|
+
function footer(r, shown) {
|
|
125
|
+
const unvalidated = shown.some((a) => a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic");
|
|
126
|
+
const bands = [...new Set(shown.filter((a) => a.totals.always.measuredItems).map((a) => `${a.name} ${ascii(a.profile.band)}`))];
|
|
127
|
+
return `_Generated by prehello ${r.tool.version} on ${r.generatedAt.slice(0, 10)}. Local-only scan: no paths or file contents are included. All token counts are estimates (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling priors" : "chars/3.5 heuristic"}${bands.length ? "; band " + bands.join(", ") : ""}${unvalidated ? ", unvalidated" : ""}), not exact for any model. Window ${(0, fmt_1.fmtWindow)(r.scan.window)}.${r.partial ? " PARTIAL scan: every total is a lower bound." : ""}_`;
|
|
128
|
+
}
|
|
129
|
+
function renderMarkdown(r, o) {
|
|
130
|
+
const shown = r.agents.filter((a) => !a.presentInRepoOnly);
|
|
131
|
+
const out = ["## prehello report: what your coding agent loads before you say hello", ""];
|
|
132
|
+
const repoOnly = r.agents.filter((a) => a.presentInRepoOnly).map((a) => a.name);
|
|
133
|
+
if (!shown.length) {
|
|
134
|
+
out.push("No coding agent was detected on this machine, so nothing was measured. This is not a score of 0.", "");
|
|
135
|
+
if (repoOnly.length)
|
|
136
|
+
out.push(`Agent files for ${repoOnly.join(" and ")} exist in the repo, but the agent is not installed here, so they are not counted.`, "");
|
|
137
|
+
out.push(footer(r, shown));
|
|
138
|
+
return out.join("\n") + "\n";
|
|
139
|
+
}
|
|
140
|
+
const head = headlineOf(r, o);
|
|
141
|
+
if (head) {
|
|
142
|
+
// cut headline: "top 3 of 6 groups: cut them, save ~13.2k (estimate)" -> "Claude Code: top 3 of 6 groups - cut them, save ~13.2k (estimate)."
|
|
143
|
+
const sub = head.sub.replace(/ · /g, ", ");
|
|
144
|
+
const parts = sub.split(", ");
|
|
145
|
+
const line = head.id === "cut-list" ? `${head.agent.name}: ${sub.replace(": ", " - ")}` : (sub.includes(" window") ? `${sub.charAt(0).toUpperCase()}${sub.slice(1, sub.indexOf(" window") + 7)} (${parts.slice(-2).join(", ")})` : `${sub.charAt(0).toUpperCase()}${sub.slice(1)}`);
|
|
146
|
+
out.push(`**${head.hero.charAt(0).toUpperCase()}${head.hero.slice(1)}**`, "", `${line}.`, "");
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
out.push("Nothing measurable loads before hello for the detected agents. This is not a score of 0: absence of data is never shown as 0.", "");
|
|
150
|
+
if (o.compact) {
|
|
151
|
+
for (const a of shown) {
|
|
152
|
+
const t = a.totals.always;
|
|
153
|
+
const g = a.grade ? `, grade ${a.grade.letter}${a.grade.provisional ? " (provisional)" : ""}` : "";
|
|
154
|
+
out.push(`- **${a.name}**: ${t.measuredItems ? `${a.lowerBound ? card_1.AT_LEAST + " " : ""}~${(0, fmt_1.fmtTok)(t.est)} tokens always on (${a.lowerBound ? card_1.AT_LEAST + " " : ""}${(0, fmt_1.fmtPct)((t.est / r.scan.window) * 100)} of ${(0, fmt_1.fmtWindow)(r.scan.window)})` : t.unmeasuredItems ? "tokens unmeasured (not zero)" : "nothing found up front"}${g}`);
|
|
155
|
+
}
|
|
156
|
+
const cut = (0, card_1.pickCardAgent)(r)?.savings;
|
|
157
|
+
const top = (0, card_1.pickCardAgent)(r);
|
|
158
|
+
if (cut && top) {
|
|
159
|
+
const tiles = (0, savings_1.cardTiles)(top.items, o.anonymize).filter((x) => x.cut !== undefined && x.cut < cut.n);
|
|
160
|
+
out.push("", `Cut first (${top.name}, estimates): ${tiles.map((x) => `${x.named ? mdCode(x.label) : x.label} ~${(0, fmt_1.fmtTok)(x.tokens)}`).join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
const fxc = fixtureNote(r);
|
|
163
|
+
if (fxc)
|
|
164
|
+
out.push("", fxc);
|
|
165
|
+
out.push("", footer(r, shown));
|
|
166
|
+
return out.join("\n") + "\n";
|
|
167
|
+
}
|
|
168
|
+
for (const a of shown)
|
|
169
|
+
out.push(...agentSection(r, a, o));
|
|
170
|
+
if (repoOnly.length)
|
|
171
|
+
out.push(`Agent files for ${repoOnly.join(" and ")} exist in the repo, but the agent is not installed here, so they are not counted.`, "");
|
|
172
|
+
const shared = r.findings.filter((f) => f.agent === "all").length;
|
|
173
|
+
if (shared)
|
|
174
|
+
out.push(`${plural(shared, "finding")} apply to more than one agent (duplicate or conflicting files); details name files, so they are left out of this report.`, "");
|
|
175
|
+
if (r.warnings.length || r.partial)
|
|
176
|
+
out.push(`**Partial results:** some sources could not be read or were skipped (or the scan hit its budget), so every total is a lower bound. Run prehello locally for details.`, "");
|
|
177
|
+
const fx = fixtureNote(r);
|
|
178
|
+
if (fx)
|
|
179
|
+
out.push(fx, "");
|
|
180
|
+
out.push(footer(r, shown));
|
|
181
|
+
return out.join("\n") + "\n";
|
|
182
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PNG_MAGIC = exports.PNG_UNAVAILABLE_MESSAGE = exports.FONT_DIR = exports.PNG_CHAR_EM = exports.PNG_FONT_FAMILY = exports.PngUnavailableError = void 0;
|
|
7
|
+
exports.detectInstallKind = detectInstallKind;
|
|
8
|
+
exports.pngUnavailableMessage = pngUnavailableMessage;
|
|
9
|
+
exports.loadEngine = loadEngine;
|
|
10
|
+
exports.toFontSafeText = toFontSafeText;
|
|
11
|
+
exports.tagPng = tagPng;
|
|
12
|
+
exports.svgToPng = svgToPng;
|
|
13
|
+
/**
|
|
14
|
+
* PNG export of the share card. Pure JS + WebAssembly: no native binaries, no network, no child processes.
|
|
15
|
+
*
|
|
16
|
+
* Engine: `@resvg/resvg-wasm` (MPL-2.0), an OPTIONAL dependency. If it is not installed, prehello says so and SVG keeps working.
|
|
17
|
+
* Fonts: three subsetted static weights of Inter (SIL OFL 1.1, see assets/fonts/OFL.txt) are shipped in the package and handed to the
|
|
18
|
+
* renderer as buffers. System fonts are never used, so the PNG looks the same on every machine and can never show "tofu" boxes
|
|
19
|
+
* for the glyphs a card uses (ASCII, Latin-1, and a handful of punctuation marks).
|
|
20
|
+
*/
|
|
21
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
class PngUnavailableError extends Error {
|
|
24
|
+
}
|
|
25
|
+
exports.PngUnavailableError = PngUnavailableError;
|
|
26
|
+
/** Family name the PNG variant of the SVG asks for; it matches the name inside the shipped font files. */
|
|
27
|
+
exports.PNG_FONT_FAMILY = "Inter";
|
|
28
|
+
const FONT_FILES = ["Inter-Regular.subset.ttf", "Inter-SemiBold.subset.ttf", "Inter-ExtraBold.subset.ttf"];
|
|
29
|
+
/** Average advance of the shipped Inter weights is 0.46-0.50 em on card text and 0.58-0.61 on caps and digits; layout uses this (the system-font stack uses a wider 0.62). */
|
|
30
|
+
exports.PNG_CHAR_EM = 0.58;
|
|
31
|
+
exports.FONT_DIR = node_path_1.default.join(__dirname, "..", "..", "assets", "fonts");
|
|
32
|
+
function detectInstallKind(dir = __dirname, hasPackageJson = (d) => node_fs_1.default.existsSync(node_path_1.default.join(d, "package.json"))) {
|
|
33
|
+
const hits = [...dir.matchAll(/[\\/]node_modules(?=[\\/]|$)/g)];
|
|
34
|
+
if (!hits.length)
|
|
35
|
+
return "source";
|
|
36
|
+
if (dir.split(/[\\/]+/).includes("_npx"))
|
|
37
|
+
return "npx";
|
|
38
|
+
// <something>/node_modules/prehello/dist/render -> <something> is a project (has package.json) for a local install,
|
|
39
|
+
// and a global prefix (<prefix>/lib or %APPDATA%\npm, no package.json) for `npm install -g`.
|
|
40
|
+
const holder = dir.slice(0, hits[hits.length - 1].index) || "/";
|
|
41
|
+
return hasPackageJson(holder) ? "local" : "global";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Printed (stderr) when PNG is asked for and the optional engine is missing. Same wording on Linux, macOS and Windows: npm commands
|
|
45
|
+
* are identical there. The detected install kind is marked, the other ways follow, and SVG is always offered.
|
|
46
|
+
*/
|
|
47
|
+
function pngUnavailableMessage(kind = "unknown") {
|
|
48
|
+
const here = (k) => (kind === k ? " <- how this prehello was installed" : "");
|
|
49
|
+
return [
|
|
50
|
+
"PNG cards need the optional package @resvg/resvg-wasm, which is not installed here, so no card was written (everything else in this run completed normally).",
|
|
51
|
+
"Install it, then run the same command again:",
|
|
52
|
+
` global install of prehello: npm install -g @resvg/resvg-wasm${here("global")}`,
|
|
53
|
+
` project install: npm install @resvg/resvg-wasm (run it in the project that has prehello in node_modules)${here("local")}`,
|
|
54
|
+
` one-off with npx: npx --package prehello --package @resvg/resvg-wasm -- prehello --card=card.png${here("npx")}`,
|
|
55
|
+
...(kind === "source" ? [" from a source checkout: npm install (in the prehello folder) <- how this prehello is running"] : []),
|
|
56
|
+
" if you installed with --omit=optional (or npm config optional=false), reinstall without it.",
|
|
57
|
+
"Or skip PNG: an SVG card needs no extra install, e.g. --card=card.svg (browsers and most image tools open it).",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
/** Generic form of the message (install kind unknown). */
|
|
61
|
+
exports.PNG_UNAVAILABLE_MESSAGE = pngUnavailableMessage();
|
|
62
|
+
let engine;
|
|
63
|
+
/** Loads the optional engine once. `resolver` exists so tests can simulate a missing dependency. */
|
|
64
|
+
function loadEngine(resolver = require.resolve) {
|
|
65
|
+
if (engine)
|
|
66
|
+
return engine;
|
|
67
|
+
const p = (async () => {
|
|
68
|
+
let mod;
|
|
69
|
+
let wasmPath;
|
|
70
|
+
try {
|
|
71
|
+
wasmPath = resolver("@resvg/resvg-wasm/index_bg.wasm");
|
|
72
|
+
mod = require(resolver("@resvg/resvg-wasm"));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new PngUnavailableError(pngUnavailableMessage(detectInstallKind()));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await mod.initWasm(node_fs_1.default.readFileSync(wasmPath));
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
const msg = e.message;
|
|
82
|
+
// initWasm throws "Already initialized" when something else in the process initialised it: harmless.
|
|
83
|
+
if (!/already initialized/i.test(msg))
|
|
84
|
+
throw new PngUnavailableError(`PNG export could not start its WebAssembly renderer (${msg}). Reinstalling it may help: npm install @resvg/resvg-wasm. Or write an SVG instead: --card=card.svg`);
|
|
85
|
+
}
|
|
86
|
+
return mod;
|
|
87
|
+
})();
|
|
88
|
+
// a failed load is not cached when the caller injected a resolver (tests); the real one is cached either way
|
|
89
|
+
if (resolver === require.resolve)
|
|
90
|
+
engine = p;
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
/** Every glyph the card can print is in the font subset; anything else (never expected) becomes "?" rather than a tofu box. */
|
|
94
|
+
function toFontSafeText(svg) {
|
|
95
|
+
return svg.replace(/[^\x09\x0a\x0d\x20-\x7e\u00a0-\u00ff\u2013\u2014\u2018\u2019\u201c\u201d\u2022\u2026\u2192\u2212\u2248\u2264\u2265]/gu, "?");
|
|
96
|
+
}
|
|
97
|
+
const CRC_TABLE = (() => {
|
|
98
|
+
const t = new Uint32Array(256);
|
|
99
|
+
for (let n = 0; n < 256; n++) {
|
|
100
|
+
let c = n;
|
|
101
|
+
for (let k = 0; k < 8; k++)
|
|
102
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
103
|
+
t[n] = c >>> 0;
|
|
104
|
+
}
|
|
105
|
+
return t;
|
|
106
|
+
})();
|
|
107
|
+
function crc32(buf) {
|
|
108
|
+
let c = 0xffffffff;
|
|
109
|
+
for (let i = 0; i < buf.length; i++)
|
|
110
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
111
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
112
|
+
}
|
|
113
|
+
function textChunk(keyword, text) {
|
|
114
|
+
const data = Buffer.concat([Buffer.from(keyword, "latin1"), Buffer.from([0]), Buffer.from(text, "latin1")]);
|
|
115
|
+
const body = Buffer.concat([Buffer.from("tEXt", "latin1"), data]);
|
|
116
|
+
const len = Buffer.alloc(4);
|
|
117
|
+
len.writeUInt32BE(data.length);
|
|
118
|
+
const crc = Buffer.alloc(4);
|
|
119
|
+
crc.writeUInt32BE(crc32(body));
|
|
120
|
+
return Buffer.concat([len, body, crc]);
|
|
121
|
+
}
|
|
122
|
+
/** Adds `Software` and `Description` tEXt chunks right after IHDR. This is how prehello recognises its own PNG before replacing it. */
|
|
123
|
+
function tagPng(png, version) {
|
|
124
|
+
const ihdrEnd = 8 + 4 + 4 + 13 + 4; // signature + length + type + data + crc
|
|
125
|
+
return Buffer.concat([
|
|
126
|
+
png.subarray(0, ihdrEnd),
|
|
127
|
+
textChunk("Software", `prehello ${version}`),
|
|
128
|
+
textChunk("Description", "Share card generated locally by prehello. Token counts are estimates."),
|
|
129
|
+
png.subarray(ihdrEnd),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
exports.PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
133
|
+
/** Rasterises a card SVG (made with `fontFamily: PNG_FONT_FAMILY`) to a PNG buffer. Throws PngUnavailableError when the engine is missing. */
|
|
134
|
+
async function svgToPng(svg, o) {
|
|
135
|
+
const { Resvg } = await loadEngine(o.resolver);
|
|
136
|
+
let fontBuffers;
|
|
137
|
+
try {
|
|
138
|
+
fontBuffers = FONT_FILES.map((f) => new Uint8Array(node_fs_1.default.readFileSync(node_path_1.default.join(exports.FONT_DIR, f))));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new PngUnavailableError("PNG export could not read prehello's bundled fonts (assets/fonts is missing from this install). Reinstall prehello, or write an SVG instead: --card=card.svg");
|
|
142
|
+
}
|
|
143
|
+
const width = Number(/<svg[^>]*\swidth="(\d+)"/.exec(svg)?.[1] ?? 1200);
|
|
144
|
+
try {
|
|
145
|
+
const r = new Resvg(toFontSafeText(svg), {
|
|
146
|
+
font: { fontBuffers, defaultFontFamily: exports.PNG_FONT_FAMILY, sansSerifFamily: exports.PNG_FONT_FAMILY },
|
|
147
|
+
fitTo: { mode: "width", value: width * o.scale },
|
|
148
|
+
textRendering: 1,
|
|
149
|
+
shapeRendering: 2,
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
return tagPng(Buffer.from(r.render().asPng()), o.version);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
r.free();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
// never a stack trace: a renderer failure is reported like a missing engine, and SVG keeps working
|
|
160
|
+
throw new PngUnavailableError(`PNG rendering failed (${String(e?.message ?? e).slice(0, 200)}). Write an SVG instead: --card=card.svg`);
|
|
161
|
+
}
|
|
162
|
+
}
|