117 lines
4.2 KiB
JavaScript
117 lines
4.2 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* @module scripts/push-docs
|
||
|
|
* @description Push this repo's PUBLIC docs to the soulcraft.com docs ingest
|
||
|
|
* door after an npm publish (VENUE-DOCS-RELEASE-PUSH — retires the old
|
||
|
|
* build-time docs sync).
|
||
|
|
*
|
||
|
|
* Contract (mirrors the reference implementation on the serving side):
|
||
|
|
* POST {base}/api/docs/ingest
|
||
|
|
* headers: x-service-secret: $DOCS_INGEST_SECRET, Content-Type: application/json
|
||
|
|
* body: { docs: [{ slug, title, markdown, nav: { order, section } }] }
|
||
|
|
* batches of 10, idempotent per slug.
|
||
|
|
*
|
||
|
|
* A doc is public iff its frontmatter has `public: true` AND a `slug`. The
|
||
|
|
* frontmatter is stripped; `category` → nav.section, `order` → nav.order.
|
||
|
|
*
|
||
|
|
* Deliberately NOT pushed: the combined /docs landing index. It spans BOTH
|
||
|
|
* engine corpora (this repo's and the native accelerator's), so a per-repo
|
||
|
|
* push would clobber the union — the index is authored on the serving side.
|
||
|
|
*
|
||
|
|
* Env: DOCS_INGEST_SECRET (required), DOCS_INGEST_BASE (default
|
||
|
|
* https://soulcraft.com). Exits 0 with a LOUD warning when the secret is
|
||
|
|
* absent (the npm publish has already happened; the serving side runs its
|
||
|
|
* interim sync on request) and exits 1 when a push actually fails — the docs
|
||
|
|
* site would silently trail npm otherwise, and that must be visible.
|
||
|
|
*/
|
||
|
|
import * as fs from 'node:fs'
|
||
|
|
import * as path from 'node:path'
|
||
|
|
|
||
|
|
const BASE = (process.env.DOCS_INGEST_BASE || 'https://soulcraft.com').replace(/\/+$/, '')
|
||
|
|
const SECRET = process.env.DOCS_INGEST_SECRET
|
||
|
|
const DOCS_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'docs')
|
||
|
|
const BATCH = 10
|
||
|
|
|
||
|
|
if (!SECRET) {
|
||
|
|
console.warn(
|
||
|
|
'⚠️ DOCS PUSH SKIPPED: DOCS_INGEST_SECRET is not set.\n' +
|
||
|
|
' soulcraft.com/docs now TRAILS this npm release until docs are pushed.\n' +
|
||
|
|
' Either export DOCS_INGEST_SECRET and re-run `node scripts/push-docs.js`,\n' +
|
||
|
|
' or ping venue on VENUE-DOCS-RELEASE-PUSH for the interim sync.'
|
||
|
|
)
|
||
|
|
process.exit(0)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Minimal frontmatter split — returns [meta, body] or [null, raw]. */
|
||
|
|
function parseFrontmatter(raw) {
|
||
|
|
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||
|
|
if (!m) return [null, raw]
|
||
|
|
const meta = {}
|
||
|
|
for (const line of m[1].split('\n')) {
|
||
|
|
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/)
|
||
|
|
if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '')
|
||
|
|
}
|
||
|
|
return [meta, m[2]]
|
||
|
|
}
|
||
|
|
|
||
|
|
const docs = []
|
||
|
|
;(function walk(dir) {
|
||
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
|
|
const full = path.join(dir, entry.name)
|
||
|
|
if (entry.isDirectory()) walk(full)
|
||
|
|
else if (entry.name.endsWith('.md')) {
|
||
|
|
const [meta, body] = parseFrontmatter(fs.readFileSync(full, 'utf-8'))
|
||
|
|
if (!meta || meta.public !== 'true' || !meta.slug) continue
|
||
|
|
docs.push({
|
||
|
|
slug: meta.slug,
|
||
|
|
title: meta.title || meta.slug,
|
||
|
|
markdown: body.trim(),
|
||
|
|
nav: {
|
||
|
|
order: Number.parseInt(meta.order || '99', 10) || 99,
|
||
|
|
section: meta.category || 'guides'
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})(DOCS_DIR)
|
||
|
|
|
||
|
|
if (docs.length === 0) {
|
||
|
|
console.error('❌ DOCS PUSH FAILED: zero public docs collected — refusing to push an empty corpus.')
|
||
|
|
process.exit(1)
|
||
|
|
}
|
||
|
|
docs.sort((a, b) => a.slug.localeCompare(b.slug))
|
||
|
|
console.log(`Pushing ${docs.length} public docs to ${BASE}/api/docs/ingest …`)
|
||
|
|
|
||
|
|
let failed = false
|
||
|
|
for (let i = 0; i < docs.length; i += BATCH) {
|
||
|
|
const batch = docs.slice(i, i + BATCH)
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${BASE}/api/docs/ingest`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'x-service-secret': SECRET,
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'User-Agent': 'brainy-docs-push/1.0'
|
||
|
|
},
|
||
|
|
body: JSON.stringify({ docs: batch }),
|
||
|
|
signal: AbortSignal.timeout(120_000)
|
||
|
|
})
|
||
|
|
if (!res.ok) {
|
||
|
|
throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||
|
|
}
|
||
|
|
console.log(` batch ${i / BATCH + 1}: ${batch.map((d) => d.slug).join(', ')} → ok`)
|
||
|
|
} catch (err) {
|
||
|
|
failed = true
|
||
|
|
console.error(` batch ${i / BATCH + 1} FAILED: ${err instanceof Error ? err.message : err}`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (failed) {
|
||
|
|
console.error(
|
||
|
|
'❌ DOCS PUSH INCOMPLETE — soulcraft.com/docs may trail npm. ' +
|
||
|
|
'Re-run `node scripts/push-docs.js` or ping venue on VENUE-DOCS-RELEASE-PUSH.'
|
||
|
|
)
|
||
|
|
process.exit(1)
|
||
|
|
}
|
||
|
|
console.log('✅ Docs pushed.')
|