ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
201 lines (171 loc) • 7.83 kB
text/typescript
import { existsSync } from 'node:fs';
import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const REQUIRED_ICON_FILES = [
'favicon.ico',
'favicon-16x16.png',
'favicon-32x32.png',
'android-chrome-192x192.png',
'apple-touch-icon.png',
'mstile-150x150.png',
'mstile-310x310.png',
'safari-pinned-tab.svg',
'browserconfig.xml',
'manifest.json',
] as const;
export const REQUIRED_LOCALES = ['en', 'de', 'es-ES', 'fr', 'ru-RU', 'zh-CN', 'zh-TW'] as const;
const ROOT_HTML_FILES = ['index.html', 'start.html', 'panels.html'] as const;
export interface CopyFileStep {
readonly from: string;
readonly to: string;
}
export interface CopyDirectoryStep {
readonly from: string;
readonly to: string;
}
export interface AssetBuildPlan {
readonly root: string;
readonly destination: string;
readonly socialCalcCandidates: readonly string[];
readonly playerBundle: string;
readonly multiDist: string;
readonly requiredFiles: readonly string[];
readonly requiredDirectories: readonly string[];
readonly staticCopies: readonly CopyFileStep[];
readonly directoryCopies: readonly CopyDirectoryStep[];
}
export function buildAssetPlan(rootInput: string): AssetBuildPlan {
const root = resolve(rootInput);
const destination = join(root, 'assets');
const playerBundle = join(root, 'packages/client/dist/player.js');
const multiDist = join(root, 'packages/client-multi/dist');
const socialCalcCandidates = [
join(root, 'packages/socialcalc-headless/node_modules/socialcalc/dist/SocialCalc.js'),
join(root, 'node_modules/socialcalc/dist/SocialCalc.js'),
];
const staticCopies: CopyFileStep[] = [
...ROOT_HTML_FILES.map((file) => ({ from: join(root, file), to: file })),
...REQUIRED_ICON_FILES.map((file) => ({ from: join(root, file), to: file })),
{ from: join(root, 'manifest.appcache'), to: 'manifest.appcache' },
...REQUIRED_LOCALES.map((locale) => ({
from: join(root, 'l10n', `${locale}.json`),
to: join('l10n', `${locale}.json`),
})),
{ from: playerBundle, to: join('static', 'player.js') },
];
const directoryCopies: CopyDirectoryStep[] = [
{ from: join(root, 'images'), to: 'images' },
{ from: join(root, 'static'), to: 'static' },
{ from: multiDist, to: 'multi' },
];
return {
root,
destination,
socialCalcCandidates,
playerBundle,
multiDist,
requiredFiles: [...staticCopies.map((step) => step.from), playerBundle],
requiredDirectories: directoryCopies.map((step) => step.from),
staticCopies,
directoryCopies,
};
}
export function patchSocialCalcRuntime(input: string): string {
// Strip UMD-level "use strict" wrappers (present in 3.0.x with double
// quotes and 3.1.0 with single quotes; harmless in-browser but stripped
// for parity).
const stripped = input
.replace('(function (root, factory) {\n "use strict";', '(function (root, factory) {')
.replace('function (window) {\n"use strict";', 'function (window) {')
.replace("(function (root, factory) {\n 'use strict';", '(function (root, factory) {')
.replace("function (window) {\n 'use strict';", 'function (window) {');
// SocialCalc 3.1.0 ships its own opt-in rendering security model
// (`untrustedContent`, `securityPolicy.sanitizeHtml`,
// `EscapeUntrustedHtml`, `SafeUrlForRender`). The client enables it at
// boot via `installSecurityPolicy` (see packages/client/src/sanitize-html.ts),
// so no regex-injected sanitiser hook is needed.
if (stripped.includes('EscapeUntrustedHtml')) {
return stripped;
}
// Pre-3.1.0 fallback: inject the SocialCalc.sanitizeHTML hook into the
// text-html render sink (the live-editor stored-XSS fix). The client
// installs SocialCalc.sanitizeHTML at boot; if absent (old cached asset),
// the branch falls back to the raw value.
const htmlSink =
" displayvalue = (SocialCalc.sanitizeHTML ? SocialCalc.sanitizeHTML(displayvalue) : displayvalue);\n";
const patched = stripped.replace(
/(if\s*\(\s*valueformat\s*==\s*["']text-html["']\s*\)\s*)\{[\s\S]*?\}/,
`$1{${"\n"}${htmlSink} }`,
);
if (!patched.includes("SocialCalc.sanitizeHTML(displayvalue)")) {
throw new Error(
"text-html sanitize hook not injected into socialcalc.js (upstream render sink changed?)",
);
}
return patched;
}
function destinationPath(plan: AssetBuildPlan, relativePath: string): string {
return join(plan.destination, relativePath);
}
async function assertFile(path: string, message: string): Promise<void> {
if (!existsSync(path) || !(await stat(path)).isFile()) throw new Error(message);
}
async function assertDirectory(path: string, message: string): Promise<void> {
if (!existsSync(path) || !(await stat(path)).isDirectory()) throw new Error(message);
}
async function copyFileStep(plan: AssetBuildPlan, step: CopyFileStep): Promise<void> {
const target = destinationPath(plan, step.to);
await mkdir(dirname(target), { recursive: true });
await cp(step.from, target);
}
async function copyDirectoryStep(plan: AssetBuildPlan, step: CopyDirectoryStep): Promise<void> {
await cp(step.from, destinationPath(plan, step.to), { recursive: true });
}
async function findSocialCalc(plan: AssetBuildPlan): Promise<string> {
for (const candidate of plan.socialCalcCandidates) {
if (existsSync(candidate) && (await stat(candidate)).isFile()) return candidate;
}
throw new Error(`missing ${plan.socialCalcCandidates[0]} (run \`bun install\`)`);
}
async function collectFiles(dir: string): Promise<string[]> {
const entries = await Array.fromAsync(new Bun.Glob('**/*').scan({ cwd: dir, onlyFiles: true }));
return entries.map((entry) => join(dir, entry));
}
async function summarize(dir: string): Promise<{ count: number; bytes: number }> {
const files = await collectFiles(dir);
const sizes = await Promise.all(files.map(async (file) => (await stat(file)).size));
return { count: files.length, bytes: sizes.reduce((sum, size) => sum + size, 0) };
}
export async function buildAssets(rootInput = resolve(dirname(fileURLToPath(import.meta.url)), '..')): Promise<{
readonly files: number;
readonly bytes: number;
}> {
const plan = buildAssetPlan(rootInput);
const socialCalc = await findSocialCalc(plan);
await assertFile(plan.playerBundle, `missing ${plan.playerBundle} (run \`bun run --cwd packages/client build\`)`);
await assertDirectory(plan.multiDist, `missing ${plan.multiDist} (run \`bun run --cwd packages/client-multi build\`)`);
console.log(`[build-assets] rebuilding ${plan.destination}`);
await rm(plan.destination, { recursive: true, force: true });
await mkdir(join(plan.destination, 'l10n'), { recursive: true });
await mkdir(join(plan.destination, 'static'), { recursive: true });
await mkdir(join(plan.destination, 'multi'), { recursive: true });
await mkdir(join(plan.destination, 'images'), { recursive: true });
for (const step of plan.staticCopies) await copyFileStep(plan, step);
for (const step of plan.directoryCopies) await copyDirectoryStep(plan, step);
const patchedSocialCalc = patchSocialCalcRuntime(await readFile(socialCalc, 'utf8'));
await writeFile(join(plan.destination, 'static/socialcalc.js'), patchedSocialCalc);
const summary = await summarize(plan.destination);
console.log(`[build-assets] wrote ${summary.count} files, ${summary.bytes} bytes`);
return { files: summary.count, bytes: summary.bytes };
}
if (import.meta.main) {
try {
await buildAssets();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[build-assets] FAIL: ${message}`);
process.exit(1);
}
}