Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
92 changes: 92 additions & 0 deletions demo/playground/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guided Navigation Fixture Playground</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>Fixtures</h1>
<input type="search" id="filter-input" placeholder="Filter (e.g. footnote, epub-type)">
</div>
<div id="fixture-list" class="fixture-list"></div>
</aside>

<main class="content">
<div id="fixture-meta" class="fixture-meta"></div>

<div class="pane">
<h2>1. Input HTML</h2>
<pre id="input-html" class="code-block"></pre>
</div>

<div class="pane">
<div class="pane-header">
<h2>2. Guided Navigation document</h2>
<span id="gnd-badge" class="badge"></span>
</div>
<div class="compare-grid">
<div>
<h3>Expected</h3>
<pre id="gnd-expected" class="code-block"></pre>
</div>
<div>
<h3>Actual</h3>
<pre id="gnd-actual" class="code-block"></pre>
</div>
</div>
</div>

<div class="pane">
<div class="pane-header">
<h2>3. Utterances</h2>
<span id="utterances-badge" class="badge"></span>
</div>

<div id="utterances-options" class="options-bar">
<fieldset class="options-group">
<legend>Format</legend>
<label><input type="radio" name="format" value="plain" checked> plain</label>
<label><input type="radio" name="format" value="ssml"> ssml</label>
</fieldset>

<fieldset class="options-group">
<legend>Language</legend>
<select id="option-language">
<option value="">(default)</option>
<option value="never">never</option>
<option value="block">block</option>
<option value="inline">inline</option>
</select>
</fieldset>

<fieldset class="options-group">
<label><input type="checkbox" id="option-interrupt"> interruptSentence</label>
<label><input type="checkbox" id="option-contextualize" checked> contextualize</label>
</fieldset>

<fieldset class="options-group">
<legend>Skip roles</legend>
<select id="option-skip" multiple></select>
</fieldset>
</div>

<div class="compare-grid">
<div>
<h3>Expected</h3>
<pre id="utterances-expected" class="code-block"></pre>
</div>
<div>
<h3>Actual</h3>
<pre id="utterances-actual" class="code-block"></pre>
</div>
</div>
</div>
</main>

<script type="module" src="./script.js"></script>
</body>
</html>
301 changes: 301 additions & 0 deletions demo/playground/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
const filterInput = document.getElementById("filter-input");
const fixtureListEl = document.getElementById("fixture-list");
const fixtureMetaEl = document.getElementById("fixture-meta");
const inputHtmlEl = document.getElementById("input-html");
const gndExpectedEl = document.getElementById("gnd-expected");
const gndActualEl = document.getElementById("gnd-actual");
const gndBadgeEl = document.getElementById("gnd-badge");
const utterancesExpectedEl = document.getElementById("utterances-expected");
const utterancesActualEl = document.getElementById("utterances-actual");
const utterancesBadgeEl = document.getElementById("utterances-badge");
const optionSkipEl = document.getElementById("option-skip");
const optionLanguageEl = document.getElementById("option-language");
const optionInterruptEl = document.getElementById("option-interrupt");
const optionContextualizeEl = document.getElementById("option-contextualize");
const formatRadios = [...document.querySelectorAll('input[name="format"]')];

// Feature-detect the GND converter and the utterance extractor, if/when
// @readium/speech exports them.
let converter = null;
let utteranceExtractor = null;
try {
const mod = await import("../../build/index.js");
if (typeof mod.parseMarkup === "function") {
converter = mod;
}
if (typeof mod.extractUtterances === "function") {
utteranceExtractor = mod;
}
if (Array.isArray(mod.skippableRoles)) {
for (const role of mod.skippableRoles) {
const option = document.createElement("option");
option.value = role;
option.textContent = role;
optionSkipEl.appendChild(option);
}
optionSkipEl.addEventListener("change", () => renderUtterances());
}
} catch (err) {
// build/index.js may not exist yet (run `npm run build`) or may not
// export these yet; logged rather than silently swallowed so a real
// failure here doesn't just look like an empty options list.
console.error("Failed to load @readium/speech build/index.js:", err);
}

