Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ One-time setup:
the URLs from `SITE_URL`, nothing loaded from another origin, the deploy's triggers and
permissions, exact docs dependency versions, no docs committed, and the docs transforms
(titles, sidebar, links).
- **The site build**: the docs build against uptide-dev/uptide@main and
`scripts/check-links.mjs` finds no broken internal link or anchor in landing + docs.
- **The site build**: the docs build against uptide-dev/uptide@main, the landing page's
version and packs are rendered from that checkout and its terminal blocks checked against
their public sources (`scripts/landing-data.mjs`), and `scripts/check-links.mjs` finds no
broken internal link or anchor in landing + docs.

`scripts/assemble-site.mjs` and `scripts/check-links.mjs` belong to this repository. The rest
`scripts/assemble-site.mjs`, `scripts/landing-data.mjs` and `scripts/check-links.mjs` belong to
this repository. The rest
of `scripts/` (`dco.mjs`, `bots.mjs`, `public-tree.mjs`, `private-material.mjs`) and
`.github/workflows/dco.yml` are copies from uptide-dev/uptide, where they are tested. Change
them there first, then copy them here.
Expand Down
4 changes: 2 additions & 2 deletions docs-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ square corners, 1px rules and no shadows. Code blocks are Expressive Code with o
on `#131210` in both themes. `src/components/SiteTitle.astro` is the landing page's mark and
wordmark, linking to `/`. `public/favicon.svg` is a link to `site/favicon.svg`.

In light mode the dim text color is `#6A675F` rather than the landing page's `#76736B`, which
falls just short of 4.5:1 for small text on the page background.
In light mode the dim text color is `#6A675F`, the same as the landing page: 4.5:1 or better for
small text on the page background.

## Versions

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/styles/uptide.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
--uptide-line: #D5D2C8;
--uptide-text: #22211E;
--uptide-body: #57554F;
/* The landing page's dim (#76736B) is a shade short of 4.5:1 on the page for small text. */
/* The same dim as the landing page: 4.5:1 or better for small text on the page. */
--uptide-dim: #6A675F;
--uptide-accent: #1D63C4;

Expand Down
Binary file added docs/screenshots/site-404/404-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-404/404-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/site-landing/desktop-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/site-landing/desktop-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/hero-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/hero-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/site-landing/mobile-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/site-landing/mobile-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/packs-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/site-landing/packs-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 31 additions & 7 deletions scripts/assemble-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,45 @@
//
// node scripts/assemble-site.mjs [out] (default: _site)
//
// site/ goes to the root as it is, and the built docs (docs-site/dist, from `npm run build`
// in docs-site/) go under docs/. The landing page's %SITE_URL% placeholders are written in by
// the Pages workflow afterwards, on the copy, never on site/ itself.
import { cpSync, existsSync, rmSync } from 'node:fs';
// site/ goes to the root (404.html included: Pages serves it for every missing path), and the
// built docs (docs-site/dist, from `npm run build` in docs-site/) go under docs/. On the copy
// of the landing page, never on site/ itself:
// - the data-uptide slots (version, packs) are filled from the uptide-dev/uptide checkout the
// docs build made, cross-checked against its README (scripts/landing-data.mjs);
// - every <pre data-source> block must match its public source in that checkout.
// The %SITE_URL% placeholders are written in by the Pages workflow afterwards.
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkSources, fill, readPacks, readVersion, slots } from './landing-data.mjs';

const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
const out = resolve(process.argv[2] ?? join(root, '_site'));
const docs = join(root, 'docs-site', 'dist');
const sourceFile = join(root, 'docs-site', 'src', 'generated', 'source.json');

if (!existsSync(join(docs, 'index.html')))
throw new Error('docs-site/dist/index.html not found: run `npm run build` in docs-site/ first');
if (!existsSync(join(docs, 'index.html')) || !existsSync(sourceFile))
throw new Error('docs-site/dist or its source.json not found: run `npm run build` in docs-site/ first');

const source = JSON.parse(readFileSync(sourceFile, 'utf8'));
const checkout = resolve(root, 'docs-site', source.checkout);

