mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Search Console had 5,812 hanzo.ai URLs as 404, 1,323 as duplicates without a canonical and 165 as noindex. Three causes: the export shipped a noindex page that forwarded in the browser at each retired address, every page also answered at /x/, /x.html and www, and each page's .md twin was an indexable copy of it. Retired addresses are now 301s at the edge, rendered into hanzoai/universe by scripts/edge.mjs from lib/edge.ts: MOVED grows the old product taxonomy, the first site's routes, pages later dropped and the gateway's old docs; SHELVES sends the rest of /docs/ to docs.hanzo.ai; and lib/data/retired.json holds the 256 model pages the catalog has dropped, read against the catalog each build renders. The shells go, except the four that are Go import paths, which ?go-get=1 still reads. The sitemap lists only pages at their own address, each with a lastmod from its files' last commit, so deploy and gates check out the whole history. /privacy, /terms and /cookies stay up for the shared footer but point at /legal/*, so they leave the sitemap. /pay and /v1 join the Disallow list. Gates run the edge's rules over the export: every retired address lands on a page in one hop, every page answers at one address, every sitemap URL is final and dated, and no page links a redirect. scripts/crawl.mjs asks production the same questions, and reads a Search Console export.
185 lines
7.2 KiB
TypeScript
185 lines
7.2 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { readdirSync, type Dirent } from 'node:fs'
|
|
import { join, relative } from 'node:path'
|
|
|
|
/**
|
|
* The two trees a route lives in, read as routes.
|
|
*
|
|
* `routes()` reads the App Router tree — what the site ANSWERS. `shipped()`
|
|
* reads `out/` — what the export SHIPS. They are separate questions and the
|
|
* gap between them is the interesting one, so both walks live here, once,
|
|
* because the callers drift the moment there are two of them: `app/sitemap.ts`
|
|
* OFFERS what the tree contains, and a gate MEASURES what a hand-written list
|
|
* in a spec told it about. A page the walk finds and the list has never heard
|
|
* of is a page that ships unmeasured — which is how the longest title on the
|
|
* site came to be the one title no gate looked at. Derive the list; do not
|
|
* maintain it.
|
|
*
|
|
* Build-time only: they read the filesystem, so nothing that reaches a client
|
|
* bundle may import this.
|
|
*/
|
|
|
|
/** A route, and the directory that answers it. */
|
|
export interface Route {
|
|
/** The URL path, always leading-slash, `/` for the root. */
|
|
path: string
|
|
/** The directory holding its `page.*` and any component beside it. */
|
|
dir: string
|
|
}
|
|
|
|
const PAGE = /^page\.(tsx|ts|jsx|js|mdx)$/
|
|
const SOURCE = /\.(tsx|ts|jsx|js|mdx)$/
|
|
const isGroup = (name: string) => name.startsWith('(') && name.endsWith(')')
|
|
/** Dynamic segments and private directories are not routes anything can address. */
|
|
const skip = (name: string) => name.startsWith('[') || name.startsWith('_')
|
|
|
|
/** Every route the tree answers, sorted, with the root included. */
|
|
export function routes(appDir: string): Route[] {
|
|
const found = new Map<string, Route>()
|
|
const root: Route = { path: '/', dir: appDir }
|
|
found.set('/', root)
|
|
for (const route of walk(appDir, [])) found.set(route.path, route)
|
|
return [...found.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
}
|
|
|
|
/**
|
|
* The source a route is written in: its own directory's files.
|
|
*
|
|
* Not recursive — a nested directory is its own route. That boundary is what
|
|
* makes "which pages are built on the kit" answerable: `/risk` renders the kit
|
|
* through `risk-client.tsx`, a sibling of `page.tsx`, and a scan that read only
|
|
* `page.tsx` would miss every page written that way.
|
|
*/
|
|
export function sources(route: Route): string[] {
|
|
return readdir(route.dir)
|
|
.filter((e) => e.isFile() && SOURCE.test(e.name))
|
|
.map((e) => join(route.dir, e.name))
|
|
}
|
|
|
|
/** A route the export ships a page for, and the file that answers it. */
|
|
export interface Page {
|
|
/** The URL path the file is served at, always leading-slash. */
|
|
route: string
|
|
/** The `.html` file in the export. */
|
|
file: string
|
|
}
|
|
|
|
/**
|
|
* Every route the export ships a page for.
|
|
*
|
|
* `/404` and `/_not-found` are Next's own pages rather than routes of ours —
|
|
* they carry a `noindex` the framework writes — and the App Router skips
|
|
* `_`-prefixed directories, so both are out. A file and a directory of the
|
|
* same name both exist (`out/api.html` beside `out/api/`), which is why the
|
|
* route comes from the file and not from the directory walk.
|
|
*/
|
|
export function shipped(outDir: string): Page[] {
|
|
const found: Page[] = []
|
|
const walk = (dir: string, seg: string[]) => {
|
|
for (const e of readdir(dir)) {
|
|
const path = join(dir, e.name)
|
|
if (e.isDirectory()) {
|
|
if (!e.name.startsWith('_')) walk(path, [...seg, e.name])
|
|
continue
|
|
}
|
|
if (!e.name.endsWith('.html')) continue
|
|
const name = e.name.slice(0, -'.html'.length)
|
|
if (name.startsWith('_') || name === '404') continue
|
|
const route = name === 'index' ? '/' + seg.join('/') : '/' + [...seg, name].join('/')
|
|
found.push({ route: route === '/' ? '/' : route.replace(/\/$/, ''), file: path })
|
|
}
|
|
}
|
|
walk(outDir, [])
|
|
return found.sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : 0))
|
|
}
|
|
|
|
/**
|
|
* The words an exported page renders, without markup.
|
|
*
|
|
* Scoped to the page's own `<main>` — the innermost, since the kit renders one
|
|
* inside the layout's — because the shell's nav and footer are the same on
|
|
* every page and would drown the thing being measured.
|
|
*
|
|
* Attribute VALUES go before the tags do. Stripping `<[^>]+>` is not a parser:
|
|
* any attribute holding a `>` ends the match early and the rest of it lands in
|
|
* the "words", which for a measurement inflates a page that has nothing on it
|
|
* into one that looks like it has something. Removing quoted values first —
|
|
* every attribute Next emits is double-quoted — leaves that unreachable.
|
|
*
|
|
* This is a measurement over the whole export, cheap enough to run on every
|
|
* page. Asserting what one page SAYS is a different job with a different
|
|
* subject: the browser's `innerText`, which is what a reader gets.
|
|
*/
|
|
export function copy(html: string): string {
|
|
const parts = html.split('<main')
|
|
const inner = parts[parts.length - 1]
|
|
const end = inner.indexOf('</main>')
|
|
if (parts.length < 2 || end < 0) return ''
|
|
return inner
|
|
.slice(0, end)
|
|
.replace(/<script[\s\S]*?<\/script>/g, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/g, ' ')
|
|
.replace(/="[^"]*"/g, '=""')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/&[a-z]+;|&#\d+;/g, "'")
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* When each file last changed, by its last commit: one pass over history,
|
|
* newest first, so a file's first date is its latest. Paths are relative to
|
|
* `root`. A shallow clone knows one commit and dates everything by it, which is
|
|
* why the deploy checks out the whole history.
|
|
*/
|
|
export function changed(root: string, dirs: string[]): Map<string, Date> {
|
|
const log = execFileSync('git', ['log', '--format=%x00%cI', '--name-only', 'HEAD', '--', ...dirs], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
maxBuffer: 1 << 28,
|
|
})
|
|
const dates = new Map<string, Date>()
|
|
let when: Date | null = null
|
|
for (const line of log.split('\n')) {
|
|
if (line.startsWith('\0')) when = new Date(line.slice(1))
|
|
else if (line && when && !dates.has(line)) dates.set(line, when)
|
|
}
|
|
return dates
|
|
}
|
|
|
|
/** The latest of `files`' dates, or null when git knows none of them. */
|
|
export function latest(dates: Map<string, Date>, root: string, files: string[]): Date | null {
|
|
let best: Date | null = null
|
|
for (const file of files) {
|
|
const d = dates.get(relative(root, file))
|
|
if (d && (!best || d > best)) best = d
|
|
}
|
|
return best
|
|
}
|
|
|
|
/** Missing or unreadable is empty, not a throw: the walk describes what is there. */
|
|
function readdir(dir: string): Dirent[] {
|
|
try {
|
|
// Encoding is explicit: without it @types/node resolves the Buffer overloads
|
|
// — Dirent<NonSharedBuffer> — and every `.name` below stops being a string.
|
|
return readdirSync(dir, { withFileTypes: true, encoding: 'utf8' })
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function walk(dir: string, seg: string[]): Route[] {
|
|
const out: Route[] = []
|
|
for (const e of readdir(dir)) {
|
|
if (!e.isDirectory() || skip(e.name)) continue
|
|
// A route group — `(marketing)` — contributes no URL segment.
|
|
const next = isGroup(e.name) ? seg : [...seg, e.name]
|
|
const child = join(dir, e.name)
|
|
if (readdir(child).some((f) => f.isFile() && PAGE.test(f.name))) {
|
|
out.push({ path: '/' + next.join('/'), dir: child })
|
|
}
|
|
out.push(...walk(child, next))
|
|
}
|
|
return out
|
|
}
|