2026-07-30 18:35:32 +00:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>Tutorial Execution Report</title>
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #0d1117; --panel: #161b22; --border: #30363d;
|
|
|
|
|
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
|
|
|
|
|
--pass: #2ea043; --fail: #f85149; --info: #d29922;
|
|
|
|
|
--code-bg: #0b0f14;
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0; background: var(--bg); color: var(--text);
|
|
|
|
|
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
|
|
|
}
|
|
|
|
|
header {
|
|
|
|
|
position: sticky; top: 0; z-index: 10; background: var(--panel);
|
|
|
|
|
border-bottom: 1px solid var(--border); padding: 12px 20px;
|
|
|
|
|
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
|
|
|
.pill { padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
|
|
|
.pill.pass { background: rgba(46,160,67,.15); color: var(--pass); }
|
|
|
|
|
.pill.fail { background: rgba(248,81,73,.15); color: var(--fail); }
|
|
|
|
|
.pill.skip { background: rgba(210,153,34,.15); color: var(--info); }
|
|
|
|
|
select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; }
|
|
|
|
|
main { max-width: 1600px; margin: 0 auto; padding: 16px; }
|
|
|
|
|
.tutorial-title { font-size: 20px; font-weight: 700; margin: 24px 0 8px; color: #fff; }
|
|
|
|
|
.row {
|
|
|
|
|
display: grid; grid-template-columns: 1fr 1fr; gap: 0;
|
|
|
|
|
border: 1px solid var(--border); border-radius: 8px; margin: 10px 0; overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
.row.preamble, .row.section { grid-template-columns: 1fr; }
|
|
|
|
|
.col { padding: 14px 18px; min-width: 0; }
|
|
|
|
|
.col.left { border-right: 1px solid var(--border); }
|
|
|
|
|
.row.preamble .col.left, .row.section .col.left { border-right: none; }
|
|
|
|
|
.col.right { background: var(--panel); }
|
|
|
|
|
/* A row containing any failed step is tinted red end-to-end so it's
|
|
|
|
|
impossible to miss when scanning the report. */
|
|
|
|
|
.row.has-fail { border-color: var(--fail); box-shadow: 0 0 0 1px var(--fail); }
|
|
|
|
|
.row.has-fail .col.left { background: rgba(248,81,73,.07); border-right-color: var(--fail); }
|
|
|
|
|
.row.has-fail .col.right { background: rgba(248,81,73,.12); }
|
|
|
|
|
.col-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 8px; }
|
|
|
|
|
.md :first-child { margin-top: 0; }
|
|
|
|
|
.md h1 { font-size: 22px; } .md h2 { font-size: 18px; } .md h3 { font-size: 15px; }
|
|
|
|
|
.md pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; }
|
|
|
|
|
.md code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
|
|
|
|
.md :not(pre) > code { background: rgba(110,118,129,.2); padding: .15em .4em; border-radius: 4px; }
|
|
|
|
|
.md table { border-collapse: collapse; margin: 8px 0; }
|
|
|
|
|
.md th, .md td { border: 1px solid var(--border); padding: 5px 10px; }
|
|
|
|
|
.md blockquote { border-left: 3px solid var(--border); margin: 8px 0; padding: 2px 12px; color: var(--muted); }
|
|
|
|
|
.md img { width: 100%; height: auto; border: 1px solid var(--border); border-radius: 6px; }
|
|
|
|
|
.exec { margin-bottom: 14px; }
|
|
|
|
|
.exec:last-child { margin-bottom: 0; }
|
|
|
|
|
.exec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
|
|
|
.badge { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 4px; }
|
|
|
|
|
.badge.pass { background: rgba(46,160,67,.15); color: var(--pass); }
|
|
|
|
|
.badge.fail { background: rgba(248,81,73,.15); color: var(--fail); }
|
|
|
|
|
.badge.info { background: rgba(210,153,34,.15); color: var(--info); }
|
|
|
|
|
.kind { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
|
|
|
|
.note { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; }
|
|
|
|
|
pre.cmd { background: var(--code-bg); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 6px; padding: 10px 12px; margin: 0 0 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
|
|
|
pre.output { background: #010409; border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin: 0; overflow-x: auto; max-height: 360px; overflow-y: auto; color: #b9c1ca; }
|
|
|
|
|
pre.cmd, pre.output { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.45; }
|
|
|
|
|
.empty { color: var(--muted); font-style: italic; font-size: 12.5px; }
|
|
|
|
|
details summary { cursor: pointer; color: var(--accent); font-size: 12px; margin-bottom: 4px; }
|
|
|
|
|
/* GitHub-style permalinks: every heading carries a link icon in the left
|
|
|
|
|
gutter (revealed on hover/focus) that copies a deep link to that section.
|
|
|
|
|
scroll-margin-top keeps the target clear of the sticky header. */
|
|
|
|
|
.heading-anchored { position: relative; scroll-margin-top: 76px; }
|
|
|
|
|
a.anchor {
|
|
|
|
|
position: absolute; left: -18px; top: 0; width: 16px; height: 1.55em;
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
color: var(--muted); opacity: 0; text-decoration: none;
|
|
|
|
|
transition: opacity .1s ease-in;
|
|
|
|
|
}
|
|
|
|
|
a.anchor svg { width: 14px; height: 14px; display: block; }
|
|
|
|
|
.heading-anchored:hover > a.anchor, a.anchor:focus { opacity: 1; }
|
|
|
|
|
a.anchor:hover { color: var(--accent); }
|
|
|
|
|
.heading-anchored.flash { animation: flash 1.4s ease-out; border-radius: 4px; }
|
|
|
|
|
@keyframes flash {
|
|
|
|
|
from { background: rgba(88,166,255,.28); box-shadow: 0 0 0 4px rgba(88,166,255,.28); }
|
|
|
|
|
to { background: transparent; box-shadow: 0 0 0 4px transparent; }
|
|
|
|
|
}
|
|
|
|
|
#toast {
|
|
|
|
|
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
|
|
|
|
background: var(--panel); border: 1px solid var(--border); color: var(--text);
|
|
|
|
|
border-radius: 6px; padding: 7px 14px; font-size: 12.5px; z-index: 20;
|
|
|
|
|
opacity: 0; pointer-events: none; transition: opacity .15s ease-in-out;
|
|
|
|
|
}
|
|
|
|
|
#toast.show { opacity: 1; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<header>
|
|
|
|
|
<h1>Tutorial Execution Report</h1>
|
|
|
|
|
<span id="summary"></span>
|
|
|
|
|
<label style="margin-left:auto; font-size:12px; color:var(--muted)">Tutorial
|
|
|
|
|
<select id="picker"></select>
|
|
|
|
|
</label>
|
|
|
|
|
</header>
|
|
|
|
|
<main id="main"></main>
|
|
|
|
|
<div id="toast" role="status" aria-live="polite"></div>
|
|
|
|
|
|
2026-07-31 15:37:35 +00:00
|
|
|
<script id="report-data" type="application/json">{"generated_platform": "Darwin", "summary": {"passed": 23, "failed": 0, "skipped": 0}, "tutorials": [{"meta": {"name": "Running This Execution-Zone Module Against logoscore", "platform": "Darwin (ext=dylib)", "release": "(none)"}, "rows": [{"md": "# Running This Execution-Zone Module Against logoscore\n\n`logos-execution-zone` is a Logos `core` module that wraps the\n[Logos execution-zone wallet library](https://github.com/logos-blockchain/lssa)\n(`wallet_ffi`) to provide wallet lifecycle, account management, balance and\nblock queries, transfers, pinata claiming, and account-id encoding. This\ndoc-test exercises **this** execution-zone-module commit end-to-end through the\nheadless `logoscore` runtime:\n\n1. Build the `logoscore` CLI and the `lgpm` local package manager from their\n published flakes. `logoscore` is the headless frontend for `logos-liblogos`,\n so building it brings in the whole module-runtime stack (`logos_host`,\n `liblogos_core`, the IPC layer).\n2. Build **this** execution-zone module as an installable `.lgx` package\n straight from its own flake's `#lgx` output, **pinned to the commit under\n test** \u2014 so the module you run is built from exactly what is checked out\n here, not the latest published release.\n3. Install the `.lgx` into a `./modules` directory with `lgpm`.\n4. Start `logoscore` in daemon mode (`-D`), load `logos_execution_zone`,\n introspect it with `module-info`, and call its wallet-free methods \u2014\n verifying the module actually runs and round-trips real values through\n `wallet_ffi`.\n\nThe methods exercised here \u2014 `name`/`version` and the base58 account-id codec\n(`account_id_to_base58` / `account_id_from_base58`) \u2014 are the module's\ndeterministic, **offline** surface: they need neither an open wallet nor a live\nsequencer, so a green run is reproducible in CI. The stateful operations\n(creating accounts, transfers, sync, pinata claims) require a running\nsequencer and network, and are covered by the module's unit tests (mocked\n`wallet_ffi`) and integration tests (real `wallet_ffi`) instead.\n\nBecause the module is built from the commit under test and then loaded and\ncalled through a real `logoscore` daemon, a green run is real evidence that\nthis change keeps the execution-zone module loadable and callable.\n\n**What you'll build:** This `logos_execution_zone` module, packaged as `.lgx`, installed with `lgpm`, and called through a `logoscore` daemon.\n\n**What you'll learn:**\n\n- How to build the `logoscore` runtime and the `lgpm` package manager from their flakes\n- How a module's flake exposes a ready-to-install `.lgx` via its `#lgx` output\n- How to install an `.lgx` into a modules directory with `lgpm`\n- How to start the `logoscore` daemon, load a module, introspect it, and call its methods\n- How to shut the daemon down and confirm it has exited\n\n## Prerequisites\n\n- **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:\n\n```bash\nmkdir -p ~/.config/nix\necho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf\n```\n\nVerify: `nix flake --help >/dev/null 2>&1 && echo \"Flakes enabled\"`\n\n- **A Linux or macOS machine.**", "execs": []}, {"md": "## Step 1: Build logoscore\n\nBuild the `logoscore` CLI from its published flake. The result is symlinked to\n`./logos/`. `logoscore` is the headless frontend for `logos-liblogos`, so this\none build brings in the whole module-runtime stack the daemon needs.", "execs": []}, {"md": "### 1.1 Build the CLI\n\n```bash\nnix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos\n```\n\nThe build produces `logos/bin/logoscore` plus bundled runtime libraries\nand a `logos/modules/` directory containing the built-in\n`capability_module` (required for the auth handshake when loading\nmodules).", "execs": [{"kind": "run", "cmd": "nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos", "status": "pass", "exit_code": 0, "output": "unpacking 'github:logos-co/logo
|
2026-07-30 18:35:32 +00:00
|
|
|
<script>
|
|
|
|
|
const DATA = JSON.parse(document.getElementById("report-data").textContent);
|
|
|
|
|
if (window.marked) { marked.setOptions({ gfm: true, breaks: false }); }
|
|
|
|
|
const mdToHtml = (s) => window.marked ? marked.parse(s || "") : ("<pre>" + (s || "") + "</pre>");
|
|
|
|
|
|
|
|
|
|
const sum = DATA.summary;
|
|
|
|
|
document.getElementById("summary").innerHTML =
|
|
|
|
|
`<span class="pill pass">${sum.passed} passed</span> ` +
|
|
|
|
|
(sum.failed ? `<span class="pill fail">${sum.failed} failed</span> ` : "") +
|
|
|
|
|
(sum.skipped ? `<span class="pill skip">${sum.skipped} skipped</span>` : "");
|
|
|
|
|
|
|
|
|
|
const picker = document.getElementById("picker");
|
|
|
|
|
DATA.tutorials.forEach((t, i) => {
|
|
|
|
|
const o = document.createElement("option");
|
|
|
|
|
o.value = i; o.textContent = t.meta.name; picker.appendChild(o);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function execHtml(e) {
|
|
|
|
|
const badge = `<span class="badge ${e.status}">${e.status.toUpperCase()}</span>`;
|
|
|
|
|
const kind = `<span class="kind">${e.kind}${e.exit_code !== undefined ? " · exit " + e.exit_code : ""}</span>`;
|
|
|
|
|
let h = `<div class="exec"><div class="exec-head">${badge}${kind}</div>`;
|
|
|
|
|
if (e.note) h += `<div class="note">${escapeHtml(e.note)}</div>`;
|
|
|
|
|
if (e.cmd) h += `<pre class="cmd">${escapeHtml(e.cmd)}</pre>`;
|
|
|
|
|
const out = (e.output || "").trim();
|
|
|
|
|
if (out) {
|
|
|
|
|
const long = out.split("\n").length > 12;
|
|
|
|
|
if (long) {
|
|
|
|
|
h += `<details><summary>output (${out.split("\n").length} lines)</summary><pre class="output">${escapeHtml(out)}</pre></details>`;
|
|
|
|
|
} else {
|
|
|
|
|
h += `<pre class="output">${escapeHtml(out)}</pre>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
h += `</div>`;
|
|
|
|
|
return h;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
return String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render(idx) {
|
|
|
|
|
const t = DATA.tutorials[idx];
|
|
|
|
|
const main = document.getElementById("main");
|
|
|
|
|
main.innerHTML = "";
|
|
|
|
|
const h = document.createElement("div");
|
|
|
|
|
h.className = "tutorial-title";
|
|
|
|
|
h.textContent = t.meta.name + " — " + t.meta.platform;
|
|
|
|
|
main.appendChild(h);
|
|
|
|
|
|
|
|
|
|
t.rows.forEach(row => {
|
|
|
|
|
const isStructural = row.execs.length === 0 &&
|
|
|
|
|
(/^#\s|^##\s/.test(row.md.trim()));
|
|
|
|
|
const div = document.createElement("div");
|
|
|
|
|
const hasExec = row.execs.length > 0;
|
|
|
|
|
const hasFail = row.execs.some(e => e.status === "fail");
|
|
|
|
|
div.className = "row" + (hasExec ? "" : (isStructural ? " section" : " preamble")) +
|
|
|
|
|
(hasFail ? " has-fail" : "");
|
|
|
|
|
|
|
|
|
|
const left = document.createElement("div");
|
|
|
|
|
left.className = "col left";
|
|
|
|
|
left.innerHTML = `<div class="md">${mdToHtml(row.md)}</div>`;
|
|
|
|
|
div.appendChild(left);
|
|
|
|
|
|
|
|
|
|
if (hasExec || !isStructural) {
|
|
|
|
|
const right = document.createElement("div");
|
|
|
|
|
right.className = "col right";
|
|
|
|
|
if (hasExec) {
|
|
|
|
|
right.innerHTML = `<div class="col-label">Executed</div>` +
|
|
|
|
|
row.execs.map(execHtml).join("");
|
|
|
|
|
} else {
|
|
|
|
|
right.innerHTML = `<div class="col-label">Executed</div>` +
|
|
|
|
|
`<div class="empty">No commands run for this step.</div>`;
|
|
|
|
|
}
|
|
|
|
|
div.appendChild(right);
|
|
|
|
|
}
|
|
|
|
|
main.appendChild(div);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
addAnchors(main, slug(t.meta.name));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function slug(s) {
|
|
|
|
|
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Octicon "link" — the same glyph GitHub puts next to markdown headings.
|
|
|
|
|
const LINK_ICON =
|
|
|
|
|
'<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d=' +
|
|
|
|
|
'"M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 ' +
|
|
|
|
|
'.75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25Zm-4.69 ' +
|
|
|
|
|
'9.64a2 2 0 0 1 0-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 ' +
|
|
|
|
|
'2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 0 1-2.83 0Z"></path></svg>';
|
|
|
|
|
|
|
|
|
|
// Give every heading a stable slug id (deduplicated the way GitHub does, with
|
|
|
|
|
// -1/-2 suffixes) and a permalink whose href is "#<tutorial>/<heading>".
|
|
|
|
|
function addAnchors(container, tutSlug) {
|
|
|
|
|
const used = new Map();
|
|
|
|
|
const nodes = container.querySelectorAll(
|
|
|
|
|
".tutorial-title, .md h1, .md h2, .md h3, .md h4, .md h5, .md h6"
|
|
|
|
|
);
|
|
|
|
|
nodes.forEach(node => {
|
|
|
|
|
const text = node.textContent.trim();
|
|
|
|
|
const isTitle = node.classList.contains("tutorial-title");
|
|
|
|
|
let href = "#" + tutSlug;
|
|
|
|
|
if (!isTitle) {
|
|
|
|
|
const base = slug(text) || "section";
|
|
|
|
|
const n = (used.get(base) || 0) + 1;
|
|
|
|
|
used.set(base, n);
|
|
|
|
|
node.id = n > 1 ? base + "-" + n : base;
|
|
|
|
|
href += "/" + node.id;
|
|
|
|
|
}
|
|
|
|
|
node.classList.add("heading-anchored");
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.className = "anchor";
|
|
|
|
|
a.href = href;
|
|
|
|
|
a.title = "Copy link to this section";
|
|
|
|
|
a.setAttribute("aria-label", "Permalink: " + text);
|
|
|
|
|
a.innerHTML = LINK_ICON;
|
|
|
|
|
node.insertBefore(a, node.firstChild);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let toastTimer = null;
|
|
|
|
|
function toast(msg) {
|
|
|
|
|
const el = document.getElementById("toast");
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
el.classList.add("show");
|
|
|
|
|
clearTimeout(toastTimer);
|
|
|
|
|
toastTimer = setTimeout(() => el.classList.remove("show"), 1600);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// navigator.clipboard needs a secure context; reports are often opened over
|
|
|
|
|
// file://, so fall back to the old execCommand path there.
|
|
|
|
|
function copyText(text) {
|
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
|
|
|
navigator.clipboard.writeText(text).then(
|
|
|
|
|
() => toast("Link copied"),
|
|
|
|
|
() => toast(text)
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const ta = document.createElement("textarea");
|
|
|
|
|
ta.value = text;
|
|
|
|
|
ta.style.position = "fixed";
|
|
|
|
|
ta.style.opacity = "0";
|
|
|
|
|
document.body.appendChild(ta);
|
|
|
|
|
ta.select();
|
|
|
|
|
let ok = false;
|
|
|
|
|
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
|
|
|
|
|
document.body.removeChild(ta);
|
|
|
|
|
toast(ok ? "Link copied" : text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scrollToId(id) {
|
|
|
|
|
if (!id) { window.scrollTo(0, 0); return; }
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.scrollIntoView({ block: "start" });
|
|
|
|
|
el.classList.remove("flash");
|
|
|
|
|
void el.offsetWidth; // restart the animation on a repeat click
|
|
|
|
|
el.classList.add("flash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener("click", ev => {
|
|
|
|
|
const a = ev.target.closest && ev.target.closest("a.anchor");
|
|
|
|
|
if (!a) return;
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
const href = a.getAttribute("href");
|
|
|
|
|
copyText(location.href.split("#")[0] + href);
|
|
|
|
|
if (location.hash === href) scrollToId(a.parentNode.id);
|
|
|
|
|
else location.hash = href; // hashchange scrolls
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// "#<tutorial>" (name, slug, or index) optionally followed by "/<heading>".
|
|
|
|
|
function parseHash() {
|
|
|
|
|
const raw = decodeURIComponent(location.hash.replace(/^#/, ""));
|
|
|
|
|
if (!raw) return { idx: 0, frag: "" };
|
|
|
|
|
const cut = raw.indexOf("/");
|
|
|
|
|
const tut = cut >= 0 ? raw.slice(0, cut) : raw;
|
|
|
|
|
const frag = cut >= 0 ? raw.slice(cut + 1) : "";
|
|
|
|
|
if (/^\d+$/.test(tut) && +tut < DATA.tutorials.length) return { idx: +tut, frag };
|
|
|
|
|
const i = DATA.tutorials.findIndex(
|
|
|
|
|
t => slug(t.meta.name) === tut || t.meta.name === tut
|
|
|
|
|
);
|
|
|
|
|
if (i >= 0) return { idx: i, frag };
|
|
|
|
|
// No tutorial matched: treat a bare hash as a heading in the current one.
|
|
|
|
|
return { idx: currentIdx >= 0 ? currentIdx : 0, frag: cut >= 0 ? frag : raw };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let currentIdx = -1;
|
|
|
|
|
function show(idx, frag) {
|
|
|
|
|
if (idx !== currentIdx) {
|
|
|
|
|
picker.value = idx;
|
|
|
|
|
render(idx);
|
|
|
|
|
currentIdx = idx;
|
|
|
|
|
}
|
|
|
|
|
if (frag) requestAnimationFrame(() => scrollToId(frag));
|
|
|
|
|
}
|
|
|
|
|
picker.addEventListener("change", () => {
|
|
|
|
|
location.hash = slug(DATA.tutorials[+picker.value].meta.name);
|
|
|
|
|
show(+picker.value);
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
|
|
|
const h = parseHash();
|
|
|
|
|
show(h.idx, h.frag);
|
|
|
|
|
});
|
|
|
|
|
const initial = parseHash();
|
|
|
|
|
show(initial.idx, initial.frag);
|
|
|
|
|
// Inlined screenshots have no intrinsic size until decoded, so the first
|
|
|
|
|
// scroll can land short; re-anchor once everything has loaded.
|
|
|
|
|
if (initial.frag) {
|
|
|
|
|
window.addEventListener("load", () => scrollToId(initial.frag), { once: true });
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|