rmSync(out, { recursive: true, force: true });
cpSync(join(root, 'site'), out, { recursive: true, dereference: true });
cpSync(docs, join(out, 'docs'), { recursive: true, dereference: true });
console.log(`site: ${out} (landing at /, docs at /docs/)`);

const page = join(out, 'index.html');
const html = readFileSync(page, 'utf8');
const problems = checkSources(html, checkout);
if (problems.length) {
console.error(`site/index.html no longer matches uptide-dev/uptide@${source.sha.slice(0, 7)}:\n ${problems.join('\n ')}`);
process.exit(1);
}
const packs = readPacks(checkout);
const version = readVersion(checkout);
writeFileSync(page, fill(html, slots({ version, packs })));

console.log(
`site: ${out} (landing at /, docs at /docs/), uptide ${version} @ ${source.sha.slice(0, 7)}: ` +
`${packs.filter((p) => p.status === 'verified').length} verified packs (${packs.map((p) => p.package).join(', ')})`,
);
170 changes: 170 additions & 0 deletions scripts/landing-data.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// What the landing page says about Uptide, from the uptide-dev/uptide checkout the docs build
// makes (docs-site/scripts/fetch-docs.mjs), so the page and the repository never disagree.
//
// - The packs: each pack's verification.json (what `uptide pack test --write` recorded) and
// ground-truth.json, rendered into the page's data-uptide slots. The version range lives in
// the pack's TypeScript metadata, so it is read from the README's generated packs table
// (scripts/docs-packs.ts in uptide, kept current by its CI), and every other column of that
// table must equal what this module computes from the JSON, or the build fails.
// - The version: packages/cli/package.json.
// - Terminal output: every <pre data-source="path"> on the page must match its source in the
// checkout, so a number on the page is always one a public run printed.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const PACKS = 'packages/core/src/packs';
const START = '<!-- packs:start -->';
const END = '<!-- packs:end -->';

const escape = (s) =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const unescape = (s) =>
s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');

/** The same rounding as uptide's README table. */
export const percent = (n) => `${Math.round(n * 100)}%`;

/** The README's generated packs table, by package: { range, precision, recall, repos, status }. */
export function readmePacks(readme) {
const start = readme.indexOf(START);
const end = readme.indexOf(END);
if (start < 0 || end < start) throw new Error(`README.md: no ${START} … ${END} packs table`);
const rows = new Map();
for (const line of readme.slice(start + START.length, end).split('\n')) {
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
const name = /^`([^`]+)`$/.exec(cells[0] ?? '');
if (!name || cells.length !== 6) continue;
const [, range, precision, recall, repos, status] = cells;
rows.set(name[1], { range, precision, recall, repos, status });
}
if (!rows.size) throw new Error('README.md: the packs table has no rows');
return rows;
}

/** Every pack in the checkout, in the README's order, cross-checked against the README. */
export function readPacks(checkout) {
const dir = join(checkout, PACKS);
const fromJson = new Map();
for (const name of readdirSync(dir).sort()) {
const verification = join(dir, name, 'verification.json');
const truth = join(dir, name, 'ground-truth.json');
if (!existsSync(verification) || !existsSync(truth)) continue;
const v = JSON.parse(readFileSync(verification, 'utf8'));
const t = JSON.parse(readFileSync(truth, 'utf8'));
const repos = t.repos.filter((r) => r.repo).map((r) => r.repo);
fromJson.set(t.package, {
package: t.package,
status: v.status,
precision: v.breaking.precision,
recall: v.breaking.recall,
falsePositives: v.breaking.falsePositives,
repos,
});
}
if (!fromJson.size) throw new Error(`${PACKS}: no pack with verification.json and ground-truth.json`);

const readme = readmePacks(readFileSync(join(checkout, 'README.md'), 'utf8'));
const problems = [];
for (const name of fromJson.keys()) if (!readme.has(name)) problems.push(`${name}: in ${PACKS} but not in the README table`);
for (const name of readme.keys()) if (!fromJson.has(name)) problems.push(`${name}: in the README table but not in ${PACKS}`);
for (const [name, row] of readme) {
const pack = fromJson.get(name);
if (!pack) continue;
const ours = { precision: percent(pack.precision), recall: percent(pack.recall), repos: pack.repos.join(', '), status: pack.status };
for (const [key, value] of Object.entries(ours))
if (row[key] !== value) problems.push(`${name}: ${key} is "${value}" from the JSON but "${row[key]}" in the README`);
}
if (problems.length)
throw new Error(`The landing page and uptide's README would disagree:\n ${problems.join('\n ')}`);

