nnw-theme 0.0.0 → 1.0.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/LICENSE +201 -0
- package/README.md +75 -1
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/assets/fixtures/article.toml +24 -0
- package/assets/fixtures/kitchen-sink.toml +36 -0
- package/assets/footnotes.js +174 -0
- package/assets/guide/design-checklist.md +19 -0
- package/assets/guide/fixtures.md +42 -0
- package/assets/guide/publishing.md +34 -0
- package/assets/guide/skill.md +44 -0
- package/assets/guide/theme-format.md +52 -0
- package/assets/netnewswire/LICENSE +21 -0
- package/assets/netnewswire/Mac/main_mac.js +43 -0
- package/assets/netnewswire/Mac/page.html +12 -0
- package/assets/netnewswire/Shared/core.css +186 -0
- package/assets/netnewswire/Shared/main.js +221 -0
- package/assets/netnewswire/Shared/newsfoot.js +173 -0
- package/assets/netnewswire/iOS/main_ios.js +520 -0
- package/assets/netnewswire/iOS/page.html +19 -0
- package/assets/netnewswire/netnewswire.json +46 -0
- package/assets/stubs/.agents/skills/creating-nnw-themes/SKILL.md +22 -0
- package/assets/stubs/.agents/skills/creating-nnw-themes/agents/openai.yaml +4 -0
- package/assets/stubs/.github/workflows/check.yml +14 -0
- package/assets/stubs/.github/workflows/pages.yml +24 -0
- package/assets/stubs/.github/workflows/release.yml +24 -0
- package/assets/stubs/.github/workflows/screenshot.yml +27 -0
- package/assets/stubs/AGENTS.md +24 -0
- package/dist/browser.js +227 -0
- package/dist/cli.js +3 -0
- package/dist/commands/bump.js +23 -0
- package/dist/commands/capture.js +26 -0
- package/dist/commands/check.js +67 -0
- package/dist/commands/completion.js +8 -0
- package/dist/commands/guide.js +9 -0
- package/dist/commands/init.js +155 -0
- package/dist/commands/marketplace.js +22 -0
- package/dist/commands/package.js +16 -0
- package/dist/commands/preview.js +49 -0
- package/dist/commands/progress.js +31 -0
- package/dist/commands/release-check.js +45 -0
- package/dist/commands/render.js +17 -0
- package/dist/commands/screenshot.js +36 -0
- package/dist/commands/setup.js +4 -0
- package/dist/commands/update.js +7 -0
- package/dist/commands.js +175 -0
- package/dist/completion.js +203 -0
- package/dist/interactive.js +32 -0
- package/dist/main.js +229 -0
- package/dist/netnewswire.js +84 -0
- package/dist/package.js +28 -0
- package/dist/plist.js +175 -0
- package/dist/project.js +195 -0
- package/dist/pyformat.js +82 -0
- package/dist/render.js +516 -0
- package/dist/stubs.js +49 -0
- package/dist/urlparse.js +32 -0
- package/dist/validate.js +259 -0
- package/dist/zip.js +72 -0
- package/lldb/nnwdump.py +151 -0
- package/package.json +55 -2
package/dist/plist.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// XML property lists, read and written the way Python's plistlib does, so an
|
|
2
|
+
// Info.plist that init or bump rewrites is byte-identical to the Python tool's.
|
|
3
|
+
import { Parser } from "htmlparser2";
|
|
4
|
+
/** A <real>, kept apart from <integer> so a Version of 1.0 is still rejected. */
|
|
5
|
+
export class PlistReal {
|
|
6
|
+
value;
|
|
7
|
+
constructor(value) {
|
|
8
|
+
this.value = value;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function tree(text) {
|
|
12
|
+
const root = { name: "#root", children: [], text: "" };
|
|
13
|
+
const stack = [root];
|
|
14
|
+
let failure;
|
|
15
|
+
const parser = new Parser({
|
|
16
|
+
onopentag(name) {
|
|
17
|
+
const node = { name, children: [], text: "" };
|
|
18
|
+
stack.at(-1)?.children.push(node);
|
|
19
|
+
stack.push(node);
|
|
20
|
+
},
|
|
21
|
+
ontext(data) {
|
|
22
|
+
const node = stack.at(-1);
|
|
23
|
+
if (node)
|
|
24
|
+
node.text += data;
|
|
25
|
+
},
|
|
26
|
+
onclosetag(name) {
|
|
27
|
+
if (stack.at(-1)?.name !== name)
|
|
28
|
+
failure ??= new Error(`mismatched tag </${name}>`);
|
|
29
|
+
stack.pop();
|
|
30
|
+
},
|
|
31
|
+
onerror(error) {
|
|
32
|
+
failure ??= error;
|
|
33
|
+
},
|
|
34
|
+
}, { xmlMode: true, decodeEntities: true });
|
|
35
|
+
parser.end(text);
|
|
36
|
+
if (failure)
|
|
37
|
+
throw failure;
|
|
38
|
+
if (stack.length !== 1)
|
|
39
|
+
throw new Error("unclosed element");
|
|
40
|
+
return root;
|
|
41
|
+
}
|
|
42
|
+
function value(node) {
|
|
43
|
+
switch (node.name) {
|
|
44
|
+
case "dict": {
|
|
45
|
+
const result = {};
|
|
46
|
+
const children = node.children;
|
|
47
|
+
for (let index = 0; index < children.length; index += 2) {
|
|
48
|
+
const key = children[index];
|
|
49
|
+
const item = children[index + 1];
|
|
50
|
+
if (key?.name !== "key" || !item)
|
|
51
|
+
throw new Error("dict keys and values must alternate");
|
|
52
|
+
result[key.text] = value(item);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
case "array":
|
|
57
|
+
return node.children.map(value);
|
|
58
|
+
case "string":
|
|
59
|
+
return node.text;
|
|
60
|
+
case "integer": {
|
|
61
|
+
const text = node.text.trim();
|
|
62
|
+
const parsed = /^[+-]?0x/i.test(text) ? Number.parseInt(text, 16) : Number(text);
|
|
63
|
+
if (!/^[+-]?(0x[0-9a-f]+|\d+)$/i.test(text) || !Number.isSafeInteger(parsed)) {
|
|
64
|
+
throw new Error(`invalid integer ${JSON.stringify(text)}`);
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
case "real": {
|
|
69
|
+
const parsed = Number(node.text.trim());
|
|
70
|
+
if (Number.isNaN(parsed) && !/nan/i.test(node.text)) {
|
|
71
|
+
throw new Error(`invalid real ${JSON.stringify(node.text)}`);
|
|
72
|
+
}
|
|
73
|
+
return new PlistReal(parsed);
|
|
74
|
+
}
|
|
75
|
+
case "true":
|
|
76
|
+
return true;
|
|
77
|
+
case "false":
|
|
78
|
+
return false;
|
|
79
|
+
case "date":
|
|
80
|
+
return new Date(node.text.trim());
|
|
81
|
+
case "data":
|
|
82
|
+
return Uint8Array.from(Buffer.from(node.text.replace(/\s+/g, ""), "base64"));
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`unknown element <${node.name}>`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Parse an XML property list; throws an Error describing what is invalid. */
|
|
88
|
+
export function parsePlist(text) {
|
|
89
|
+
const plist = tree(text).children.find((node) => node.name === "plist");
|
|
90
|
+
const [content, ...rest] = plist?.children ?? [];
|
|
91
|
+
if (!content || rest.length)
|
|
92
|
+
throw new Error("expected one value inside <plist>");
|
|
93
|
+
return value(content);
|
|
94
|
+
}
|
|
95
|
+
function escapeText(text) {
|
|
96
|
+
if (/[\0-\x08\x0b\x0c\x0e-\x1f]/.test(text)) {
|
|
97
|
+
throw new Error("strings can't contain control characters; use bytes instead");
|
|
98
|
+
}
|
|
99
|
+
return text
|
|
100
|
+
.replaceAll("\r\n", "\n")
|
|
101
|
+
.replaceAll("\r", "\n")
|
|
102
|
+
.replaceAll("&", "&")
|
|
103
|
+
.replaceAll("<", "<")
|
|
104
|
+
.replaceAll(">", ">");
|
|
105
|
+
}
|
|
106
|
+
function realText(value) {
|
|
107
|
+
if (Number.isNaN(value))
|
|
108
|
+
return "nan";
|
|
109
|
+
if (!Number.isFinite(value))
|
|
110
|
+
return value > 0 ? "inf" : "-inf";
|
|
111
|
+
const text = String(value);
|
|
112
|
+
if (text.includes("e"))
|
|
113
|
+
return text.replace(/e([+-])(\d)$/, "e$10$2");
|
|
114
|
+
return Number.isInteger(value) ? `${text}.0` : text;
|
|
115
|
+
}
|
|
116
|
+
function lines(item, depth, out) {
|
|
117
|
+
const indent = "\t".repeat(depth);
|
|
118
|
+
if (typeof item === "string")
|
|
119
|
+
out.push(`${indent}<string>${escapeText(item)}</string>`);
|
|
120
|
+
else if (typeof item === "boolean")
|
|
121
|
+
out.push(`${indent}<${item}/>`);
|
|
122
|
+
else if (typeof item === "number") {
|
|
123
|
+
if (!Number.isInteger(item))
|
|
124
|
+
throw new Error("integers must be whole numbers");
|
|
125
|
+
out.push(`${indent}<integer>${item}</integer>`);
|
|
126
|
+
}
|
|
127
|
+
else if (item instanceof PlistReal)
|
|
128
|
+
out.push(`${indent}<real>${realText(item.value)}</real>`);
|
|
129
|
+
else if (item instanceof Date) {
|
|
130
|
+
out.push(`${indent}<date>${item.toISOString().replace(/\.\d{3}Z$/, "Z")}</date>`);
|
|
131
|
+
}
|
|
132
|
+
else if (item instanceof Uint8Array) {
|
|
133
|
+
const encoded = Buffer.from(item).toString("base64");
|
|
134
|
+
const width = Math.max(16, 76 - indent.replaceAll("\t", " ".repeat(8)).length);
|
|
135
|
+
const wrapped = encoded.match(new RegExp(`.{1,${(Math.floor(width / 4) * 3 * 4) / 3}}`, "g"));
|
|
136
|
+
out.push(`${indent}<data>`);
|
|
137
|
+
for (const line of wrapped ?? [])
|
|
138
|
+
out.push(`${indent}${line}`);
|
|
139
|
+
out.push(`${indent}</data>`);
|
|
140
|
+
}
|
|
141
|
+
else if (Array.isArray(item)) {
|
|
142
|
+
if (!item.length)
|
|
143
|
+
out.push(`${indent}<array/>`);
|
|
144
|
+
else {
|
|
145
|
+
out.push(`${indent}<array>`);
|
|
146
|
+
for (const child of item)
|
|
147
|
+
lines(child, depth + 1, out);
|
|
148
|
+
out.push(`${indent}</array>`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const entries = Object.entries(item);
|
|
153
|
+
if (!entries.length)
|
|
154
|
+
out.push(`${indent}<dict/>`);
|
|
155
|
+
else {
|
|
156
|
+
out.push(`${indent}<dict>`);
|
|
157
|
+
for (const [key, child] of entries) {
|
|
158
|
+
out.push(`${indent}\t<key>${escapeText(key)}</key>`);
|
|
159
|
+
lines(child, depth + 1, out);
|
|
160
|
+
}
|
|
161
|
+
out.push(`${indent}</dict>`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** plistlib.dumps(value, fmt=FMT_XML, sort_keys=False). */
|
|
166
|
+
export function buildPlist(item) {
|
|
167
|
+
const out = [
|
|
168
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
169
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
170
|
+
'<plist version="1.0">',
|
|
171
|
+
];
|
|
172
|
+
lines(item, 0, out);
|
|
173
|
+
out.push("</plist>", "");
|
|
174
|
+
return out.join("\n");
|
|
175
|
+
}
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { BUILT_IN_FIXTURE_NAMES } from "./commands.js";
|
|
6
|
+
import { buildPlist, parsePlist } from "./plist.js";
|
|
7
|
+
export const REQUIRED_THEME_FILES = ["Info.plist", "template.html", "stylesheet.css"];
|
|
8
|
+
export const PLACEHOLDER_MARKER = ".nnw-theme-uninitialized";
|
|
9
|
+
export const IDENTITY_START = "<!-- nnw-theme-identity:start -->";
|
|
10
|
+
export const IDENTITY_END = "<!-- nnw-theme-identity:end -->";
|
|
11
|
+
/** The fixtures that ship in the package; a repository's own copy of one wins. */
|
|
12
|
+
export const BUILT_IN_FIXTURES = BUILT_IN_FIXTURE_NAMES;
|
|
13
|
+
/** A user-actionable theme project error. */
|
|
14
|
+
export class ThemeError extends Error {
|
|
15
|
+
name = "ThemeError";
|
|
16
|
+
}
|
|
17
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
/** A path inside the installed package (src/ in a checkout, dist/ when published). */
|
|
19
|
+
export function packagePath(...parts) {
|
|
20
|
+
return join(PACKAGE_ROOT, ...parts);
|
|
21
|
+
}
|
|
22
|
+
function isDirectory(path) {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(path).isDirectory();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isFile(path) {
|
|
31
|
+
try {
|
|
32
|
+
return statSync(path).isFile();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function themeDirectories(root) {
|
|
39
|
+
let names;
|
|
40
|
+
try {
|
|
41
|
+
names = readdirSync(root);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return names
|
|
47
|
+
.filter((name) => name.endsWith(".nnwtheme") && isDirectory(join(root, name)))
|
|
48
|
+
.sort()
|
|
49
|
+
.map((name) => join(root, name));
|
|
50
|
+
}
|
|
51
|
+
/** The nearest directory, from start upward, that holds a root *.nnwtheme bundle. */
|
|
52
|
+
export function findRoot(start = process.cwd()) {
|
|
53
|
+
let current = resolve(start);
|
|
54
|
+
if (isFile(current))
|
|
55
|
+
current = dirname(current);
|
|
56
|
+
for (;;) {
|
|
57
|
+
if (themeDirectories(current).length)
|
|
58
|
+
return current;
|
|
59
|
+
const parent = dirname(current);
|
|
60
|
+
if (parent === current)
|
|
61
|
+
break;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
throw new ThemeError("run this command inside the theme repository");
|
|
65
|
+
}
|
|
66
|
+
export function findTheme(root) {
|
|
67
|
+
const themes = themeDirectories(root);
|
|
68
|
+
const [theme] = themes;
|
|
69
|
+
if (themes.length !== 1 || !theme) {
|
|
70
|
+
const names = themes.map((path) => path.slice(root.length + 1)).join(", ") || "none";
|
|
71
|
+
throw new ThemeError(`expected one .nnwtheme directory at the repository root; found ${names}`);
|
|
72
|
+
}
|
|
73
|
+
return theme;
|
|
74
|
+
}
|
|
75
|
+
/** A bundle's name without .nnwtheme, as Python's Path.stem gives it. */
|
|
76
|
+
export function themeStem(theme) {
|
|
77
|
+
const name = theme.split("/").at(-1) ?? "";
|
|
78
|
+
return name.slice(0, -".nnwtheme".length);
|
|
79
|
+
}
|
|
80
|
+
export function readPlistFile(path) {
|
|
81
|
+
let value;
|
|
82
|
+
try {
|
|
83
|
+
value = parsePlist(readFileSync(path, "utf8"));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new ThemeError(`${path}: invalid property list: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (!value ||
|
|
89
|
+
typeof value !== "object" ||
|
|
90
|
+
Array.isArray(value) ||
|
|
91
|
+
value.constructor !== Object) {
|
|
92
|
+
throw new ThemeError(`${path}: the top-level value must be a dictionary`);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
export function readPlist(theme) {
|
|
97
|
+
return readPlistFile(join(theme, "Info.plist"));
|
|
98
|
+
}
|
|
99
|
+
export function writePlist(path, metadata) {
|
|
100
|
+
writeFileSync(path, buildPlist(metadata), "utf8");
|
|
101
|
+
}
|
|
102
|
+
export function parseFixture(text, name) {
|
|
103
|
+
let value;
|
|
104
|
+
try {
|
|
105
|
+
value = parseToml(text);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw new ThemeError(`${name}: invalid fixture: ${error.message}`);
|
|
109
|
+
}
|
|
110
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
111
|
+
throw new ThemeError(`${name}: fixture must be a table`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
export function readFixture(path) {
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
text = readFileSync(path, "utf8");
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new ThemeError(`${path}: invalid fixture: ${error.message}`);
|
|
122
|
+
}
|
|
123
|
+
return parseFixture(text, path);
|
|
124
|
+
}
|
|
125
|
+
/** The repository's fixture names (fixtures/*.toml), sorted. */
|
|
126
|
+
export function repositoryFixtures(root) {
|
|
127
|
+
let names;
|
|
128
|
+
try {
|
|
129
|
+
names = readdirSync(join(root, "fixtures"));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
return names
|
|
135
|
+
.filter((name) => name.endsWith(".toml") && isFile(join(root, "fixtures", name)))
|
|
136
|
+
.map((name) => name.slice(0, -".toml".length))
|
|
137
|
+
.sort();
|
|
138
|
+
}
|
|
139
|
+
/** Where a fixture lives: the repository's copy, else the package's built-in one. */
|
|
140
|
+
export function fixturePath(root, name) {
|
|
141
|
+
const own = join(root, "fixtures", `${name}.toml`);
|
|
142
|
+
if (existsSync(own))
|
|
143
|
+
return own;
|
|
144
|
+
if (BUILT_IN_FIXTURES.includes(name)) {
|
|
145
|
+
return packagePath("assets", "fixtures", `${name}.toml`);
|
|
146
|
+
}
|
|
147
|
+
throw new ThemeError(`fixture not found: ${own}`);
|
|
148
|
+
}
|
|
149
|
+
function isTable(value) {
|
|
150
|
+
return (!!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A fixture's optional [expect.footnotes] table, validated for the browser check.
|
|
154
|
+
*
|
|
155
|
+
* notes maps each footnote marker's rendered text to the note text its popover must
|
|
156
|
+
* show; plain_links lists CSS selectors for links that must not become footnotes;
|
|
157
|
+
* keep_with_word requires markers written against a word to stay on its line.
|
|
158
|
+
*/
|
|
159
|
+
export function footnoteExpectations(fixture, name) {
|
|
160
|
+
const expect = fixture.expect ?? {};
|
|
161
|
+
const footnotes = isTable(expect) ? (expect.footnotes ?? {}) : undefined;
|
|
162
|
+
if (!isTable(expect) ||
|
|
163
|
+
!isTable(footnotes) ||
|
|
164
|
+
Object.keys(expect).some((key) => key !== "footnotes")) {
|
|
165
|
+
throw new ThemeError(`${name}: [expect] may contain only a [expect.footnotes] table`);
|
|
166
|
+
}
|
|
167
|
+
const unknown = Object.keys(footnotes)
|
|
168
|
+
.filter((key) => !["notes", "plain_links", "keep_with_word"].includes(key))
|
|
169
|
+
.sort();
|
|
170
|
+
if (unknown.length) {
|
|
171
|
+
throw new ThemeError(`${name}: unknown [expect.footnotes] key(s): ${unknown.join(", ")}`);
|
|
172
|
+
}
|
|
173
|
+
const result = {};
|
|
174
|
+
if ("notes" in footnotes) {
|
|
175
|
+
const notes = footnotes.notes;
|
|
176
|
+
if (!isTable(notes) || !Object.values(notes).every((value) => typeof value === "string")) {
|
|
177
|
+
throw new ThemeError(`${name}: [expect.footnotes.notes] must map markers to text`);
|
|
178
|
+
}
|
|
179
|
+
result.notes = notes;
|
|
180
|
+
}
|
|
181
|
+
if ("plain_links" in footnotes) {
|
|
182
|
+
const links = footnotes.plain_links;
|
|
183
|
+
if (!Array.isArray(links) || !links.every((link) => typeof link === "string")) {
|
|
184
|
+
throw new ThemeError(`${name}: expect.footnotes.plain_links must be CSS selectors`);
|
|
185
|
+
}
|
|
186
|
+
result.plain_links = links;
|
|
187
|
+
}
|
|
188
|
+
if ("keep_with_word" in footnotes) {
|
|
189
|
+
if (typeof footnotes.keep_with_word !== "boolean") {
|
|
190
|
+
throw new ThemeError(`${name}: expect.footnotes.keep_with_word must be true or false`);
|
|
191
|
+
}
|
|
192
|
+
result.keep_with_word = footnotes.keep_with_word;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
package/dist/pyformat.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Python's formatting of values that appear in messages and pages, so the port's
|
|
2
|
+
// output matches the Python tool's text exactly.
|
|
3
|
+
const NON_PRINTABLE = /[\p{C}\p{Zl}\p{Zp}]|(?! )\p{Zs}/u;
|
|
4
|
+
/** repr() of a str. */
|
|
5
|
+
export function pyRepr(value) {
|
|
6
|
+
const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
|
|
7
|
+
let result = quote;
|
|
8
|
+
for (const character of value) {
|
|
9
|
+
const code = character.codePointAt(0) ?? 0;
|
|
10
|
+
if (character === "\\")
|
|
11
|
+
result += "\\\\";
|
|
12
|
+
else if (character === quote)
|
|
13
|
+
result += `\\${quote}`;
|
|
14
|
+
else if (character === "\n")
|
|
15
|
+
result += "\\n";
|
|
16
|
+
else if (character === "\r")
|
|
17
|
+
result += "\\r";
|
|
18
|
+
else if (character === "\t")
|
|
19
|
+
result += "\\t";
|
|
20
|
+
else if (NON_PRINTABLE.test(character)) {
|
|
21
|
+
if (code < 0x100)
|
|
22
|
+
result += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
23
|
+
else if (code < 0x10000)
|
|
24
|
+
result += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
25
|
+
else
|
|
26
|
+
result += `\\U${code.toString(16).padStart(8, "0")}`;
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
result += character;
|
|
30
|
+
}
|
|
31
|
+
return result + quote;
|
|
32
|
+
}
|
|
33
|
+
/** repr() of a list of str. */
|
|
34
|
+
export function pyReprList(values) {
|
|
35
|
+
return `[${values.map(pyRepr).join(", ")}]`;
|
|
36
|
+
}
|
|
37
|
+
/** Python's shortest round-tripping float text: 17.0 -> "17.0", 1e16 -> "1e+16". */
|
|
38
|
+
function floatRepr(value) {
|
|
39
|
+
if (Number.isNaN(value))
|
|
40
|
+
return "nan";
|
|
41
|
+
if (!Number.isFinite(value))
|
|
42
|
+
return value > 0 ? "inf" : "-inf";
|
|
43
|
+
const text = String(value);
|
|
44
|
+
if (/e/.test(text))
|
|
45
|
+
return text.replace(/e([+-])(\d)$/, "e$10$2");
|
|
46
|
+
if (Math.abs(value) >= 1e16)
|
|
47
|
+
return value.toExponential().replace(/e([+-])(\d)$/, "e$10$2");
|
|
48
|
+
return Number.isInteger(value) ? `${text}.0` : text;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* str() of a TOML value. Integers and floats are indistinguishable once parsed, so
|
|
52
|
+
* callers that need Python's float text use formatG instead.
|
|
53
|
+
*/
|
|
54
|
+
export function pyStr(value) {
|
|
55
|
+
if (typeof value === "string")
|
|
56
|
+
return value;
|
|
57
|
+
if (typeof value === "boolean")
|
|
58
|
+
return value ? "True" : "False";
|
|
59
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
60
|
+
return String(value);
|
|
61
|
+
if (value === null || value === undefined)
|
|
62
|
+
return "None";
|
|
63
|
+
if (value instanceof Date)
|
|
64
|
+
return value.toISOString();
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
/** format(value, "g"): six significant digits, trailing zeros dropped. */
|
|
68
|
+
export function formatG(value) {
|
|
69
|
+
if (!Number.isFinite(value))
|
|
70
|
+
return floatRepr(value);
|
|
71
|
+
if (value === 0)
|
|
72
|
+
return Object.is(value, -0) ? "-0" : "0";
|
|
73
|
+
const exponent = Math.floor(Math.log10(Math.abs(Number(value.toPrecision(6)))));
|
|
74
|
+
if (exponent < -4 || exponent >= 6) {
|
|
75
|
+
const [mantissa = "", power = "0"] = value.toExponential(5).split("e");
|
|
76
|
+
const trimmed = mantissa.includes(".") ? mantissa.replace(/\.?0+$/, "") : mantissa;
|
|
77
|
+
const sign = power.startsWith("-") ? "-" : "+";
|
|
78
|
+
return `${trimmed}e${sign}${power.replace(/^[+-]/, "").padStart(2, "0")}`;
|
|
79
|
+
}
|
|
80
|
+
const fixed = value.toFixed(Math.max(0, 5 - exponent));
|
|
81
|
+
return fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
|
|
82
|
+
}
|