${escape(t.untranslated)}
` : ""}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 ` ${text}${html}
\n`
}
if (!id) {
report("missing-id", `heading without an explicit {#id}: ${raw}`)
return ``
},
} })
// 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) => `${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 ``
}
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 `${items}
`
}).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 ` : ""}
${escape(t.skipToContent)}
CodeNomad ${escape(t.brand)}
${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(/[\s\S]*?<\/div><\/div>/, "") ?? ""
return { title: result.title, heading: heading.text, excerpt: stripTags(section.slice(section.indexOf(" [page, rendered.get(`${locale}/${page}`).title]))
for (const page of available.keys()) {
const result = rendered.get(`${locale}/${page}`)
const html = pageDocument({ site, strings, locale, page, rendered: result, fallback: result.fallback, available: labels, sponsor })
await writeFile(resolve(output, locale, `${page}.html`), html)
}
}
await writeFile(ownership, JSON.stringify(["index.html", ".nojekyll", "assets/help.css", "assets/help-search.js", "assets/help-download.js", "assets/logo.png", ...[...media.values()].map((name) => `assets/media/${name}`), ...site.locales.flatMap((locale) => [`${locale}/search-index.json`, ...[...available.keys()].map((page) => `${locale}/${page}.html`)])]))
return { site, problems, pages: [...available.keys()] }
}
function option(args, name) {
const index = args.indexOf(name)
if (index === -1) return undefined
const [value] = args.splice(index, 2).slice(1)
if (!value) throw new Error(`${name} needs a value`)
return value
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const args = process.argv.slice(2)
const content = option(args, "--content")
const releaseFile = option(args, "--release")
const output = resolve(args[0] ?? resolve(root, "dist/help"))
const release = releaseFile ? await readJson(resolve(releaseFile)) : undefined
const { site, problems, pages } = await buildHelp({ content: content && resolve(content), output, release })
for (const problem of problems) {
const level = TRANSLATION_PROBLEMS.has(problem.kind) ? "warning" : "error"
console.log(`${level}: ${problem.locale}/${problem.page}: ${problem.message}`)
}
console.log(`Built ${pages.length} help pages in ${site.locales.length} languages in ${output}`)
if (problems.some((problem) => !TRANSLATION_PROBLEMS.has(problem.kind))) process.exitCode = 1
}