return [...readme.keys()].map((name) => ({ ...fromJson.get(name), range: readme.get(name).range }));
}

export function readVersion(checkout) {
return JSON.parse(readFileSync(join(checkout, 'packages/cli/package.json'), 'utf8')).version;
}

/** The HTML for each data-uptide slot on the landing page. */
export function slots({ version, packs }) {
const verified = packs.filter((p) => p.status === 'verified');
const names = verified.map((p) => `<code>${escape(p.package)}</code> ${escape(p.range)}`);
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join('');
return {
version: escape(version),
'packs-heading': `${verified.length} verified ${verified.length === 1 ? 'pack' : 'packs'}`,
'packs-list': `${list}.`,
'packs-rows': packs
.map((p) => {
const repos = p.repos
.map((r) => `<a href="https://github.com/${escape(r)}">${escape(r)}</a>`)
.join(', ');
return `<tr><td><code>${escape(p.package)}</code></td><td>${escape(p.range)}</td><td>${percent(p.precision)}</td><td>${percent(p.recall)}</td><td>${repos}</td><td>${escape(p.status)}</td></tr>`;
})
.join('\n '),
};
}

const SLOT = /(<(\w+)\b[^>]*\sdata-uptide="([\w-]+)"[^>]*>)([\s\S]*?)(<\/\2>)/g;

/** The page with every data-uptide slot filled; fails on a slot it does not know or one left out. */
export function fill(html, values) {
const seen = new Set();
const out = html.replace(SLOT, (_, open, _tag, key, _fallback, close) => {
if (!(key in values)) throw new Error(`data-uptide="${key}": no such value`);
seen.add(key);
return `${open}${values[key]}${close}`;
});
const missing = Object.keys(values).filter((k) => !seen.has(k));
if (missing.length) throw new Error(`the page has no data-uptide slot for: ${missing.join(', ')}`);
return out;
}

