Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions index.html

Large diffs are not rendered by default.

Binary file removed public/icons/icon-192.png
Binary file not shown.
Binary file removed public/icons/icon-512.png
Binary file not shown.
107 changes: 106 additions & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,112 @@ import { build } from 'esbuild';
import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { relative } from 'node:path';
import { deflateSync } from 'node:zlib';

const outdir = new URL('../dist/', import.meta.url);
const templateUrl = new URL('../index.html', import.meta.url);
const publicDir = new URL('../public/', import.meta.url);
const serviceWorkerTemplateUrl = new URL('../src/sw.js', import.meta.url);

const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
let crcTable = null;

function crc32(buffer) {
if (!crcTable) {
crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let value = n;
for (let k = 0; k < 8; k++) {
value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
}
crcTable[n] = value >>> 0;
}
}

let crc = 0xffffffff;
for (const byte of buffer) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

function pngChunk(type, data) {
const typeBytes = Buffer.from(type, 'ascii');
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);

const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])));

return Buffer.concat([length, typeBytes, data, crc]);
}

function createAppIcon(size) {
const rowSize = 1 + size * 4;
const pixels = Buffer.alloc(rowSize * size);
const pad = Math.floor(size / 8);
const stroke = Math.max(4, Math.floor(size / 24));

const isSigmaPixel = (x, y) => {
const top = Math.floor(size * 0.29);
const bottom = Math.floor(size * 0.71);
const left = Math.floor(size * 0.31);
const right = Math.floor(size * 0.69);
const mid = Math.floor(size * 0.50);

if (Math.abs(y - top) <= stroke && x >= left && x <= right) return true;
if (Math.abs(y - bottom) <= stroke && x >= left && x <= right) return true;

const upperProgress = (y - top) / Math.max(1, mid - top);
const lowerProgress = (bottom - y) / Math.max(1, bottom - mid);
if (y >= top && y <= mid) {
const lineX = Math.round(left + upperProgress * (mid - left));
if (Math.abs(x - lineX) <= stroke) return true;
}
if (y > mid && y <= bottom) {
const lineX = Math.round(left + lowerProgress * (mid - left));
if (Math.abs(x - lineX) <= stroke) return true;
}
return false;
};

for (let y = 0; y < size; y++) {
const rowOffset = y * rowSize;
pixels[rowOffset] = 0;

for (let x = 0; x < size; x++) {
const offset = rowOffset + 1 + x * 4;
const insideCard = x >= pad && x < size - pad && y >= pad && y < size - pad;
const sigma = insideCard && isSigmaPixel(x, y);

const color = sigma || !insideCard
? [25, 113, 194, 255]
: [255, 255, 255, 255];

pixels[offset] = color[0];
pixels[offset + 1] = color[1];
pixels[offset + 2] = color[2];
pixels[offset + 3] = color[3];
}
}

const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;

return Buffer.concat([
PNG_SIGNATURE,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(pixels, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0))
]);
}

await rm(outdir, { recursive: true, force: true });
await mkdir(outdir, { recursive: true });

Expand All @@ -31,14 +131,19 @@ await build({

await cp(publicDir, outdir, { recursive: true });

const iconsDir = new URL('../dist/icons/', import.meta.url);
await mkdir(iconsDir, { recursive: true });
await writeFile(new URL('icon-192.png', iconsDir), createAppIcon(192));
await writeFile(new URL('icon-512.png', iconsDir), createAppIcon(512));

let html = await readFile(templateUrl, 'utf8');
html = html.replace(
'<!-- MATHNOTE_BUILD_CSS -->',
'<link rel="stylesheet" href="./app.css">'
);
html = html.replace(
'<!-- MATHNOTE_BUILD_JS -->',
'<script defer src="./app.js"><\\/script>'
'<script defer src="./app.js"></script>'
);

if (html.includes('MATHNOTE_BUILD_')) {
Expand Down
78 changes: 78 additions & 0 deletions scripts/verify-offline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,63 @@ const textExtensions = new Set(['.html', '.js', '.css', '.json', '.webmanifest',
const violations = [];
let fontCount = 0;

const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
let crcTable = null;

function getCrcTable() {
if (crcTable) return crcTable;
crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c >>> 0;
}
return crcTable;
}

function crc32(buffer) {
const table = getCrcTable();
let crc = 0xffffffff;
for (const byte of buffer) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

async function verifyPng(path, expectedWidth, expectedHeight) {
const bytes = await readFile(new URL(path, root));
if (bytes.length < 33 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('invalid PNG signature');

let offset = 8;
let width = null;
let height = null;
let sawIend = false;

while (offset + 12 <= bytes.length) {
const length = bytes.readUInt32BE(offset);
const typeStart = offset + 4;
const dataStart = offset + 8;
const crcOffset = dataStart + length;
const nextOffset = crcOffset + 4;
if (nextOffset > bytes.length) throw new Error('truncated PNG chunk');

const type = bytes.toString('ascii', typeStart, typeStart + 4);
const expectedCrc = bytes.readUInt32BE(crcOffset);
const actualCrc = crc32(bytes.subarray(typeStart, crcOffset));
if (actualCrc !== expectedCrc) throw new Error(`bad CRC in ${type} chunk`);

if (type === 'IHDR') {
width = bytes.readUInt32BE(dataStart);
height = bytes.readUInt32BE(dataStart + 4);
} else if (type === 'IEND') {
sawIend = true;
break;
}
offset = nextOffset;
}

if (!sawIend) throw new Error('missing IEND chunk');
if (width !== expectedWidth || height !== expectedHeight) throw new Error(`unexpected dimensions ${width}x${height}`);
}

async function walk(dirUrl) {
const entries = await readdir(dirUrl, { withFileTypes: true });
for (const entry of entries) {
Expand Down Expand Up @@ -60,6 +117,16 @@ if (!html.includes('rel="manifest" href="./manifest.webmanifest"')) {
violations.push('index.html: manifest link missing or not relative');
}

if (!html.includes('<script defer src="./app.js"></script>')) {
violations.push('index.html: app.js script tag is missing or malformed');
}
if (html.includes('<\\/script>')) {
violations.push('index.html: escaped script closing tag must not be emitted');
}
if (!html.includes('<meta name="mobile-web-app-capable" content="yes">')) {
violations.push('index.html: mobile-web-app-capable meta tag missing');
}

if (!html.includes('id="examples-btn"')) {
violations.push('index.html: examples button missing');
}
Expand Down Expand Up @@ -90,6 +157,17 @@ const iconSizes = new Set((manifest.icons || []).map(icon => icon.sizes));
if (!iconSizes.has('192x192')) violations.push('manifest: 192x192 icon missing');
if (!iconSizes.has('512x512')) violations.push('manifest: 512x512 icon missing');

for (const [path, width, height] of [
['icons/icon-192.png', 192, 192],
['icons/icon-512.png', 512, 512]
]) {
try {
await verifyPng(path, width, height);
} catch (error) {
violations.push(`${path}: ${error.message}`);
}
}

const sw = await readFile(new URL('sw.js', root), 'utf8');
if (sw.includes('__CACHE_NAME__') || sw.includes('__PRECACHE_ASSETS__')) {
violations.push('sw.js: build placeholders were not replaced');
Expand Down
Loading