// The extraction options currently selected in the toolbar, beyond the
// required `format` — mirrors exactly how fixtures/*/utterances.json's
// `variants[].options` are shaped, so it can be compared against them
// directly (see `matchingExpected`).
function currentExtraOptions() {
const extra = {};
const skip = optionSkipEl
? [...optionSkipEl.selectedOptions].map((option) => option.value)
: [];
if (skip.length > 0) extra.skip = skip;
if (optionLanguageEl?.value) extra.language = optionLanguageEl.value;
if (optionInterruptEl?.checked) extra.interruptSentence = true;
if (optionContextualizeEl && !optionContextualizeEl.checked) extra.contextualize = false;
return extra;
}

function currentFormat() {
return formatRadios.find((r) => r.checked)?.value ?? "plain";
}

// Finds the fixture's expected output for the exact combination of options
// currently selected: the branch's `base` when no extra options are set, or
// the `variants` entry whose `options` deep-equals the selection. Returns
// `undefined` when this fixture doesn't illustrate that combination.
function matchingExpected(utterances, format, extraOptions) {
const branch = utterances[format];
if (!branch) return undefined;
if (Object.keys(extraOptions).length === 0) return branch.base;
const variant = (branch.variants ?? []).find((v) => deepEqual(v.options, extraOptions));
return variant?.utterances;
}

const manifest = await fetch("../../fixtures/manifest.json").then((r) => r.json());
manifest.sort((a, b) => a.role.localeCompare(b.role) || a.description.localeCompare(b.description));

let selectedId = null;

// Matches against what's actually shown to the user (role, description) —
// not the roles.md-internal "tier"/"pathway" bookkeeping, which isn't (and
// shouldn't be) displayed as a navigable concept anywhere in this UI.
function matchesFilter(fixture, filter) {
return [fixture.role, fixture.description].join(" ").toLowerCase().includes(filter);
}

function renderList() {
const filter = filterInput.value.trim().toLowerCase();
const filtering = filter.length > 0;
const filtered = filtering ? manifest.filter((f) => matchesFilter(f, filter)) : manifest;

fixtureListEl.innerHTML = "";

if (filtered.length === 0) {
const empty = document.createElement("div");
empty.className = "no-results";
empty.textContent = "No fixtures match.";
fixtureListEl.appendChild(empty);
return;
}

const byRole = new Map();
for (const fixture of filtered) {
if (!byRole.has(fixture.role)) byRole.set(fixture.role, []);
byRole.get(fixture.role).push(fixture);
}

let firstGroup = true;
for (const [role, fixtures] of byRole) {
const details = document.createElement("details");
details.className = "role-group";
// While filtering, force every matching group open so results are never
// hidden inside a collapsed section. Otherwise, only the first group
// (or the one containing the currently selected fixture) starts open.
details.open = filtering || firstGroup || fixtures.some((f) => f.id === selectedId);
firstGroup = false;

const summary = document.createElement("summary");
summary.textContent = role;
details.appendChild(summary);

const ul = document.createElement("ul");
for (const fixture of fixtures) {
const li = document.createElement("li");
li.dataset.id = fixture.id;
li.className = fixture.id === selectedId ? "active" : "";
li.textContent = fixture.description;
li.addEventListener("click", () => selectFixture(fixture.id));
ul.appendChild(li);
}
details.appendChild(ul);
fixtureListEl.appendChild(details);
}

if (!selectedId && filtered.length > 0) selectFixture(filtered[0].id);
}

function deepEqual(a, b) {
return JSON.stringify(sortKeysDeep(a)) === JSON.stringify(sortKeysDeep(b));
}

function sortKeysDeep(value) {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value && typeof value === "object") {
return Object.keys(value)
.sort()
.reduce((acc, key) => {
acc[key] = sortKeysDeep(value[key]);
return acc;
}, {});
}
return value;
}

// Property order per object.schema.json, for display only — fixtures are
// hand-authored and don't consistently follow it, and JSON key order carries
// no semantic meaning, but showing both panes in the schema's own order keeps
// the visual diff free of that noise.
const SCHEMA_KEY_ORDER = [
"id",
"audioref",
"imgref",
"textref",
"videoref",
"text",
"role",
"children",
"description",
];

function withSchemaKeyOrder(value) {
if (Array.isArray(value)) return value.map(withSchemaKeyOrder);
if (value && typeof value === "object") {
const ordered = {};
for (const key of SCHEMA_KEY_ORDER) {
if (key in value) ordered[key] = withSchemaKeyOrder(value[key]);
}
for (const key of Object.keys(value)) {
if (!(key in ordered)) ordered[key] = withSchemaKeyOrder(value[key]);
}
return ordered;
}
return value;
}