/** The code blocks of a Markdown file, each as an array of lines. */
function fencedBlocks(markdown) {
const blocks = [];
let current = null;
for (const line of markdown.split('\n')) {
if (/^\s*```/.test(line)) {
if (current) blocks.push(current);
current = current ? null : [];
} else if (current) current.push(line.trimEnd());
}
return blocks;
}

const contains = (block, lines) =>
block.some((_, i) => lines.every((line, j) => block[i + j] === line));

const flat = (s) => s.replace(/\s+/g, ' ').trim();

/**
* Problems with the page's terminal output: each <pre data-source="path"> must be a run of
* consecutive lines of a code block in that file of the checkout, or, with
* data-match="text", appear in it word for word (whitespace aside), as uptide's own test
* checks its privacy statement.
*/
export function checkSources(html, checkout) {
const problems = [];
let count = 0;
for (const [, attrs, inner] of html.matchAll(/<pre\b([^>]*\sdata-source="[^"]*"[^>]*)>([\s\S]*?)<\/pre>/g)) {
count++;
const source = /data-source="([^"]*)"/.exec(attrs)[1];
const mode = /data-match="([^"]*)"/.exec(attrs)?.[1] ?? 'lines';
const file = join(checkout, source);
if (!existsSync(file)) {
problems.push(`${source}: not in the checkout`);
continue;
}
// Diff lines are block spans with no newline between them.
const text = unescape(inner.replace(/<\/span>(?=<span class="l\b)/g, '</span>\n').replace(/<[^>]+>/g, ''));
const markdown = readFileSync(file, 'utf8');
const lines = text.split('\n').map((l) => l.trimEnd());
const ok =
mode === 'text'
? flat(markdown).includes(flat(text.replace(/^Privacy:\s*/, '')))
: fencedBlocks(markdown).some((block) => contains(block, lines));
if (!ok) problems.push(`${source}: the page's block starting "${lines[0]}" is not in it`);
}
if (!count) problems.push('the page has no <pre data-source> block to check');
return problems;
}
67 changes: 67 additions & 0 deletions site/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · Uptide</title>
<meta name="robots" content="noindex">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#F4F2EC" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#131210" media="(prefers-color-scheme: dark)">
<!-- GitHub Pages serves this file for every missing path, so every URL here is absolute. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/fonts/space-grotesk-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face{font-family:'Space Grotesk';font-weight:600;font-display:swap;src:url(/fonts/space-grotesk-latin-600-normal.woff2) format('woff2')}
@font-face{font-family:'Instrument Sans';font-weight:400;font-display:swap;src:url(/fonts/instrument-sans-latin-400-normal.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-weight:600;font-display:swap;src:url(/fonts/ibm-plex-mono-latin-600-normal.woff2) format('woff2')}

/* The landing page's tokens (site/index.html). */
:root{color-scheme:light;--bg:#F4F2EC;--surface:#FAF9F5;--line:#D5D2C8;--text:#22211E;--body:#57554F;--dim:#6A675F;--accent:#1D63C4;
--logo-wave:#7CC4FF;--logo-line:#3E7FD9;
--display:'Space Grotesk',system-ui,sans-serif;--sans:'Instrument Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;--gx:40px}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#131210;--surface:#1B1A17;--line:#36342F;--text:#F0EEE7;--body:#BCB9AF;--dim:#918E83;--accent:#7CC4FF}}
:root[data-theme=dark]{color-scheme:dark;--bg:#131210;--surface:#1B1A17;--line:#36342F;--text:#F0EEE7;--body:#BCB9AF;--dim:#918E83;--accent:#7CC4FF}

*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 var(--sans);-webkit-font-smoothing:antialiased;display:flex;flex-direction:column}
:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.dots{background-image:radial-gradient(color-mix(in srgb,var(--dim) 24%,transparent) .7px,transparent .7px);background-size:18px 18px}
.bar{border-bottom:1px solid var(--line)}
.bar-inner,main{max-width:1200px;width:100%;margin:0 auto;border-left:1px solid var(--line);border-right:1px solid var(--line)}
.bar-inner{height:64px;padding:0 var(--gx);display:flex;align-items:center}
.brand{display:flex;align-items:center;gap:9px;color:var(--text);text-decoration:none;font:600 24px var(--display);letter-spacing:-.04em}
.brand svg{width:30px;height:30px;flex-shrink:0}
.brand .wave{stroke:var(--logo-wave)} .brand .base{stroke:var(--logo-line)}
main{flex:1;padding:120px var(--gx);display:flex;flex-direction:column;align-items:flex-start;gap:32px}
h1{margin:0;font:600 clamp(40px,6vw,72px)/1.05 var(--display);letter-spacing:-.045em;color:var(--text);text-wrap:balance}
.actions{display:flex;flex-wrap:wrap;gap:10px}
.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;font:600 12px var(--mono);letter-spacing:.12em;text-transform:uppercase;text-decoration:none;border:1px solid var(--line);color:var(--text);background:var(--surface);white-space:nowrap}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.solid{background:var(--text);color:var(--bg);border-color:var(--text)}
.btn.solid:hover{background:var(--accent);border-color:var(--accent);color:var(--bg)}
@media (max-width:900px){:root{--gx:20px}.bar-inner,main{border-left:0;border-right:0}main{padding-top:72px}}
</style>
</head>
<body>

<header class="bar">
<div class="bar-inner">
<a class="brand" href="/" aria-label="Uptide home">
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true"><path d="M3 23c4.5 0 6.8-2.6 8.6-6.4C13.6 12.2 16.2 8 21.2 8c3.9 0 6.8 2.6 6.8 5.8 0 2.4-1.8 4.1-4.1 4.1-1.9 0-3.1-1.2-3.1-2.8" class="wave" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 27.5h26" class="base" stroke-width="3" stroke-linecap="round"/></svg>
uptide
</a>
</div>
</header>

<main class="dots">
<h1>This page is not here.</h1>
<nav class="actions" aria-label="Where to go">
<a class="btn solid" href="/">Home</a>
<a class="btn" href="/docs/">Docs</a>
</nav>
</main>

</body>
</html>
Loading
Loading