From e4ff44221994ff9a377d808fe19529a696ee1ea0 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sat, 4 Jul 2026 19:42:26 +0300 Subject: [PATCH] Core: spec-link payload codec; base64 helpers consolidated --- src/core/base64.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/core/font-asset.ts | 21 ++------------- src/core/spec-link.test.ts | 39 +++++++++++++++++++++++++++ src/core/spec-link.ts | 41 +++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 src/core/base64.ts create mode 100644 src/core/spec-link.test.ts create mode 100644 src/core/spec-link.ts diff --git a/src/core/base64.ts b/src/core/base64.ts new file mode 100644 index 0000000..b0b6a97 --- /dev/null +++ b/src/core/base64.ts @@ -0,0 +1,54 @@ +/** + * Shared base64 codecs — the one home for base64 in core (font bytes, spec-link + * payloads). `btoa`/`atob` and `TextEncoder`/`TextDecoder` are platform globals + * in every runtime we target (browsers, the Node test environment), so core + * stays portable without hand-rolled bit twiddling. + * + * Revisit when `Uint8Array.prototype.toBase64`/`fromBase64` (with the + * `base64url` alphabet option) is old enough to assume in arbitrary public + * browsers — it deletes this module. + */ + +/** Base64-encode raw bytes (32 KB chunks to stay under the argument-spread limit). */ +export function bytesToBase64(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +/** Decode standard base64 to raw bytes. Throws on malformed input (caller guards). */ +export function base64ToBytes(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +/** Encode text as unpadded base64url (RFC 4648 §5) — a URL-hash-safe alphabet. */ +export function textToBase64Url(text: string): string { + return bytesToBase64(new TextEncoder().encode(text)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +/** + * Decode unpadded base64url back to text. Total: malformed input — characters + * outside the alphabet, an impossible length, bytes that aren't valid UTF-8 — + * returns `null` rather than throwing. + */ +export function base64UrlToText(payload: string): string | null { + // A base64 stream never leaves exactly 6 leftover bits (length ≡ 1 mod 4). + if (payload.length === 0 || payload.length % 4 === 1) return null; + const b64 = payload.replaceAll('-', '+').replaceAll('_', '/'); + try { + const bytes = base64ToBytes(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return null; + } +} diff --git a/src/core/font-asset.ts b/src/core/font-asset.ts index 07112c1..0e6e142 100644 --- a/src/core/font-asset.ts +++ b/src/core/font-asset.ts @@ -20,6 +20,8 @@ * and the SVG embed treat every FontAsset the same regardless of source. */ +import { base64ToBytes, bytesToBase64 } from './base64'; + /** A FontAsset record's schema version (read-time migration target). */ export const CURRENT_FONT_VERSION = 1; @@ -189,25 +191,6 @@ const FONT_MIME: Record = { otf: 'font/otf', }; -/** Base64-encode raw font bytes (32 KB chunks to stay under the spread limit). */ -function bytesToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -/** Decode base64 back to raw font bytes. Throws on malformed input (caller guards). */ -function base64ToBytes(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes.buffer; -} - /** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */ export function fontDataUri(asset: FontAsset): string { return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`; diff --git a/src/core/spec-link.test.ts b/src/core/spec-link.test.ts new file mode 100644 index 0000000..03f7899 --- /dev/null +++ b/src/core/spec-link.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; +import { decodeSpecPayload, encodeSpecPayload, specLinkHref } from './spec-link'; + +describe('spec-link payload', () => { + test('round-trips ASCII spec text', () => { + const text = JSON.stringify({ mark: 'bar', data: { values: [{ a: 1 }] } }, null, 2); + expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text); + }); + + test('round-trips multi-byte text (Cyrillic, emoji, CJK)', () => { + for (const text of ['"title": "Доходи по кварталах"', '📈 chart', '売上高', 'a']) { + expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text); + } + }); + + test('every byte-length remainder round-trips (1, 2, and 3 mod 3)', () => { + for (const text of ['x', 'xy', 'xyz', 'xyzw']) { + expect(decodeSpecPayload(encodeSpecPayload(text))).toBe(text); + } + }); + + test('the payload uses only hash-safe characters (no percent-escapes)', () => { + const payload = encodeSpecPayload('{"$schema": "https://…", "mark": "point?&#%"}'); + expect(payload).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test('malformed payloads decode to null, never throw', () => { + expect(decodeSpecPayload('')).toBeNull(); + expect(decodeSpecPayload('abc!d')).toBeNull(); // character outside the alphabet + expect(decodeSpecPayload('AAAAA')).toBeNull(); // length ≡ 1 mod 4 is impossible + expect(decodeSpecPayload('_____-__')).toBeNull(); // valid alphabet, invalid UTF-8 + }); + + test('specLinkHref targets the app entry with the spec- prefix', () => { + const href = specLinkHref('{"mark":"bar"}'); + expect(href.startsWith('/app/#spec-')).toBe(true); + expect(decodeSpecPayload(href.slice('/app/#spec-'.length))).toBe('{"mark":"bar"}'); + }); +}); diff --git a/src/core/spec-link.ts b/src/core/spec-link.ts new file mode 100644 index 0000000..6cce1a5 --- /dev/null +++ b/src/core/spec-link.ts @@ -0,0 +1,41 @@ +/** + * Shareable spec links — the `#spec-` one-shot action link (spec §01 + * → Navigation, docs/architecture/04 → action links): the payload carries the + * spec text itself, so a lesson stage or any sender can hand a self-contained + * spec into the app as a URL. + * + * Portable core: both the learn pages (which build these links) and the app + * (which consumes them) need the format, and learn must not import app + * infrastructure — so the encoding is the single source of truth here. + * + * The payload is **base64url** (`core/base64`), not percent-encoding: Firefox + * returns `location.hash` percent-decoded, which corrupts a percent-encoded + * payload on read; base64url's alphabet (`A–Z a–z 0–9 - _`) survives every + * hash read verbatim. + * + * The payload is uncompressed, so link length ≈ 4/3 of the spec text: a lesson + * stage with injected data runs tens of KB — well inside browser URL limits + * (single-digit MB). Compress (e.g. a `spec2-` deflate variant) only if links + * ever need to travel through length-hostile channels. + */ + +import { base64UrlToText, textToBase64Url } from './base64'; + +/** Encode spec text into a URL-hash-safe payload (unpadded base64url of UTF-8). */ +export function encodeSpecPayload(specText: string): string { + return textToBase64Url(specText); +} + +/** + * Decode a payload back to spec text. Total: any malformed input — a truncated + * or hand-mangled link — returns `null` rather than throwing, degrading to + * "no payload". + */ +export function decodeSpecPayload(payload: string): string | null { + return base64UrlToText(payload); +} + +/** The full in-app link for a spec: `/app/#spec-`. */ +export function specLinkHref(specText: string): string { + return `/app/#spec-${encodeSpecPayload(specText)}`; +}