site.js

docs/site.js · raw · all examples · site home

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// Radix docs: what the site pages and the reference pages (ref/) share:
// the top bar, the section dots and scroll progress of the long pages,
// reveal on scroll, the reference pages' contents column, and syntax
// highlighting for every code sample. A classic script, not a module, so the
// pages work when opened from disk (file://). A page sets
// <body data-root="" | "../"> (the path to the docs folder) and data-page
// (its path from there, for the top bar); a page outside the docs sets
// data-section (examples, source; empty for none). It declares no globals,
// so pages keep their own helpers.
/* global document, matchMedia, IntersectionObserver, MutationObserver, NodeFilter, innerHeight, addEventListener */
"use strict";

(() => {
  // The guide: the site pages, shown as a second row of the top bar
  const GUIDE = [
    ["index.html", "Home"],
    ["why.html", "vs Spark"],
    ["bench.html", "Benchmarks"],
    ["ideas.html", "Design ideas"],
    ["model.html", "How it fits"],
    ["data.html", "Data"],
    ["pipeline.html", "Pipeline"],
    ["multi-splat.html", "Multi-splat"],
  ];
  // The sources on GitHub; the static site (scripts/build-site.mjs)
  // points this at its highlighted source pages
  const SOURCE = "https://github.com/sparkjsdev/radix/tree/main/src";
  // [section, label, page from the docs folder]
  const SECTIONS = [
    ["guide", "Guide", "index.html"],
    ["examples", "Examples", "../build/examples/index.html"],
    ["source", "Source", SOURCE],
    ["reference", "Reference", "ref/overview.html"],
  ];
  const $ = (s, r = document) => r.querySelector(s);
  const $$ = (s, r = document) => [...r.querySelectorAll(s)];
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const root = document.body.dataset.root ?? "";
  const page = document.body.dataset.page ?? "";
  // A page outside the docs names its section (data-section)
  const section = document.body.dataset.section ?? (page.startsWith("ref/") ? "reference" : GUIDE.some(([href]) => href === page) ? "guide" : "");
  const link = (href) => (/^https?:/.test(href) ? href : `${root}${href}`);

  const logo = `<svg viewBox="0 0 16 16" aria-hidden="true"><ellipse cx="6" cy="8" rx="5" ry="3" fill="#f472b6" transform="rotate(-30 6 8)"/><ellipse cx="10" cy="8" rx="5" ry="3" fill="#2dd4bf" fill-opacity=".8" transform="rotate(30 10 8)"/></svg>`;
  const top = document.createElement("nav");
  top.id = "siteTop";
  top.innerHTML = `<div class="bar-row"><a class="brand" href="${root}index.html">${logo}Radix</a>` +
    SECTIONS.map(([id, label, href]) => `<a class="pg${id === section ? " on" : ""}" href="${link(href)}">${label}</a>`).join("") + "</div>" +
    (section === "guide" ? `<div class="bar-row bar-sub">${GUIDE.map(([href, label]) => `<a class="pg${href === page ? " on" : ""}" href="${link(href)}">${label}</a>`).join("")}</div>` : "");
  // Two rows: the sticky offsets (var(--top)) grow with the bar
  document.documentElement.classList.toggle("site-sub", section === "guide");
  document.body.prepend(top);
  if (!$("#progress")) {
    document.body.insertAdjacentHTML("afterbegin", `<div id="progress"></div>`);
  }

  // The long pages: a dot per section, and reveal on scroll
  const sections = $$("section[data-title]");
  const nav = $("#nav");
  if (nav) {
    nav.innerHTML = sections.map((s) => `<a href="#${s.id}" data-id="${s.id}"><span>${s.dataset.title}</span><i></i></a>`).join("");
  }
  const io = new IntersectionObserver((entries) => entries.forEach((e) => e.isIntersecting && e.target.classList.add("in")), { threshold: 0.12 });
  $$(".reveal").forEach((el) => (reducedMotion ? el.classList.add("in") : io.observe(el)));

  // Reference pages: the heading being read, in the contents column
  const toc = $$(".ref-toc a");
  const heads = toc.map((a) => document.getElementById(decodeURIComponent(a.hash.slice(1)))).filter(Boolean);

  const onScroll = () => {
    const h = document.documentElement;
    $("#progress").style.width = `${(h.scrollTop / Math.max(1, h.scrollHeight - h.clientHeight)) * 100}%`;
    if (nav && sections.length > 0) {
      let current = sections[0].id;
      for (const s of sections) {
        if (s.getBoundingClientRect().top < innerHeight * 0.4) current = s.id;
      }
      $$("a", nav).forEach((a) => a.classList.toggle("on", a.dataset.id === current));
    }
    if (heads.length > 0) {
      let current = heads[0].id;
      for (const el of heads) {
        if (el.getBoundingClientRect().top < innerHeight * 0.25) current = el.id;
      }
      toc.forEach((a) => a.classList.toggle("on", decodeURIComponent(a.hash.slice(1)) === current));
    }
  };
  addEventListener("scroll", onScroll, { passive: true });
  onScroll();

  // Code samples. Classes (colors in site.css): k keyword, s string, n number
  // or literal, c comment, t type or class, f function call, p property or
  // object key, o operator or punctuation; other identifiers keep the text
  // color. Text nodes are highlighted in place, so elements a page wraps
  // around lines (hover groups) stay, and a sample a page script rewrites is
  // highlighted again. Reference pages mark their language (pre.lang-ts,
  // pre.lang-sh; no class: plain text); site pages' samples are TypeScript
  // unless marked data-plain.
  const KEYWORDS = new Set(("const let var function return if else for while do of in new class extends implements import from export " +
    "default await async type interface static readonly void typeof instanceof switch case break continue throw try catch finally as " +
    "keyof enum declare abstract private protected public get set yield").split(" "));
  const LITERALS = new Set("true false null undefined this NaN Infinity".split(" "));
  const PRIMITIVES = new Set("number string boolean bigint symbol object unknown any never".split(" "));
  const TOKEN = new RegExp([
    /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/.source,
    /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/.source,
    /\b(0[xX][\da-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/.source,
    /([A-Za-z_$][\w$]*)/.source,
    /(=>|\.\.\.|[=!<>]=?=?|&&|\|\||\?\?|[-+*/%&|^~?:]=?|[{}()[\];,.])/.source,
  ].join("|"), "g");
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  const span = (cls, text) => (cls ? `<span class="${cls}">${esc(text)}</span>` : esc(text));

  const identifierClass = (code, word, start) => {
    if (KEYWORDS.has(word)) return "k";
    if (LITERALS.has(word) || /^[A-Z][A-Z\d_]+$/.test(word)) return "n";
    if (/^[A-Z]/.test(word) || PRIMITIVES.has(word)) return "t";
    const next = code.slice(start + word.length).match(/^\s*(\S\S?)/)?.[1] ?? "";
    if (next[0] === "(") return "f";
    if (code[start - 1] === "." && code[start - 2] !== ".") return "p";
    if (next[0] === ":" || next === "?:") return "p";
    return "";
  };
  const highlightCode = (code) => {
    let out = "";
    let last = 0;
    for (const m of code.matchAll(TOKEN)) {
      out += esc(code.slice(last, m.index));
      last = m.index + m[0].length;
      out += span(m[1] ? "c" : m[2] ? "s" : m[3] ? "n" : m[4] ? identifierClass(code, m[4], m.index) : "o", m[0]);
    }
    return out + esc(code.slice(last));
  };
  const SHELL = /("[^"]*"|'[^']*')|(^|\s)(--?[\w-]+)|^(\s*)([\w./-]+)|([^"'\s]+|\s+)/g;
  const highlightShell = (code) => code.split("\n").map((line) => {
    const hash = line.search(/(^|\s)#/);
    const body = hash < 0 ? line : line.slice(0, hash);
    const html = body.replace(SHELL, (m, str, sp, flag, lead, cmd) =>
      str ? span("s", str) : flag ? esc(sp) + span("n", flag) : cmd ? esc(lead) + span("f", cmd) : esc(m));
    return html + (hash < 0 ? "" : span("c", line.slice(hash)));
  }).join("\n");

  const HIGHLIGHT = new Set(["k", "s", "n", "c", "t", "f", "p", "o"]);
  const highlighterFor = (pre) => {
    if (pre.hasAttribute("data-plain")) return null;
    const lang = [...pre.classList].find((c) => c.startsWith("lang-"))?.slice(5);
    if (lang) return /^(sh|bash|shell|zsh)$/.test(lang) ? highlightShell : /^(ts|tsx|js|mjs|javascript|typescript|json)$/.test(lang) ? highlightCode : null;
    return pre.closest(".doc") ? null : highlightCode;
  };
  const highlightPre = (pre, highlighter) => {
    // Undo earlier highlighting (the pages' own spans or a previous pass), then tokenize each text node
    for (const el of pre.querySelectorAll("span")) {
      if (el.attributes.length === 1 && HIGHLIGHT.has(el.className)) el.replaceWith(...el.childNodes);
    }
    pre.normalize();
    const walker = document.createTreeWalker(pre, NodeFilter.SHOW_TEXT);
    const texts = [];
    while (walker.nextNode()) texts.push(walker.currentNode);
    for (const text of texts) {
      const template = document.createElement("template");
      template.innerHTML = highlighter(text.data);
      text.replaceWith(template.content);
    }
  };
  const samples = $$("pre").map((pre) => [pre, highlighterFor(pre)]).filter(([, h]) => h);
  const highlighters = new Map(samples);
  const watch = () => samples.forEach(([pre]) => codeObserver.observe(pre, { childList: true, characterData: true, subtree: true }));
  const codeObserver = new MutationObserver((records) => {
    codeObserver.disconnect();
    const changed = new Set(records.map((r) => (r.target.nodeType === 1 ? r.target : r.target.parentElement)?.closest("pre")));
    changed.forEach((pre) => pre && highlightPre(pre, highlighters.get(pre)));
    codeObserver.takeRecords();
    watch();
  });
  samples.forEach(([pre, h]) => highlightPre(pre, h));
  watch();
})();