Release Unicode Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = path.join(root, "src/toolbox/manifest.source.json");
|
||||
const outputPath = path.join(root, "public/toolbox-app.json");
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const versionSource = await readFile(path.join(root, "src/version.ts"), "utf8");
|
||||
const appVersion = /^export const APP_VERSION = "([^"]+)";$/mu.exec(
|
||||
versionSource,
|
||||
)?.[1];
|
||||
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
|
||||
if (source.version !== pkg.version || appVersion !== pkg.version)
|
||||
throw new Error("Version identity drift");
|
||||
if (
|
||||
source.id !== "de.add-ideas." + pkg.name ||
|
||||
source.source?.repository !== repository ||
|
||||
source.source?.license !== "GPL-3.0-or-later"
|
||||
)
|
||||
throw new Error("Manifest source identity is invalid");
|
||||
const serialized = await format(JSON.stringify(source), {
|
||||
filepath: outputPath,
|
||||
});
|
||||
if (process.argv.includes("--check")) {
|
||||
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
|
||||
throw new Error("public/toolbox-app.json is stale");
|
||||
console.log("Toolbox manifest is synchronized");
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log("Generated public/toolbox-app.json");
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const cacheDirectory = join(root, ".cache", "unicode-17.0.0");
|
||||
const outputDirectory = join(root, "public", "data");
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const sources = [
|
||||
{
|
||||
id: "ucd",
|
||||
filename: "UCD.zip",
|
||||
url: "https://www.unicode.org/Public/17.0.0/ucd/UCD.zip",
|
||||
sha256: "2066d1909b2ea93916ce092da1c0ee4808ea3ef8407c94b4f14f5b7eb263d28e",
|
||||
},
|
||||
{
|
||||
id: "unihan",
|
||||
filename: "Unihan.zip",
|
||||
url: "https://www.unicode.org/Public/17.0.0/ucd/Unihan.zip",
|
||||
sha256: "f7a48b2b545acfaa77b2d607ae28747404ce02baefee16396c5d2d7a8ef34b5e",
|
||||
},
|
||||
{
|
||||
id: "emoji-test",
|
||||
filename: "emoji-test.txt",
|
||||
url: "https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt",
|
||||
sha256: "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda",
|
||||
},
|
||||
{
|
||||
id: "emoji-sequences",
|
||||
filename: "emoji-sequences.txt",
|
||||
url: "https://www.unicode.org/Public/17.0.0/emoji/emoji-sequences.txt",
|
||||
sha256: "12cc8267dc33cbd11ed32bcf6fc5dc2ad9c7a77bae1bdfba2f41b1b9b3ead8dd",
|
||||
},
|
||||
{
|
||||
id: "emoji-zwj",
|
||||
filename: "emoji-zwj-sequences.txt",
|
||||
url: "https://www.unicode.org/Public/17.0.0/emoji/emoji-zwj-sequences.txt",
|
||||
sha256: "5b25441daed2322b068c5e70cda522946a4f0274df864445a1965a92e5fc5cad",
|
||||
},
|
||||
{
|
||||
id: "uts39",
|
||||
filename: "uts39-data-17.0.0.zip",
|
||||
url: "https://www.unicode.org/Public/security/latest/uts39-data-17.0.0.zip",
|
||||
sha256: "349b8d9bbb4718e6bb58a8e8ee774857762e8121742e762e1bfb7152bd4c4238",
|
||||
},
|
||||
{
|
||||
id: "license",
|
||||
filename: "UNICODE-LICENSE-3.txt",
|
||||
url: "https://www.unicode.org/license.txt",
|
||||
sha256: "e7a93b009565cfce55919a381437ac4db883e9da2126fa28b91d12732bc53d96",
|
||||
},
|
||||
];
|
||||
|
||||
function hash(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function acquire(source) {
|
||||
const target = join(cacheDirectory, source.filename);
|
||||
try {
|
||||
const cached = await readFile(target);
|
||||
if (hash(cached) === source.sha256) return cached;
|
||||
} catch {
|
||||
/* Download below. */
|
||||
}
|
||||
const response = await fetch(source.url, { redirect: "follow" });
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Unable to download ${source.url}: HTTP ${response.status}`,
|
||||
);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
const actual = hash(bytes);
|
||||
if (actual !== source.sha256)
|
||||
throw new Error(
|
||||
`SHA-256 mismatch for ${source.filename}: expected ${source.sha256}, got ${actual}`,
|
||||
);
|
||||
await writeFile(target, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function textFromArchive(archive, name) {
|
||||
const value = archive[name];
|
||||
if (!value) throw new Error(`Required Unicode data file is absent: ${name}`);
|
||||
return decoder.decode(value);
|
||||
}
|
||||
|
||||
function lines(text) {
|
||||
return text.replaceAll("\r\n", "\n").split("\n");
|
||||
}
|
||||
|
||||
function parseRange(token) {
|
||||
const [start, end = start] = token.trim().split("..");
|
||||
return [Number.parseInt(start, 16), Number.parseInt(end, 16)];
|
||||
}
|
||||
|
||||
function parseRanges(text) {
|
||||
const result = [];
|
||||
for (const raw of lines(text)) {
|
||||
const content = raw.split("#", 1)[0]?.trim();
|
||||
if (!content) continue;
|
||||
const [rangeToken, valueToken] = content
|
||||
.split(";")
|
||||
.map((value) => value.trim());
|
||||
if (!rangeToken || !valueToken) continue;
|
||||
const [start, end] = parseRange(rangeToken);
|
||||
result.push({ start, end, value: valueToken });
|
||||
}
|
||||
return result.sort((left, right) => left.start - right.start);
|
||||
}
|
||||
|
||||
function findRange(ranges, point, fallback) {
|
||||
let low = 0;
|
||||
let high = ranges.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const range = ranges[middle];
|
||||
if (point < range.start) high = middle - 1;
|
||||
else if (point > range.end) low = middle + 1;
|
||||
else return range.value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseDerivedNames(text) {
|
||||
const values = new Map();
|
||||
for (const raw of lines(text)) {
|
||||
const content = raw.split("#", 1)[0]?.trim();
|
||||
if (!content) continue;
|
||||
const [rangeToken, nameToken] = content
|
||||
.split(";")
|
||||
.map((value) => value.trim());
|
||||
if (!rangeToken || !nameToken) continue;
|
||||
const [start, end] = parseRange(rangeToken);
|
||||
for (let point = start; point <= end; point += 1) {
|
||||
values.set(
|
||||
point,
|
||||
nameToken.includes("*")
|
||||
? nameToken.replace(
|
||||
"*",
|
||||
point.toString(16).toUpperCase().padStart(4, "0"),
|
||||
)
|
||||
: nameToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseAliases(text) {
|
||||
const values = new Map();
|
||||
for (const raw of lines(text)) {
|
||||
const content = raw.split("#", 1)[0]?.trim();
|
||||
if (!content) continue;
|
||||
const [pointToken, alias, type] = content
|
||||
.split(";")
|
||||
.map((value) => value.trim());
|
||||
const point = Number.parseInt(pointToken, 16);
|
||||
if (!Number.isFinite(point) || !alias) continue;
|
||||
const list = values.get(point) ?? [];
|
||||
list.push(type ? `${alias} (${type})` : alias);
|
||||
values.set(point, list);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseEmojiFlags(text) {
|
||||
const flags = new Map();
|
||||
const bitByProperty = new Map([
|
||||
["Emoji", 1],
|
||||
["Emoji_Presentation", 2],
|
||||
["Emoji_Modifier", 4],
|
||||
["Emoji_Modifier_Base", 8],
|
||||
["Extended_Pictographic", 16],
|
||||
]);
|
||||
for (const range of parseRanges(text)) {
|
||||
const bit = bitByProperty.get(range.value);
|
||||
if (!bit) continue;
|
||||
for (let point = range.start; point <= range.end; point += 1)
|
||||
flags.set(point, (flags.get(point) ?? 0) | bit);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseEmoji(text) {
|
||||
const result = [];
|
||||
let group = "Other";
|
||||
let subgroup = "Other";
|
||||
for (const raw of lines(text)) {
|
||||
if (raw.startsWith("# group:")) {
|
||||
group = raw.slice(8).trim();
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith("# subgroup:")) {
|
||||
subgroup = raw.slice(11).trim();
|
||||
continue;
|
||||
}
|
||||
const [definition, annotation] = raw.split("#");
|
||||
if (!definition || !annotation) continue;
|
||||
const [pointsToken, statusToken] = definition
|
||||
.split(";")
|
||||
.map((value) => value.trim());
|
||||
const status = statusToken?.trim();
|
||||
if (status !== "fully-qualified" && status !== "component") continue;
|
||||
const match = /^\s*(\S+)\s+E([0-9.]+)\s+(.+)$/u.exec(annotation);
|
||||
if (!match || !pointsToken) continue;
|
||||
const points = pointsToken
|
||||
.split(/\s+/u)
|
||||
.map((value) => Number.parseInt(value, 16));
|
||||
result.push([
|
||||
points,
|
||||
match[1],
|
||||
match[3],
|
||||
group,
|
||||
subgroup,
|
||||
status,
|
||||
match[2],
|
||||
]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseNamedSequences(text) {
|
||||
const result = [];
|
||||
for (const raw of lines(text)) {
|
||||
const content = raw.split("#", 1)[0]?.trim();
|
||||
if (!content) continue;
|
||||
const [name, pointsToken] = content.split(";").map((value) => value.trim());
|
||||
if (!name || !pointsToken) continue;
|
||||
const points = pointsToken
|
||||
.split(/\s+/u)
|
||||
.map((value) => Number.parseInt(value, 16));
|
||||
result.push([name, points, String.fromCodePoint(...points)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseConfusables(text) {
|
||||
const result = [];
|
||||
for (const raw of lines(text)) {
|
||||
const content = raw.split("#", 1)[0]?.trim();
|
||||
if (!content) continue;
|
||||
const [sourceToken, targetToken, type] = content
|
||||
.split(";")
|
||||
.map((value) => value.trim());
|
||||
if (!sourceToken || !targetToken) continue;
|
||||
const source = String.fromCodePoint(
|
||||
...sourceToken.split(/\s+/u).map((value) => Number.parseInt(value, 16)),
|
||||
);
|
||||
const target = String.fromCodePoint(
|
||||
...targetToken.split(/\s+/u).map((value) => Number.parseInt(value, 16)),
|
||||
);
|
||||
result.push([source, target, type]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseUnihan(archive) {
|
||||
const wanted = new Map([
|
||||
["kDefinition", 1],
|
||||
["kMandarin", 2],
|
||||
["kCantonese", 3],
|
||||
["kJapaneseOn", 4],
|
||||
["kJapaneseKun", 5],
|
||||
["kKorean", 6],
|
||||
["kTotalStrokes", 7],
|
||||
["kRSUnicode", 8],
|
||||
]);
|
||||
const records = new Map();
|
||||
for (const [name, bytes] of Object.entries(archive)) {
|
||||
if (!name.endsWith(".txt")) continue;
|
||||
for (const raw of lines(decoder.decode(bytes))) {
|
||||
if (!raw.startsWith("U+")) continue;
|
||||
const [pointToken, field, value] = raw.split("\t");
|
||||
const index = wanted.get(field);
|
||||
if (!index || !value) continue;
|
||||
const point = Number.parseInt(pointToken.slice(2), 16);
|
||||
const record = records.get(point) ?? [
|
||||
point,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
record[index] = value;
|
||||
records.set(point, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].sort((left, right) => left[0] - right[0]);
|
||||
}
|
||||
|
||||
function dictionaryIndex(dictionary, value) {
|
||||
let index = dictionary.index.get(value);
|
||||
if (index !== undefined) return index;
|
||||
index = dictionary.values.length;
|
||||
dictionary.values.push(value);
|
||||
dictionary.index.set(value, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function scalarOrNull(value) {
|
||||
return value ? Number.parseInt(value, 16) : null;
|
||||
}
|
||||
|
||||
await mkdir(cacheDirectory, { recursive: true });
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const acquired = new Map();
|
||||
for (const source of sources) acquired.set(source.id, await acquire(source));
|
||||
|
||||
const ucd = unzipSync(new Uint8Array(acquired.get("ucd")));
|
||||
const unihan = unzipSync(new Uint8Array(acquired.get("unihan")));
|
||||
const security = unzipSync(new Uint8Array(acquired.get("uts39")));
|
||||
const scripts = parseRanges(textFromArchive(ucd, "Scripts.txt"));
|
||||
const blocks = parseRanges(textFromArchive(ucd, "Blocks.txt"));
|
||||
const ages = parseRanges(textFromArchive(ucd, "DerivedAge.txt"));
|
||||
const names = parseDerivedNames(
|
||||
textFromArchive(ucd, "extracted/DerivedName.txt"),
|
||||
);
|
||||
const aliases = parseAliases(textFromArchive(ucd, "NameAliases.txt"));
|
||||
const emojiFlags = parseEmojiFlags(
|
||||
textFromArchive(ucd, "emoji/emoji-data.txt"),
|
||||
);
|
||||
|
||||
const dictionaries = {
|
||||
categories: { values: [], index: new Map() },
|
||||
bidiClasses: { values: [], index: new Map() },
|
||||
scripts: { values: [], index: new Map() },
|
||||
blocks: { values: [], index: new Map() },
|
||||
ages: { values: [], index: new Map() },
|
||||
};
|
||||
const planes = new Map();
|
||||
let pendingRange;
|
||||
|
||||
function addRecord(point, fields) {
|
||||
// Private-use code points and UTF-16 surrogates have no interoperable
|
||||
// character identity. Their ranges remain visible through Blocks.txt, but
|
||||
// emitting one synthetic search row per code point would add ~140,000
|
||||
// misleading records and more than 10 MiB to every release.
|
||||
if (fields[2] === "Co" || fields[2] === "Cs") return;
|
||||
const plane = point >>> 16;
|
||||
const list = planes.get(plane) ?? [];
|
||||
list.push([
|
||||
point,
|
||||
names.get(point) ?? fields[1] ?? "",
|
||||
dictionaryIndex(dictionaries.categories, fields[2] || "Cn"),
|
||||
dictionaryIndex(dictionaries.scripts, findRange(scripts, point, "Unknown")),
|
||||
dictionaryIndex(dictionaries.blocks, findRange(blocks, point, "No_Block")),
|
||||
dictionaryIndex(dictionaries.ages, findRange(ages, point, "Unassigned")),
|
||||
Number(fields[3] || 0),
|
||||
dictionaryIndex(dictionaries.bidiClasses, fields[4] || "L"),
|
||||
fields[5] || "",
|
||||
fields[6] || "",
|
||||
fields[8] || "",
|
||||
fields[9] === "Y" ? 1 : 0,
|
||||
scalarOrNull(fields[12]),
|
||||
scalarOrNull(fields[13]),
|
||||
scalarOrNull(fields[14]),
|
||||
aliases.get(point) ?? null,
|
||||
emojiFlags.get(point) ?? 0,
|
||||
]);
|
||||
planes.set(plane, list);
|
||||
}
|
||||
|
||||
for (const raw of lines(textFromArchive(ucd, "UnicodeData.txt"))) {
|
||||
if (!raw) continue;
|
||||
const fields = raw.split(";");
|
||||
const point = Number.parseInt(fields[0], 16);
|
||||
if (fields[1]?.endsWith(", First>")) {
|
||||
pendingRange = { point, fields };
|
||||
continue;
|
||||
}
|
||||
if (fields[1]?.endsWith(", Last>")) {
|
||||
if (!pendingRange)
|
||||
throw new Error(`Range end without start at U+${fields[0]}`);
|
||||
for (let current = pendingRange.point; current <= point; current += 1)
|
||||
addRecord(current, pendingRange.fields);
|
||||
pendingRange = undefined;
|
||||
continue;
|
||||
}
|
||||
addRecord(point, fields);
|
||||
}
|
||||
if (pendingRange) throw new Error("Unclosed range in UnicodeData.txt");
|
||||
|
||||
const planeManifest = [];
|
||||
let characterCount = 0;
|
||||
for (const [plane, records] of [...planes].sort(
|
||||
(left, right) => left[0] - right[0],
|
||||
)) {
|
||||
const filename = `characters-${plane}.json`;
|
||||
await writeFile(join(outputDirectory, filename), JSON.stringify(records));
|
||||
planeManifest.push({ plane, filename, count: records.length });
|
||||
characterCount += records.length;
|
||||
}
|
||||
|
||||
const emoji = parseEmoji(decoder.decode(acquired.get("emoji-test")));
|
||||
const namedSequences = parseNamedSequences(
|
||||
textFromArchive(ucd, "NamedSequences.txt"),
|
||||
);
|
||||
const confusables = parseConfusables(
|
||||
textFromArchive(security, "confusables.txt"),
|
||||
);
|
||||
const unihanRecords = parseUnihan(unihan);
|
||||
await writeFile(join(outputDirectory, "emoji.json"), JSON.stringify(emoji));
|
||||
await writeFile(
|
||||
join(outputDirectory, "named-sequences.json"),
|
||||
JSON.stringify(namedSequences),
|
||||
);
|
||||
await writeFile(
|
||||
join(outputDirectory, "confusables.json"),
|
||||
JSON.stringify(confusables),
|
||||
);
|
||||
await writeFile(
|
||||
join(outputDirectory, "unihan.json"),
|
||||
JSON.stringify(unihanRecords),
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
unicodeVersion: "17.0.0",
|
||||
emojiVersion: "17.0",
|
||||
characterCount,
|
||||
planes: planeManifest,
|
||||
dictionaries: Object.fromEntries(
|
||||
Object.entries(dictionaries).map(([key, value]) => [key, value.values]),
|
||||
),
|
||||
blockRanges: blocks,
|
||||
scriptRanges: scripts,
|
||||
emojiCount: emoji.length,
|
||||
namedSequenceCount: namedSequences.length,
|
||||
confusableMappingCount: confusables.length,
|
||||
unihanCount: unihanRecords.length,
|
||||
sourceIdentity: sources.map(({ id, filename, url, sha256 }) => ({
|
||||
id,
|
||||
filename,
|
||||
url,
|
||||
sha256,
|
||||
})),
|
||||
};
|
||||
await writeFile(
|
||||
join(outputDirectory, "manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
join(outputDirectory, "SOURCES.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, unicodeVersion: "17.0.0", sources: manifest.sourceIdentity }, null, 2)}\n`,
|
||||
);
|
||||
await mkdir(join(root, "LICENSES"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "LICENSES", "UNICODE-3.0.txt"),
|
||||
acquired.get("license"),
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`Generated ${characterCount.toLocaleString("en")} characters, ${emoji.length.toLocaleString("en")} emoji, ${unihanRecords.length.toLocaleString("en")} Unihan records, and ${confusables.length.toLocaleString("en")} confusable mappings.\n`,
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const execute = promisify(execFile);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
||||
const argument = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
const output = path.resolve(
|
||||
root,
|
||||
argument("--output", "release/" + pkg.name + "-" + pkg.version + ".zip"),
|
||||
);
|
||||
const checksumOutput = output + ".sha256";
|
||||
const force = process.argv.includes("--force");
|
||||
if (
|
||||
path.extname(output).toLowerCase() !== ".zip" ||
|
||||
output === root ||
|
||||
output === path.parse(output).root
|
||||
)
|
||||
throw new Error("Unsafe release target");
|
||||
const exists = (file) =>
|
||||
access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
||||
throw new Error("Release output exists");
|
||||
const input = path.join(root, "dist");
|
||||
for (const name of [
|
||||
"index.html",
|
||||
"manifest.webmanifest",
|
||||
"sw.js",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"LICENSE",
|
||||
"SECURITY.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSES/README.md",
|
||||
"LICENSES/npm-runtime-licenses.txt",
|
||||
"docs/ACCESSIBILITY.md",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/PRIVACY-SECURITY.md",
|
||||
]) {
|
||||
const details = await lstat(path.join(input, name)).catch(() => null);
|
||||
if (!details?.isFile() || details.isSymbolicLink())
|
||||
throw new Error("Missing release file: " + name);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
|
||||
);
|
||||
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
|
||||
if (
|
||||
manifest.id !== "de.add-ideas." + pkg.name ||
|
||||
manifest.version !== pkg.version ||
|
||||
manifest.entry !== "./" ||
|
||||
manifest.icon !== "./favicon.svg" ||
|
||||
manifest.source?.repository !== repository
|
||||
)
|
||||
throw new Error("Packaged manifest identity is invalid");
|
||||
if (
|
||||
/\b(?:src|href)=["']\//iu.test(
|
||||
await readFile(path.join(input, "index.html"), "utf8"),
|
||||
)
|
||||
)
|
||||
throw new Error("Root-absolute asset reference");
|
||||
async function collect(directory, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
)) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
if (entry.isSymbolicLink())
|
||||
throw new Error("Symlink in release: " + relative);
|
||||
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
|
||||
else if (entry.isFile()) files.push({ absolute, relative });
|
||||
else throw new Error("Unsupported release entry: " + relative);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
const sourceFiles = await collect(input);
|
||||
for (const file of sourceFiles)
|
||||
if (
|
||||
file.relative.endsWith(".map") ||
|
||||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
|
||||
file.relative,
|
||||
) ||
|
||||
file.relative.split("/").includes("..")
|
||||
)
|
||||
throw new Error("Forbidden release entry: " + file.relative);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const stagingRoot = await mkdtemp(
|
||||
path.join(os.tmpdir(), pkg.name + "-release-"),
|
||||
);
|
||||
const publicationRoot = await mkdtemp(
|
||||
path.join(path.dirname(output), "." + pkg.name + "-publish-"),
|
||||
);
|
||||
const stagedTree = path.join(stagingRoot, "tree");
|
||||
const stagedArchive = path.join(stagingRoot, path.basename(output));
|
||||
try {
|
||||
await cp(input, stagedTree, { recursive: true });
|
||||
const timestamp = new Date("1980-01-01T00:00:00.000Z");
|
||||
for (const file of await collect(stagedTree)) {
|
||||
await chmod(file.absolute, 0o644);
|
||||
await utimes(file.absolute, timestamp, timestamp);
|
||||
}
|
||||
await execute(
|
||||
"zip",
|
||||
[
|
||||
"-X",
|
||||
"-q",
|
||||
"-9",
|
||||
stagedArchive,
|
||||
...sourceFiles.map((file) => file.relative),
|
||||
],
|
||||
{
|
||||
cwd: stagedTree,
|
||||
env: { ...process.env, TZ: "UTC" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const archive = await readFile(stagedArchive);
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
const stagedChecksum = stagedArchive + ".sha256";
|
||||
await writeFile(stagedChecksum, digest + " " + path.basename(output) + "\n");
|
||||
const publicationArchive = path.join(publicationRoot, path.basename(output));
|
||||
const publicationChecksum = publicationArchive + ".sha256";
|
||||
await copyFile(stagedArchive, publicationArchive);
|
||||
await copyFile(stagedChecksum, publicationChecksum);
|
||||
if (force) {
|
||||
await rm(output, { force: true });
|
||||
await rm(checksumOutput, { force: true });
|
||||
}
|
||||
await rename(publicationArchive, output);
|
||||
await rename(publicationChecksum, checksumOutput);
|
||||
console.log("Created " + path.relative(root, output) + "\nSHA-256 " + digest);
|
||||
} finally {
|
||||
await rm(stagingRoot, { recursive: true, force: true });
|
||||
await rm(publicationRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const destination = path.join(root, "public");
|
||||
for (const name of [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
]) {
|
||||
await readFile(path.join(root, name));
|
||||
await cp(path.join(root, name), path.join(destination, name));
|
||||
}
|
||||
for (const directory of ["LICENSES", "docs"]) {
|
||||
const output = path.join(destination, directory);
|
||||
await rm(output, { recursive: true, force: true });
|
||||
await cp(path.join(root, directory), output, { recursive: true });
|
||||
}
|
||||
const lock = JSON.parse(
|
||||
await readFile(path.join(root, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const sections = [];
|
||||
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
)) {
|
||||
if (!location.includes("node_modules/") || locked.dev === true) continue;
|
||||
const packageDirectory = path.join(root, location);
|
||||
const details = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
);
|
||||
const candidates = (await readdir(packageDirectory))
|
||||
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
|
||||
.sort();
|
||||
const texts = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
texts.push(
|
||||
"--- " +
|
||||
candidate +
|
||||
" ---\n" +
|
||||
(await readFile(path.join(packageDirectory, candidate), "utf8")),
|
||||
);
|
||||
} catch {
|
||||
/* directory */
|
||||
}
|
||||
}
|
||||
sections.push(
|
||||
"=".repeat(78) +
|
||||
"\n" +
|
||||
details.name +
|
||||
"@" +
|
||||
details.version +
|
||||
"\nDeclared licence: " +
|
||||
(details.license ?? locked.license ?? "See upstream") +
|
||||
"\n" +
|
||||
"=".repeat(78) +
|
||||
"\n" +
|
||||
(texts.join("\n\n") || "See upstream package metadata."),
|
||||
);
|
||||
}
|
||||
await mkdir(path.join(destination, "LICENSES"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(destination, "LICENSES/npm-runtime-licenses.txt"),
|
||||
sections.join("\n\n").trimEnd() + "\n",
|
||||
);
|
||||
console.log("Prepared release documentation");
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"dist",
|
||||
);
|
||||
const prefix = "/deep/nested/unicode/";
|
||||
const types = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".md", "text/markdown; charset=utf-8"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
[".wasm", "application/wasm"],
|
||||
[".png", "image/png"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".webp", "image/webp"],
|
||||
]);
|
||||
const headers = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Permissions-Policy":
|
||||
"camera=(), microphone=(), geolocation=(), usb=(), payment=()",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const relative = decodeURIComponent(url.pathname).startsWith(prefix)
|
||||
? decodeURIComponent(url.pathname).slice(prefix.length)
|
||||
: decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
const normalized = path.posix.normalize(relative || "index.html");
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
let file = path.join(root, normalized);
|
||||
if ((await stat(file).catch(() => null))?.isDirectory())
|
||||
file = path.join(file, "index.html");
|
||||
const content = await readFile(file);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
types.get(path.extname(file)) ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...headers,
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
server.listen(4173, "127.0.0.1", () => console.log("Test server ready"));
|
||||
Reference in New Issue
Block a user