Core: spec-link payload codec; base64 helpers consolidated

This commit is contained in:
2026-07-04 19:42:26 +03:00
parent acf14a8b13
commit e4ff442219
4 changed files with 136 additions and 19 deletions
+54
View File
@@ -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;
}
}
+2 -19
View File
@@ -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<FontFormat, string> = {
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)}`;
+39
View File
@@ -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"}');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Shareable spec links — the `#spec-<payload>` 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 (`AZ az 09 - _`) 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-<payload>`. */
export function specLinkHref(specText: string): string {
return `/app/#spec-${encodeSpecPayload(specText)}`;
}