mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Core: spec-link payload codec; base64 helpers consolidated
This commit is contained in:
@@ -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
@@ -20,6 +20,8 @@
|
|||||||
* and the SVG embed treat every FontAsset the same regardless of source.
|
* 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). */
|
/** A FontAsset record's schema version (read-time migration target). */
|
||||||
export const CURRENT_FONT_VERSION = 1;
|
export const CURRENT_FONT_VERSION = 1;
|
||||||
|
|
||||||
@@ -189,25 +191,6 @@ const FONT_MIME: Record<FontFormat, string> = {
|
|||||||
otf: 'font/otf',
|
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. */
|
/** A `data:` URL embedding a face's bytes — the `src` for an `@font-face` rule. */
|
||||||
export function fontDataUri(asset: FontAsset): string {
|
export function fontDataUri(asset: FontAsset): string {
|
||||||
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
|
return `data:${FONT_MIME[asset.format]};base64,${bytesToBase64(asset.data)}`;
|
||||||
|
|||||||
@@ -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"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (`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-<payload>`. */
|
||||||
|
export function specLinkHref(specText: string): string {
|
||||||
|
return `/app/#spec-${encodeSpecPayload(specText)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user