Commit 0be22c50 by PLN (Algolia)

blog: hydra islands — bookend sketches resolved from meta.md

parent 06720e6d
'use client';
import { useEffect, useRef, useState } from 'react';
import styles from './blog.module.css';
/**
* A Hydra sketch island at the opening/closing of a post. It boots on page
* load — the sketch is local canvas work (no third-party requests once the
* hydra-synth chunk is in), so autoplay is free — except under
* prefers-reduced-motion, where it waits for a click (the facade then also
* links to the sketch's origin for no-JS readers). The source rides in the
* page HTML, hidden behind <details>; the hover footer mirrors the sketch's
* own credit block — author and CC mention, as the author wrote them.
*
* Two islands on one page (opening + closing) each boot their own
* hydra-synth instance; the last one booted owns the global generators,
* which is the right trade for a page that books both ends.
*/
export default function HydraEmbed({ sketch, lang }) {
const [running, setRunning] = useState(false);
const [failed, setFailed] = useState(false);
const canvasRef = useRef(null);
const stageRef = useRef(null);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
setRunning(true);
}, []);
useEffect(() => {
if (!running) return undefined;
let hydra = null;
let cancelled = false;
import('hydra-synth')
.then(({ default: Hydra }) => {
if (cancelled || !canvasRef.current) return;
const width = Math.min(stageRef.current.clientWidth, 1280);
const height = Math.round(width / (16 / 9));
const canvas = canvasRef.current;
canvas.width = width;
canvas.height = height;
hydra = new Hydra({ canvas, detectAudio: false, width, height });
hydra.eval(sketch.source);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
if (hydra && typeof hydra.close === 'function') hydra.close();
};
}, [running, sketch.source]);
const hint = lang === 'en' ? 'Run the sketch' : 'Lancer le sketch';
const codeLabel = lang === 'en' ? 'sketch source' : 'source du sketch';
return (
<figure className={styles.hydra}>
<div ref={stageRef} className={styles.hydraStage}>
{running ? (
<canvas ref={canvasRef} className={styles.hydraCanvas} />
) : (
<a
href={sketch.url}
target="_blank"
rel="noopener noreferrer"
className={styles.hydraFacade}
onClick={(e) => {
e.preventDefault();
setRunning(true);
}}
>
<span className={styles.hydraPlay} aria-hidden="true">
▷
</span>
<span className={styles.hydraHint}>{failed ? sketch.url : hint}</span>
</a>
)}
<footer className={styles.hydraFooter}>
{sketch.title && <span className={styles.hydraTitle}>{sketch.title}</span>}
{sketch.author && <span className={styles.hydraAuthor}>{sketch.author}</span>}
{sketch.license &&
(sketch.licenseUrl ? (
<a href={sketch.licenseUrl} target="_blank" rel="license noopener noreferrer">
{sketch.license}
</a>
) : (
<span>{sketch.license}</span>
))}
<a href={sketch.url} target="_blank" rel="noopener noreferrer">
hydra.ojack.xyz ↗
</a>
</footer>
</div>
<details className={styles.hydraCode}>
<summary>{codeLabel}</summary>
<pre>
<code>{sketch.source}</code>
</pre>
</details>
</figure>
);
}
......@@ -7,6 +7,7 @@ import { sansBody } from "@/app/blog/dev-fonts";
import { getBlogNeighbors } from "@/lib/blog";
import ArticleEnhance from "./ArticleEnhance";
import FontSwitcher from "./FontSwitcher";
import HydraEmbed from "./HydraEmbed";
/**
* The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the
......@@ -110,13 +111,19 @@ export default function PostArticle({ post, switcher, banner }) {
</p>
)}
{post.description && <p className={styles.description}>{post.description}</p>}
{/* Hydra bookends — meta.md slots (`hydra:` = both ends,
`hydra-opening:`/`hydra-closing:` = one). Source + credits are
resolved at build time (lib/hydra-sketch.js). */}
{post.hydra?.opening && <HydraEmbed sketch={post.hydra.opening} lang={post.lang} />}
{hasToc && (
<details className={styles.tocDetails}>
<summary>{labels.toc}</summary>
<TocList toc={toc} />
</details>
)}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
<div className={styles.prose} data-prose dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{post.hydra?.closing && <HydraEmbed sketch={post.hydra.closing} lang={post.lang} />}
{/* RELATED SEAM: post↔project threads slot in beside the pager
once /projects exists (DESIGN.md — the git-graph surface).
......
---
title: Hello World
date: '2026-09-06'
description: Engine fixture — proves figure centering, build-time highlighting, tables. Prose to be written.
draft: true
date: '2026-11-01'
description: Et nous y revoilà.
draft: false
hydra: https://hydra.ojack.xyz/?sketch_id=rangga_2
---
......@@ -3,6 +3,7 @@ import path from 'path';
import matter from 'gray-matter';
import { buildTocFromHtml, renderMarkdown } from './markdown';
import { draftsVisible } from './content';
import { resolveHydraSketch } from './hydra-sketch';
/**
* Blog posts — the bilingual folder model: `content/blog/<slug>/` holds
......@@ -139,6 +140,20 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
const meta = readMeta(slug);
const contentHtml = await renderMarkdown(content);
// Hydra islands — meta.md keys pick the slot: `hydra:` books the sketch at
// both ends of the post, `hydra-opening:` / `hydra-closing:` at one. The
// language frontmatter may override the shared choice per language. An
// unresolvable URL degrades to null (the page keeps its plain link).
const resolve = (slot) => {
const url = data[slot] ?? meta[slot];
return url ? resolveHydraSketch(url) : null;
};
const [opening, closing] = await Promise.all([
resolve('hydra-opening') ?? resolve('hydra'),
resolve('hydra-closing') ?? resolve('hydra'),
]);
return {
slug,
...meta,
......@@ -151,6 +166,7 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
contentHtml,
toc: buildTocFromHtml(contentHtml),
readingMinutes: readingMinutes(contentHtml),
hydra: opening || closing ? { opening, closing } : null,
};
}
......
/**
* Hydra sketch resolution for blog posts — meta.md carries a `hydra:` URL,
* the build turns it into runnable source + a parsed credit block.
*
* Two homes for sketches behind hydra.ojack.xyz `?sketch_id=` links:
* - the editor's API (api.hydrasynth.xyz/sketchById) — sketches actively
* saved/shared by their author;
* - the editor's own gallery bundle — community sketches that ship inside
* the site's JS (no API call ever fires for them; verified 2026-11-01:
* rangga_2 loads with zero sketch requests, the code sits in the bundle
* as base64-of-percent-encoded source).
* Both store the code as `btoa(encodeURIComponent(source))`, so one decode
* chain covers them. Git-raw URLs (like the /hydra pages use) pass through
* as a direct fetch.
*
* Resolution is awaited per post at build/dev time and memoized per process;
* a failed fetch resolves to null so one dead link can't break a build —
* the page degrades to the facade's plain link.
*/
const OJACK_ORIGIN = 'https://hydra.ojack.xyz';
const API_BASE = 'https://api.hydrasynth.xyz';
/**
* The gallery's license frame — hydra.ojack.xyz's own setSketch() prepends
* this line to any sketch whose code lacks it, so the CC mention "as here"
* is the gallery's, not the author's comment. Mirrored so our embed shows
* the same credit the origin does.
*/
const GALLERY_LICENSE =
'// licensed with CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/';
function withGalleryLicense(source) {
return /^\/\/\s*licensed with\b/m.test(source)
? source
: `${GALLERY_LICENSE}\n${source}\n`;
}
/** `…?sketch_id=rangga_2` → 'rangga_2', or null for other URL shapes. */
export function sketchIdOf(url) {
try {
return new URL(url).searchParams.get('sketch_id');
} catch {
return null;
}
}
/** btoa(encodeURIComponent(src)) → src. Tolerates plain source too. */
export function decodeSketchCode(code) {
// Real base64 only — Buffer is lenient and happily mangles plain JS
// ("osc(20).out()" would decode to binary noise).
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(code) || code.length % 4 !== 0) return code;
let out;
try {
out = Buffer.from(code, 'base64').toString('utf8');
} catch {
return code;
}
if (out.includes('�')) return code; // decoded bytes weren't UTF-8 text
if (!/%[0-9a-f]{2}/i.test(out)) return out;
try {
return decodeURIComponent(out);
} catch {
return out;
}
}
/**
* The credit block every shared sketch opens with:
* // licensed with CC BY-NC-SA 4.0 https://creativecommons.org/licenses/…
* // Monochrome Memoar
* // by Rangga Purnama Aji
* Lines are scanned in order; the first unclaimed comment becomes the
* title. Everything stays optional — sketches without credits render none.
*/
export function parseSketchCredits(source) {
const credits = { title: null, author: null, license: null, licenseUrl: null };
for (const line of source.split('\n')) {
const comment = line.match(/^\s*\/\/\s?(.*)/);
if (!comment) break;
const text = comment[1].trim();
if (!text) continue;
const license = text.match(/^licensed with\s+(.+?)(?:\s+(https?:\S+))?\s*$/i);
if (license) {
credits.license = license[1];
credits.licenseUrl = license[2] ?? null;
continue;
}
const author = text.match(/^by\s+(.+)$/i);
if (author && !credits.author) {
credits.author = author[1].trim();
continue;
}
if (!credits.title && !/^https?:/.test(text)) credits.title = text;
if (credits.title && credits.author && credits.license) break;
}
return credits;
}
async function fetchText(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`${res.status} ${url}`);
return res.text();
}
/** Sketches actively saved on the editor's API: [{ sketch_id, code }, …]. */
async function fromApi(sketchId) {
const body = await fetchText(`${API_BASE}/sketchById?sketch_id=${encodeURIComponent(sketchId)}`);
const rows = JSON.parse(body);
return rows.length > 0 ? decodeSketchCode(rows[0].code) : null;
}
/**
* Gallery sketches ship inside the editor bundle. One extra hop (the page
* names its content-hashed asset) keeps this working across their deploys.
*/
async function fromGalleryBundle(sketchId) {
const html = await fetchText(`${OJACK_ORIGIN}/`);
const asset = html.match(/src="\.?(\/?assets\/index-[^"]+\.js)"/)?.[1];
if (!asset) return null;
const bundle = await fetchText(asset.startsWith('http') ? asset : `${OJACK_ORIGIN}${asset}`);
const entry = bundle.match(new RegExp(`sketch_id:"${sketchId}",code:"([^"]+)"`));
return entry ? decodeSketchCode(entry[1]) : null;
}
/** Source for a git-raw URL (the /hydra model): fetch it directly. */
async function fromDirectSource(url) {
return fetchText(url);
}
const memo = new Map();
/**
* URL → { source, url, …credits } | null. Network failures (and URLs we
* can't interpret) resolve to null; the caller renders the link instead.
*/
export async function resolveHydraSketch(url) {
if (memo.has(url)) return memo.get(url);
const work = (async () => {
const sketchId = sketchIdOf(url);
let source = null;
try {
source = sketchId
? ((await fromApi(sketchId)) ?? (await fromGalleryBundle(sketchId)))
: await fromDirectSource(url);
} catch {
return null;
}
if (!source) return null;
if (sketchId) source = withGalleryLicense(source);
return { url, source, ...parseSketchCredits(source) };
})();
memo.set(url, work);
return work;
}
const { parseSketchCredits, decodeSketchCode, sketchIdOf } = require('@/lib/hydra-sketch');
/**
* The credit block shared Hydra sketches open with — the hover footer's
* source of truth. Locked here because the grammar is the author's habit,
* not a spec: `// licensed with CC … <url>`, `// <title>`, `// by <author>`.
*/
describe('hydra-sketch', () => {
test('parseSketchCredits reads the standard header', () => {
const src = [
'// licensed with CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/',
'// Monochrome Memoar',
'// by Rangga Purnama Aji',
'// https://ranggapurnamaaji1.wixsite.com/portfolio',
'',
'voronoi(50,1).out()',
].join('\n');
expect(parseSketchCredits(src)).toEqual({
title: 'Monochrome Memoar',
author: 'Rangga Purnama Aji',
license: 'CC BY-NC-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
});
});
test('parseSketchCredits survives headerless sketches', () => {
expect(parseSketchCredits('osc(20).out()')).toEqual({
title: null,
author: null,
license: null,
licenseUrl: null,
});
});
test('parseSketchCredits stops at code, tolerates license without URL', () => {
const src = '// by someone\n// licensed with CC0\nosc().out()\n// by nobody';
const credits = parseSketchCredits(src);
expect(credits.author).toBe('someone');
expect(credits.license).toBe('CC0');
expect(credits.licenseUrl).toBeNull();
expect(credits.title).toBeNull();
});
test('decodeSketchCode reverses btoa(encodeURIComponent(source))', () => {
const source = '// héy — accented, <tagged>, "quoted"';
const encoded = Buffer.from(encodeURIComponent(source)).toString('base64');
expect(decodeSketchCode(encoded)).toBe(source);
});
test('decodeSketchCode passes plain source through', () => {
expect(decodeSketchCode('osc(20).out()')).toBe('osc(20).out()');
});
test('sketchIdOf extracts the id, nulls other URLs', () => {
expect(sketchIdOf('https://hydra.ojack.xyz/?sketch_id=rangga_2')).toBe('rangga_2');
expect(sketchIdOf('https://git.example/hydra/raw/sketch.js')).toBeNull();
expect(sketchIdOf('not a url')).toBeNull();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment