import { createHash } from "node:crypto" import { realpathSync, statSync } from "node:fs" import { copyFile, mkdir, readFile, writeFile, rm } from "node:fs/promises" import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { Marked } from "marked" import { HELP_BASE_URL } from "../packages/ui/src/lib/help-url.ts" export { HELP_BASE_URL } const root = fileURLToPath(new URL("../", import.meta.url)) const chrome = resolve(root, "docs/help") const repo = "https://github.com/NeuralNomadsAI/CodeNomad" export const SECTION_IDS = ["start", "workflows", "opencode-setup", "remote-access", "customize", "extensions", "desktop", "help"] // Translation gaps still build (English fallback); every other problem is a broken page. export const TRANSLATION_PROBLEMS = new Set(["missing-translation"]) const RTL_LOCALES = new Set(["he"]) const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const RESERVED_IDS = new Set(["content", "help-query", "help-results"]) const SCHEME = /^[a-z][a-z0-9+.-]*:/i // Generated blocks, each written as a top-level paragraph of its own: `{{release}}`. export const DIRECTIVES = new Set(["release", "download-button", "tour-video", "funding"]) // Directives restricted to some pages; download-button also makes its page load help-download.js. const DIRECTIVE_PAGES = new Map([["download-button", ["download"]], ["tour-video", ["index"]], ["funding", ["sponsor", "download"]]]) // Funding channels accepted in docs/help/funding.json: exact canonical URLs, no credentials, port, query or fragment. const FUNDING_URLS = [/^https:\/\/github\.com\/sponsors\/(?=[A-Za-z0-9-]{1,39}$)[A-Za-z0-9](?:-?[A-Za-z0-9])*$/] // The home tour: a silent loop that plays only on wide screens without reduced motion; elsewhere // the poster image replaces it (help.css) and no video is downloaded. const TOUR_VIDEO = resolve(root, "docs/screenshots/codenomad-tour.mp4") const TOUR_POSTER = resolve(root, "docs/screenshots/codenomad-tour-poster.jpg") export const TOUR_MEDIA = "(prefers-reduced-motion: no-preference) and (min-width: 721px)" export const TOUR_URL = "https://youtu.be/IGuOApJ6-Kc" // A paragraph holding nothing but one link to another help page is a call to action. const ACTION = /^[^<]+<\/a>$/ const DIRECTIVE = /^\{\{([a-z0-9-]+)\}\}$/ const RELEASE_TAG = /^v(\d+\.\d+\.\d+)$/ const RELEASE_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/ const ASSET_NAME = /^[A-Za-z0-9._-]+$/ const LATEST_RELEASE = `${repo}/releases/latest` const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]) const stripTags = (html) => html.replace(/<\/(?:p|div|h[1-6]|li|ul|ol|td|th|tr|table|blockquote|pre)\b[^>]*>|<(?:br|hr)\b[^>]*>/gi, " ").replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt|quot|#39|#\d+|#x[\da-f]+);/gi, (entity) => { const named = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }[entity.toLowerCase()] if (named) return named const code = entity[2].toLowerCase() === "x" ? parseInt(entity.slice(3, -1), 16) : Number(entity.slice(2, -1)) return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "�" }).replace(/\s+/g, " ").trim() // Chrome strings are plain text; placeholders receive trusted HTML. const format = (template, values = {}) => escape(template).replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match) const isFile = (path) => { try { return statSync(path).isFile() } catch { return false } } const pathExists = (path) => { try { return statSync(path) } catch { return null } } const pageUrl = (locale, page) => `${HELP_BASE_URL}${locale}/${page === "index" ? "" : `${page}.html`}` const insideRepo = (path) => { const name = relative(realpathSync(root), realpathSync(path)); return !name.startsWith(`..${sep}`) && name !== ".." && !isAbsolute(name) } const plainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) const exactKeys = (value, keys) => plainObject(value) && Object.keys(value).sort().join() === keys // Decimal megabytes and UTC calendar dates, formatted for the page locale at build time. const formatSize = (locale, bytes) => new Intl.NumberFormat(locale, { style: "unit", unit: "megabyte", maximumFractionDigits: 0 }).format(Math.round(bytes / 1e6)) const formatDate = (locale, date) => new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(date) async function readJson(path) { return JSON.parse(await readFile(path, "utf8")) } export async function readSite(content) { const site = await readJson(resolve(content, "site.json")) const locales = site.locales if (!Array.isArray(locales) || locales[0] !== "en" || new Set(locales).size !== locales.length) { throw new Error("site.json: locales must be unique and start with en") } if (locales.some((locale) => !["en", "fr", "de", "es", "he", "ja", "ne", "ru", "tr", "zh-Hans"].includes(locale))) throw new Error("site.json: unsupported locale") const ids = (site.sections ?? []).map((section) => section.id) if (ids.join() !== SECTION_IDS.join()) throw new Error(`site.json: sections must be ${SECTION_IDS.join(", ")}`) const pages = site.sections.flatMap((section) => section.pages) if (pages[0] !== "index") throw new Error("site.json: the start section must begin with index") for (const page of pages) { if (!ID_PATTERN.test(page)) throw new Error(`site.json: invalid page name ${page}`) } if (new Set(pages).size !== pages.length) throw new Error("site.json: a page is listed twice") return site } function flatKeys(value, prefix = "") { return Object.entries(value).flatMap(([key, entry]) => { if (entry && typeof entry === "object" && !Array.isArray(entry)) return flatKeys(entry, `${prefix}${key}.`) if (typeof entry !== "string" || !entry.trim()) throw new Error(`empty or non-string chrome value: ${prefix}${key}`) return [`${prefix}${key}`] }).sort() } const DOWNLOAD_KEYS = ["label", "windows", "windowsDetail", "macos", "macosDetail", "intel", "linux", "linuxDetail", "mobile", "mobileDetail"] export async function readChromeStrings(locales, directory = chrome) { const strings = {} for (const locale of locales) strings[locale] = await readJson(resolve(directory, `i18n/${locale}.json`)) const expected = ["language", "siteTitle", "brand", "tagline", "onThisPage", "skipToContent", "editPage", "translatePage", "reportProblem", "github", ...DOWNLOAD_KEYS.map((key) => `download.${key}`), "languageLabel", "projectLinks", "helpTopics", "scope", "findHint", "untranslated", ...SECTION_IDS.map((id) => `sections.${id}`), ...["label", "clear", "empty", "noResults", "failure", "loading", "count"].map((key) => `search.${key}`), ...["published", "unavailable"].map((key) => `release.${key}`), ...["label", "poster", "caption"].map((key) => `tour.${key}`), ...["label", "action", "recipient"].map((key) => `funding.${key}`)].sort().join() for (const locale of locales) { if (flatKeys(strings[locale]).join() !== expected) throw new Error(`docs/help/i18n/${locale}.json: keys differ from en.json`) if (Object.keys(strings[locale].sections).join() !== SECTION_IDS.join()) throw new Error(`docs/help/i18n/${locale}.json: section labels must follow site.json order`) } return strings } /** Expected stable release assets in display order; `{version}` is the release tag without its leading v. */ export async function readDownloads(directory = chrome) { const downloads = await readJson(resolve(directory, "downloads.json")) if (!Array.isArray(downloads) || !downloads.length) throw new Error("downloads.json: expected a non-empty list") const ids = new Set() const templates = new Set() for (const entry of downloads) { if (!exactKeys(entry, "arch,host,id,platform,template")) throw new Error("downloads.json: each entry needs exactly id, platform, host, arch and template") if (!ID_PATTERN.test(entry.id) || ids.has(entry.id)) throw new Error(`downloads.json: invalid or duplicate id ${entry.id}`) if (!["windows", "macos", "linux"].includes(entry.platform) || !["tauri", "electron"].includes(entry.host) || !["x64", "arm64"].includes(entry.arch)) throw new Error(`downloads.json: unsupported platform, host or arch for ${entry.id}`) if (typeof entry.template !== "string" || entry.template.split("{version}").length !== 2 || !ASSET_NAME.test(entry.template.replace("{version}", "0")) || templates.has(entry.template)) throw new Error(`downloads.json: ${entry.id} needs a unique asset name with one {version}`) ids.add(entry.id) templates.add(entry.template) } return downloads } /** Validates funding channels in display order: each { id, name, url, recipient } with an allowlisted URL. */ export function validateFunding(funding) { if (!Array.isArray(funding)) throw new Error("funding.json: expected a list") const ids = new Set() for (const entry of funding) { if (!exactKeys(entry, "id,name,recipient,url")) throw new Error("funding.json: each entry needs exactly id, name, url and recipient") if (typeof entry.id !== "string" || !ID_PATTERN.test(entry.id) || ids.has(entry.id)) throw new Error(`funding.json: invalid or duplicate id ${entry.id}`) for (const key of ["name", "recipient"]) if (typeof entry[key] !== "string" || !entry[key].trim()) throw new Error(`funding.json: ${entry.id} needs a non-empty ${key}`) if (typeof entry.url !== "string" || !FUNDING_URLS.some((pattern) => pattern.test(entry.url))) throw new Error(`funding.json: ${entry.id} url is not an accepted funding page`) ids.add(entry.id) } return funding } export async function readFunding(directory = chrome) { return validateFunding(await readJson(resolve(directory, "funding.json"))) } /** * Revalidates resolved release data before anything reaches a page: every downloads.json asset * must be the exact NeuralNomadsAI/CodeNomad download URL for this tag with a positive size. */ export function validateRelease(release, downloads) { const fail = (message) => { throw new Error(`release data: ${message}`) } if (!exactKeys(release, "assets,publishedAt,tag,version")) fail("expected exactly tag, version, publishedAt and assets") const version = typeof release.tag === "string" ? RELEASE_TAG.exec(release.tag)?.[1] : undefined if (!version || release.version !== version) fail("tag must be vMAJOR.MINOR.PATCH and version the tag without its v") const published = typeof release.publishedAt === "string" && RELEASE_DATE.test(release.publishedAt) ? new Date(release.publishedAt) : null if (!published || Number.isNaN(published.getTime()) || published.toISOString().slice(0, 19) !== release.publishedAt.slice(0, 19)) fail("publishedAt must be a valid UTC timestamp") if (!exactKeys(release.assets, downloads.map((entry) => entry.id).sort().join())) fail("assets must list exactly the downloads.json ids") const assets = new Map() for (const entry of downloads) { const asset = release.assets[entry.id] const url = `${repo}/releases/download/${encodeURIComponent(release.tag)}/${encodeURIComponent(entry.template.replace("{version}", version))}` if (!exactKeys(asset, "size,url")) fail(`${entry.id} needs exactly url and size`) if (asset.url !== url) fail(`${entry.id} must be ${url}`) if (!Number.isSafeInteger(asset.size) || asset.size <= 0) fail(`${entry.id} size must be a positive integer`) assets.set(entry.id, { url, size: asset.size }) } return { version, published, assets } } function renderMarkdown(markdown, file, media, report, { locale, page, strings, release, downloads, funding }) { const downloadIds = new Set(downloads.map((entry) => entry.id)) const headings = [] const links = [] const directives = [] let title = null let description = "" const repoLink = (path, fragment) => { const target = resolve(dirname(file), decodeURIComponent(path)) const found = pathExists(target) const inRepo = found ? relative(realpathSync(root), realpathSync(target)) : "" if (!found || !insideRepo(target)) { report("broken-link", `${path} does not resolve to a repository file`) return path } return `${repo}/${found.isDirectory() ? "tree" : "blob"}/dev/${inRepo.split(sep).join("/")}${fragment ? `#${fragment}` : ""}` } const marked = new Marked({ renderer: { heading(text, level, raw) { const id = /\{#([^}]*)\}\s*$/.exec(raw)?.[1] const html = text.replace(/\s*\{#[^}]*\}\s*$/, "") if (level === 1) { if (title !== null) report("extra-h1", `second H1: ${raw}`) if (id) report("h1-id", "H1 is the page title and must not carry an explicit id; use ## for linkable sections") title ??= stripTags(html) return `

${html}

\n` } if (!id) { report("missing-id", `heading without an explicit {#id}: ${raw}`) return `${html}\n` } if (!ID_PATTERN.test(id)) report("invalid-id", `heading id must be ASCII kebab-case: ${id}`) else if (RESERVED_IDS.has(id)) report("reserved-id", `heading id is reserved by the site chrome: ${id}`) else if (headings.some((heading) => heading.id === id)) report("duplicate-id", `duplicate heading id: ${id}`) headings.push({ level, id, text: stripTags(html) }) return `${html}\n` }, paragraph(text) { description ||= stripTags(text) return `${text}

\n` }, link(href, linkTitle, text) { let target = href let suffix = "" if (href.startsWith("release:")) { // Without resolved release data (local and pull-request builds) every asset opens the latest release. const id = href.slice("release:".length) const asset = release?.assets.get(id) if (!downloadIds.has(id)) report("unknown-release-asset", `${href} is not listed in docs/help/downloads.json`) target = asset?.url ?? LATEST_RELEASE if (asset) suffix = ` ${escape(formatSize(locale, asset.size))}` } else if (!SCHEME.test(href) && !href.startsWith("//")) { const [path, fragment] = href.split("#", 2) if (!path) links.push({ page: null, fragment }) else if (/^(?:\.\/)?[a-z0-9-]+\.md$/.test(path)) { const page = path.replace(/^\.\//, "").slice(0, -3) links.push({ page, fragment }) target = `${page}.html${fragment ? `#${fragment}` : ""}` } else if (path.startsWith("/")) report("broken-link", `absolute paths do not work on a project site: ${href}`) else target = repoLink(path, fragment) } return `
${text}${suffix}` }, image(href, imageTitle, text) { let src = href if (SCHEME.test(href) || href.startsWith("/")) report("remote-image", `images must be repository files: ${href}`) else { const path = resolve(dirname(file), decodeURIComponent(href)) if (!isFile(path) || !insideRepo(path)) report("missing-image", `repository image not found: ${href}`) else src = `../assets/media/${mediaName(media, realpathSync(path))}` } return `${escape(text)}` }, } }) // One hidden block per detected platform, each with its final label and target; help-download.js // reveals at most one. Without release data every desktop target is the latest release page. const downloadButton = () => { const t = strings.download const tauri = (platform, arch) => { const entry = downloads.find((candidate) => candidate.host === "tauri" && candidate.platform === platform && candidate.arch === arch) if (!entry) report("unknown-release-asset", `{{download-button}} needs a Tauri ${platform} ${arch} entry in docs/help/downloads.json`) return release?.assets.get(entry?.id) } const detail = (text, asset) => `

${escape(text)}${asset ? ` · ${escape(formatSize(locale, asset.size))}` : ""}

` const block = (os, href, label, lines) => `` const desktop = (os, platform, arch, label, text, extra = "") => { const asset = tauri(platform, arch) return block(os, asset?.url ?? LATEST_RELEASE, label, [detail(text, asset), extra]) } const intel = tauri("macos", "x64") return `\n` } // The poster doubles as an image for narrow screens, reduced motion and print, where the // source's media condition fails, so no player without a video is ever shown. const tourVideo = () => { const asset = (path) => { if (isFile(path)) return `../assets/media/${mediaName(media, realpathSync(path))}` report("missing-image", `tour media not found: ${relative(root, path)}`) return "" } const video = asset(TOUR_VIDEO), poster = asset(TOUR_POSTER) const t = strings.tour return `
${escape(t.poster)}
${escape(t.caption)}
\n` } // One outlined button per funding.json channel, naming who receives the money. const fundingList = () => { if (!funding.length) { report("missing-funding", "{{funding}} needs at least one channel in docs/help/funding.json") return "" } const t = strings.funding const items = funding.map((entry) => `
  • ${format(t.action, { channel: `${escape(entry.name)}` })}${format(t.recipient, { recipient: `${escape(entry.recipient)}` })}
  • `) return `\n` } const generate = { release: () => release ? `

    ${format(strings.release.published, { version: escape(release.version), date: escape(formatDate(locale, release.published)) })}

    \n` : `

    ${escape(strings.release.unavailable)}

    \n`, "download-button": downloadButton, "tour-video": tourVideo, funding: fundingList, } // Directives are replaced at token level so list items, cells and quotes never qualify and the // generated block never becomes the meta description; any remaining {{ in rendered text is a problem. const tokens = marked.lexer(markdown) for (const [index, token] of tokens.entries()) { const name = token.type === "paragraph" ? DIRECTIVE.exec(token.text.trim())?.[1] : undefined if (!DIRECTIVES.has(name)) continue if (directives.includes(name)) report("duplicate-directive", `{{${name}}} appears more than once`) if (DIRECTIVE_PAGES.has(name) && !DIRECTIVE_PAGES.get(name).includes(page)) report("misplaced-directive", `{{${name}}} belongs only on ${DIRECTIVE_PAGES.get(name).map((other) => `${other}.md`).join(" or ")}`) directives.push(name) tokens[index] = { type: "html", block: true, pre: false, raw: token.raw, text: generate[name]() } } const html = marked.parser(tokens) for (const [match] of html.replace(/|/gi, "").matchAll(/\{\{[^{}<]*(?:\}\})?/g)) { report("unknown-directive", `${match} is not a known directive on a paragraph of its own`) } if (title === null) report("missing-h1", "page has no H1 title") return { html: html.replace(//g, '
    ').replace(/<\/table>/g, '
    '), title: title ?? "", description, headings, links, directives } } // Shared screenshots are copied once; distinct files with the same name get a suffix. function mediaName(media, path) { if (media.has(path)) return media.get(path) const extension = extname(path) const stem = basename(path, extension) const taken = new Set(media.values()) let name = `${stem}${extension}` for (let count = 2; taken.has(name); count++) name = `${stem}-${count}${extension}` media.set(path, name) return name } function languageMenu(site, strings, locale, href) { const items = site.locales.map((other) => `
  • ${escape(strings[other].language)}
  • `).join("") return `
    ${escape(strings[locale].languageLabel)}: ${escape(strings[locale].language)}
      ${items}
    ` } function pageDocument({ site, strings, locale, page, rendered, fallback, available, sponsor }) { const t = strings[locale] const rtl = RTL_LOCALES.has(locale) const pageTitle = rendered.title === t.siteTitle ? escape(t.siteTitle) : `${escape(rendered.title)} - ${escape(t.siteTitle)}` const description = rendered.description.length > 160 ? `${rendered.description.slice(0, 157).replace(/\s+\S*$|&[^;\s]*$/, "")}...` : rendered.description const alternates = [...site.locales.map((other) => [other, other]), ["x-default", "en"]] .map(([hreflang, other]) => ``).join("\n ") const nav = site.sections.map((section) => { const pages = section.pages.filter((name) => available.has(name)) if (!pages.length) return "" const items = pages.map((name) => `
  • ${escape(available.get(name))}
  • `).join("") return `` }).join("") const outline = rendered.headings.filter((heading) => heading.level === 2) .map((heading) => `
  • ${escape(heading.text)}
  • `).join("") const source = fallback ? `${repo}/new/dev/docs/help/${locale}?filename=${page}.md` : `${repo}/edit/dev/docs/help/${locale}/${page}.md` const keys = 'Ctrl / ⌘ + F' return ` ${pageTitle} ${alternates} ${rendered.directives.includes("download-button") ? `\n ` : ""}
    ${fallback ? `

    ${escape(t.untranslated)}

    ` : ""} ${rendered.html}
    ` } export function languageRedirectScript(locales) { const known = Object.fromEntries(locales.map((locale) => [locale.toLowerCase(), locale])) return `(()=>{const k=${JSON.stringify(known)};const m=(t)=>{if(typeof t!=="string"||!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*$/i.test(t))return; t=t.toLowerCase().replace(/_/g,"-");const b=t.split("-")[0];return k[t]||(b==="zh"?k["zh-hans"]:b==="iw"?k.he:k[b])};for(const t of navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language]){const l=m(t);if(l){location.replace(l+"/");return}}location.replace("en/")})()` } function chooserDocument(site, strings) { const script = languageRedirectScript(site.locales) const hash = createHash("sha256").update(script).digest("base64") const alternates = [...site.locales.map((locale) => [locale, `${HELP_BASE_URL}${locale}/`]), ["x-default", HELP_BASE_URL]] .map(([hreflang, href]) => ``).join("\n ") const items = site.locales.map((locale) => `
  • ${escape(strings[locale].language)}${escape(strings[locale].siteTitle)}
  • `).join("\n ") return ` ${escape(strings.en.siteTitle)} ${alternates}

    ${escape(strings.en.siteTitle)}

    ${escape(strings.en.tagline)}

      ${items}
    ` } /** * Builds every locale listed in site.json. Pages missing from a locale fall back to English. * Returns problems instead of throwing so callers decide which kinds are fatal. */ export async function buildHelp({ content = chrome, output = resolve(root, "dist/help"), release, funding } = {}) { const site = await readSite(content) const strings = await readChromeStrings(site.locales) const downloads = await readDownloads() const channels = funding === undefined ? await readFunding() : validateFunding(funding) const releaseData = release == null ? null : validateRelease(release, downloads) const problems = [] const media = new Map() const rendered = new Map() const pages = site.sections.flatMap((section) => section.pages) const render = async (locale, page) => { const file = resolve(content, locale, `${page}.md`) if (!isFile(file)) return null const report = (kind, message) => problems.push({ kind, locale, page, message }) return renderMarkdown(await readFile(file, "utf8"), file, media, report, { locale, page, strings: strings[locale], release: releaseData, downloads, funding: channels }) } for (const page of pages) { const english = await render("en", page) if (!english) { problems.push({ kind: "missing-page", locale: "en", page, message: `docs/help/en/${page}.md is missing` }) continue } rendered.set(`en/${page}`, { ...english, fallback: false }) for (const locale of site.locales.slice(1)) { const translated = await render(locale, page) if (!translated) { problems.push({ kind: "missing-translation", locale, page, message: `docs/help/${locale}/${page}.md is missing; English is shown` }) rendered.set(`${locale}/${page}`, { ...english, fallback: true }) continue } const ids = (result) => result.headings.map((heading) => `${heading.level}:${heading.id}`).join(" ") if (ids(translated) !== ids(english)) { problems.push({ kind: "heading-mismatch", locale, page, message: `heading ids differ from English\n en: ${ids(english)}\n ${locale}: ${ids(translated)}` }) } const names = (result) => [...result.directives].sort().join(" ") if (names(translated) !== names(english)) { problems.push({ kind: "directive-mismatch", locale, page, message: `directives differ from English\n en: ${names(english)}\n ${locale}: ${names(translated)}` }) } rendered.set(`${locale}/${page}`, { ...translated, fallback: false }) } } const available = new Map(pages.filter((page) => rendered.has(`en/${page}`)).map((page) => [page, rendered.get(`en/${page}`).title])) // The header Sponsor link needs both a channel and the page that explains it. const sponsor = channels.length > 0 && available.has("sponsor") for (const locale of site.locales) { for (const page of available.keys()) { const result = rendered.get(`${locale}/${page}`) if (result.fallback) continue for (const link of result.links) { const target = link.page ?? page const label = `${link.page ? `${link.page}.md` : ""}${link.fragment ? `#${link.fragment}` : ""}` if (!available.has(target)) problems.push({ kind: "broken-link", locale, page, message: `unknown page: ${label}` }) else if (link.fragment && !rendered.get(`${locale}/${target}`).headings.some((heading) => heading.id === link.fragment)) { problems.push({ kind: "broken-anchor", locale, page, message: `unknown anchor: ${label}` }) } } } } // Only delete files listed by our prior build, never the supplied output directory. const ownership = resolve(output, ".help-output.json") if (isFile(ownership)) { for (const name of await readJson(ownership)) { if (!/^(?:[a-zA-Z-]+\/[a-z0-9-]+\.html|assets\/(?:media\/[^/\\]+|help\.css|help-(?:search|download)\.js|logo\.png)|[a-zA-Z-]+\/search-index\.json|index\.html|\.nojekyll)$/.test(name)) throw new Error("invalid help output ownership record") const target = resolve(output, name) if (isFile(target) && relative(realpathSync(output), realpathSync(target)).startsWith("..")) throw new Error("help output symlink escape") await rm(target, { force: true }) } } await mkdir(resolve(output, "assets/media"), { recursive: true }) await Promise.all([ copyFile(resolve(chrome, "help.css"), resolve(output, "assets/help.css")), copyFile(resolve(chrome, "help-search.js"), resolve(output, "assets/help-search.js")), copyFile(resolve(chrome, "help-download.js"), resolve(output, "assets/help-download.js")), copyFile(resolve(root, "images/CodeNomad-Icon.png"), resolve(output, "assets/logo.png")), ...[...media].map(([path, name]) => copyFile(path, resolve(output, "assets/media", name))), writeFile(resolve(output, ".nojekyll"), ""), writeFile(resolve(output, "index.html"), chooserDocument(site, strings)), ]) for (const locale of site.locales) { await mkdir(resolve(output, locale), { recursive: true }) const index = [...available.keys()].flatMap((page) => { const result = rendered.get(`${locale}/${page}`) return result.headings.map((heading) => { // Hidden per-platform download blocks would repeat every label in the excerpt. const section = result.html.split(`id="${heading.id}"`)[1]?.split(/