// gnd.json stores a single fixture's expected top-level item(s) directly —
// a bare object for one item, or a role-less/id-less `{children: [...]}` for
// several siblings — while `parseMarkup()` always returns an array. This maps
// the stored file format onto that array shape for comparison.
function expectedTopLevel(gnd) {
if (gnd && typeof gnd === "object" && !Array.isArray(gnd)) {
const keys = Object.keys(gnd);
if (keys.length === 1 && keys[0] === "children") return gnd.children;
}
return [gnd];
}

// Inverse of expectedTopLevel: reshapes parseMarkup()'s array output into
// the same bare-object/`{children}` convention gnd.json is stored in, so
// the "actual" pane displays in the fixture's own reference shape.
function toStoredShape(items) {
return items.length === 1 ? items[0] : { children: items };
}

function setBadge(el, state) {
el.textContent =
state === "pass" ? "pass" : state === "fail" ? "fail" : state === "none" ? "no fixture data for this combination" : "not implemented yet";
el.className = `badge ${state}`;
}

// Cached across options-toolbar changes, so toggling an option re-runs
// extraction without refetching the fixture's files.
let currentFixture = null;

function renderUtterances() {
if (!currentFixture) return;
const { gndActual, utterances } = currentFixture;
const format = currentFormat();
const extraOptions = currentExtraOptions();
const expected = matchingExpected(utterances, format, extraOptions);

utterancesExpectedEl.textContent =
expected !== undefined ? JSON.stringify(expected, null, 2) : "(none — this fixture doesn't illustrate this combination of options)";

if (!utteranceExtractor || gndActual === undefined) {
utterancesActualEl.textContent = "";
setBadge(utterancesBadgeEl, "pending");
return;
}

try {
const actualUtterances = utteranceExtractor.extractUtterances(gndActual, { format, ...extraOptions });
utterancesActualEl.textContent = JSON.stringify(actualUtterances, null, 2);
setBadge(
utterancesBadgeEl,
expected === undefined ? "none" : deepEqual(actualUtterances, expected) ? "pass" : "fail",
);
} catch (err) {
utterancesActualEl.textContent = String(err);
setBadge(utterancesBadgeEl, "fail");
}
}

async function selectFixture(id) {
selectedId = id;
for (const li of fixtureListEl.querySelectorAll("li[data-id]")) {
li.classList.toggle("active", li.dataset.id === id);
}

const entry = manifest.find((f) => f.id === id);
const base = `../../fixtures/${entry.dir}/`;

const [meta, inputHtml, gnd, utterances] = await Promise.all([
fetch(base + "meta.json").then((r) => r.json()),
fetch(base + entry.files.input).then((r) => r.text()),
fetch(base + entry.files.gnd).then((r) => r.json()),
fetch(base + entry.files.utterances).then((r) => r.json()),
]);

fixtureMetaEl.replaceChildren();

const heading = document.createElement("h2");
heading.textContent = meta.description;
fixtureMetaEl.appendChild(heading);

const idLine = document.createElement("p");
idLine.className = "fixture-id";
idLine.textContent = meta.id;
fixtureMetaEl.appendChild(idLine);

inputHtmlEl.textContent = inputHtml;
gndExpectedEl.textContent = JSON.stringify(withSchemaKeyOrder(gnd), null, 2);

let gndActual;
if (!converter) {
gndActualEl.textContent = "";
setBadge(gndBadgeEl, "pending");
} else {
try {
gndActual = converter.parseMarkup(inputHtml);
gndActualEl.textContent = JSON.stringify(withSchemaKeyOrder(toStoredShape(gndActual)), null, 2);
setBadge(gndBadgeEl, deepEqual(gndActual, expectedTopLevel(gnd)) ? "pass" : "fail");
} catch (err) {
gndActualEl.textContent = String(err);
setBadge(gndBadgeEl, "fail");
}
}

currentFixture = { gndActual, utterances };
renderUtterances();
}

filterInput.addEventListener("input", renderList);
for (const radio of formatRadios) radio.addEventListener("change", renderUtterances);
optionLanguageEl?.addEventListener("change", renderUtterances);
optionInterruptEl?.addEventListener("change", renderUtterances);
optionContextualizeEl?.addEventListener("change", renderUtterances);

renderList();
Loading