diff --git a/.gitignore b/.gitignore index eb5ec1d..d908265 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ dist/ .astro/ .tmp/ +.generated/ +.playwright-cli/ +src/generated/scient-docs/ .wrangler/ .DS_Store *.log diff --git a/AGENTS.md b/AGENTS.md index 64945b6..0105b79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,9 @@ must render an approved exact source/version instead of maintaining a second copy. Website Docs work owns rendering, navigation, search, accessibility, deployment, and truthful version/source selection. A change to app behavior or source prose uses a separate desktop pull request with explicit landing order. -Do not invent the final source transport before the publishing pilot selects -and proves it. +Use the exact-source manifest transport recorded in +`docs/architecture/scient-docs-publishing.md`; replace it only through a +reviewed architecture change with concrete evidence. Every pull request includes one `Documentation impact` declaration: `None — reason`, `Updated — paths`, or `Dependent PR — repository and link`. For Docs diff --git a/README.md b/README.md index 18e9cb1..3ae260d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ Do not deploy production from a feature branch or store Cloudflare credentials i See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow. The cross-repository operating model is maintained in [`ScientFactory/Scient`](https://github.com/ScientFactory/Scient). +## Scient Docs publishing + +The public `/docs` experience is generated from reviewed Markdown in +`scient-desktop/docs/user/` at an exact commit. The source manifest, hashes, +preview/stable qualification, correction path, and rollback contract live in +[`docs/scient-docs-manifest.json`](docs/scient-docs-manifest.json) and +[`docs/architecture/scient-docs-publishing.md`](docs/architecture/scient-docs-publishing.md). +Generated website files are intentionally ignored; `bun run docs:sync` rebuilds +them and fails closed if source content no longer matches the reviewed hashes. + ## First-party event measurement Cloudflare D1 stores four website event types: diff --git a/docs/architecture/scient-docs-publishing.md b/docs/architecture/scient-docs-publishing.md new file mode 100644 index 0000000..dcbf6c0 --- /dev/null +++ b/docs/architecture/scient-docs-publishing.md @@ -0,0 +1,91 @@ +# Scient Docs Publishing Architecture + +Status: Active +Owner: Yaacov +Created: 2026-08-28 +Last updated: 2026-08-28 +Purpose: Defines the website-owned transport, provenance, failure, correction, and rollback contract for publishing canonical Scient Desktop Help. +Doc type: Architecture decision + +## Authority + +`scient-desktop/docs/user/` owns Help prose. The website owns selection, +transport, rendering, navigation, search, accessibility, source/version +display, and deployment. `docs/scient-docs-manifest.json` is the reviewed +publication selection; it is not a second prose source. `scient-agent` is +outside this publishing pass. + +## Selected Transport + +The website build reads a committed manifest that pins: + +- a full immutable `ScientFactory/scient-desktop` commit; +- every selected `docs/user/` source path; +- a SHA-256 digest for each source page; +- stable or preview channel and truthful applicability wording; and +- corpus defaults plus page-level topic, title, summary, navigation priority, + and surface metadata. + +CI and ordinary builds fetch each page from GitHub's immutable raw-commit URL. +Local development may set `SCIENT_DOCS_SOURCE_ROOT` to an exact checkout at the +same commit; the generator rejects a different head, and page hashes still +apply. Generated files are ignored build products. The website never commits a +hand-maintained prose copy. + +This transport was selected in the pilot and retained for the complete +desktop-first corpus because it remains small, independently deployable, exact, +auditable, and reversible without introducing a package registry or release +artifact before one is justified. Re-evaluate it if corpus size, availability, +rate limits, private sources, or release engineering make raw-commit transport +materially unreliable. + +## Build And Safety Contract + +`scripts/sync-scient-docs.mjs` validates the manifest, fetches exact content, +checks every digest and H1, rejects executable/unsafe raw Markdown patterns, +rewrites selected relative Help links to stable public routes, and sends +unselected relative Help links to the exact GitHub source. It generates: + +- Astro Markdown pages under stable `/docs//` routes; +- exact raw Markdown under `/docs/raw/.md`; +- `/docs/index.json` with title, summary, topic, channel, applicability, page + URL, raw URL, source path/revision/URL, and search text; and +- the metadata used by client-side topic/text search, navigation, and the + compact source footer. + +Missing content, a changed hash, unsafe content, a mutable/non-full revision, +duplicate routing, an unqualified stable manifest, or a wrong local checkout +fails the build. There is no stale cache fallback that could silently publish +the wrong content. The previously deployed website remains live while CI or a +preview exposes the failure. + +## Preview, Stable, And Corrections + +A preview manifest may pin an exact documentation or application PR head. The +website labels every page as preview, shows what it applies to, and links to the +exact source. It must not be presented as stable released behavior. + +A stable manifest must name at least one reviewed desktop release tag and pin +the exact Help revision that truthfully describes it. A release triggers a +reviewed website manifest PR; publication should complete within 24 hours. A +Help-only correction uses a desktop documentation PR followed by a dependent +website manifest PR and deployment, without requiring a new app binary. Its +applicability text continues to name the releases the corrected prose +describes. + +Rollback reverts the manifest pin or website publishing change. It never +rewrites canonical desktop Help. A reverted or failed deployment leaves the +last successful public corpus in place and visible through ordinary website +deployment history. + +## Current corpus and deferred scope + +The preview manifest selects all 31 desktop-first Help pages qualified at the +exact desktop source revision. It deliberately excludes the retained mobile +page because Scient has no public mobile release. Directory membership still +does not imply publication, and future Help owners require factual +qualification before entering the manifest. + +Stable release publication, a documentation MCP, and any larger metadata +schema remain separate decisions. Evaluate MCP only after stable HTML, raw +Markdown, the index, and search reveal a concrete agent-retrieval gap. diff --git a/docs/scient-docs-manifest.json b/docs/scient-docs-manifest.json new file mode 100644 index 0000000..67453e5 --- /dev/null +++ b/docs/scient-docs-manifest.json @@ -0,0 +1,289 @@ +{ + "schemaVersion": 1, + "channel": "preview", + "source": { + "repository": "ScientFactory/scient-desktop", + "revision": "7d2187eb383505956e95c8ebcfcfe8006d6edba2", + "pullRequest": "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/ScientFactory/scient-desktop/pull/199", + "reviewedAt": "2026-08-29" + }, + "version": { + "label": "Scient Docs preview", + "appliesTo": "Unreleased Scient Desktop documentation; not a stable release", + "stableReleaseTags": [] + }, + "corpusDefaults": { + "product": "Scient Desktop", + "surface": "desktop", + "publicationStatus": "preview-only", + "audience": "users and support agents" + }, + "publication": { + "releaseTrigger": "A reviewed desktop release plus a website manifest PR selecting its exact Help source revision", + "correctionTrigger": "A merged desktop Help correction plus a dependent website manifest PR; no desktop binary release is required", + "maximumLagHours": 24, + "failureMode": "The build fails closed and the previously deployed Docs remain live; CI and preview checks expose the failed source pin", + "rollback": "Revert the website manifest pin or the website publishing change; canonical desktop Help is never rewritten by rollback" + }, + "pages": [ + { + "slug": "getting-started", + "title": "Getting started with Scient", + "summary": "Connect your AI, open a project, and start working with Scient.", + "topic": "Essentials", + "sourcePath": "docs/user/getting-started.md", + "sha256": "bd6c5a63468f00b73935275d882016bf2522eb1c86c8003d2c530476acaa75d1" + }, + { + "slug": "projects", + "title": "Work inside a project", + "summary": "Understand why Scient works inside projects, then open or create one.", + "topic": "Essentials", + "sourcePath": "docs/user/projects.md", + "sha256": "de0e00d1c395a26b9432abcffc5629f5d5d8ce60bfd190de3be6fb11b9043974" + }, + { + "slug": "composer", + "title": "Message Scient", + "summary": "Message Scient, attach files, use skills, continue with another provider, queue work, and recover.", + "topic": "Essentials", + "sourcePath": "docs/user/composer.md", + "sha256": "d58e901fc418bc25bbb36dce49fa9eb0dfa3405688c3f783fbaf4eb339dff9c6" + }, + { + "slug": "providers", + "title": "Providers in Scient", + "summary": "Understand provider readiness, accounts, installations, remote authority, and recovery.", + "topic": "Essentials", + "sourcePath": "docs/user/providers.md", + "sha256": "3b08cf154d847eb20a744aaa18ad35bcb8501376133ec0b0a2dd50f4b806af0e" + }, + { + "slug": "file-previews", + "title": "File previews", + "summary": "Open project and environment files, refresh previews, and understand current file limits.", + "topic": "Essentials", + "sourcePath": "docs/user/file-previews.md", + "sha256": "988a9062b52b72e6c0796ba3ed828f20582c83d73013f4b3f8e82563976a80ac" + }, + { + "slug": "thread-sidebar", + "title": "Organizing threads", + "summary": "Group, sort, move, settle, archive, and restore conversations in the project sidebar.", + "topic": "Work in Scient", + "sourcePath": "docs/user/thread-sidebar.md", + "sha256": "ffeb799759d7b8f3b6c8c22ce68bb9f64ffc8571b318d1f4646f48cc17075e5e" + }, + { + "slug": "permission-modes", + "title": "Permission modes", + "summary": "Choose provider-specific approval behavior and understand what each mode can authorize.", + "topic": "Work in Scient", + "sourcePath": "docs/user/permission-modes.md", + "sha256": "597cda2775ce04dfafe4b54e0c90dacf7c9ae9d3f7b2aa0ebb104861629cd48b" + }, + { + "slug": "project-settings", + "title": "Project settings", + "summary": "Manage project skills, project icons, and automatic browser previews.", + "topic": "Work in Scient", + "sourcePath": "docs/user/project-settings.md", + "sha256": "a1c74e2391fa828b0868c9d3d6a8afd634149c8a19f116f2850ddfd5401439e4" + }, + { + "slug": "voice-dictation", + "title": "Voice dictation", + "summary": "Transcribe locally, choose a Whisper model, and optionally correct transcript text with a provider.", + "topic": "Work in Scient", + "sourcePath": "docs/user/voice-dictation.md", + "sha256": "4f1dcb185a6111b39ad2a383a8dc6fef7e1533c660cdceb0e6a0033194a8747d" + }, + { + "slug": "keybindings", + "title": "Keybindings", + "summary": "Use current shortcuts, remap commands, and resolve conflicts.", + "topic": "Work in Scient", + "sourcePath": "docs/user/keybindings.md", + "sha256": "24b81088c63d84067c5734e06785cefaf2ebd24da45ecd0c435d8e5c385d614b", + "secondary": true + }, + { + "slug": "usage", + "title": "Review usage", + "summary": "Review local provider transcript usage, token totals, cost provenance, and source diagnostics.", + "topic": "Work in Scient", + "sourcePath": "docs/user/usage.md", + "sha256": "37d5694525ee93ac3f47a2c8acb26ad818d90abcf4a3626dbdc9c1c64b4cfd92", + "secondary": true + }, + { + "slug": "sources", + "title": "Sources and PDF import", + "summary": "Manage project-owned scientific references, Zotero intake, PDF import, citations, and agent access.", + "topic": "Research & files", + "sourcePath": "docs/user/sources.md", + "sha256": "e3282a8503ba27698da2244cb540c4342a2d196f87cde7dbf58bba2e960dd2d8" + }, + { + "slug": "pdf-reader", + "title": "PDF reader", + "summary": "Read, search, navigate, and save copies of PDFs while preserving reading state.", + "topic": "Research & files", + "sourcePath": "docs/user/pdf-reader.md", + "sha256": "b26c52ec755baf3af69f9d64f91d2e4113b7e3197b111c2bf349c906e41304ce" + }, + { + "slug": "latex", + "title": "LaTeX", + "summary": "Build LaTeX projects, manage toolchains, inspect diagnostics, and navigate with SyncTeX.", + "topic": "Research & files", + "sourcePath": "docs/user/latex.md", + "sha256": "4770862ffcd4f21a669109a31a44f972b803732b5cf1e65e9bba042924d0cd47", + "secondary": true + }, + { + "slug": "matlab-run-file", + "title": "Run a MATLAB file", + "summary": "Verify an installed MATLAB runtime, run a saved file, inspect history, and promote results.", + "topic": "Research & files", + "sourcePath": "docs/user/matlab-run-file.md", + "sha256": "8b47b750655c769816c80bcca3efbb21bad88ab126bfaa5988898b6921ad806b", + "secondary": true + }, + { + "slug": "source-control", + "title": "Source control integrations", + "summary": "Connect supported Git forges, authenticate in the execution environment, and manage pull requests.", + "topic": "Research & files", + "sourcePath": "docs/user/source-control.md", + "sha256": "a924db9797014c145999c5573529f0c211d738cd9317473b786d8eb95a2c707f", + "secondary": true + }, + { + "slug": "math-in-chat", + "title": "Math in chat", + "summary": "Write and inspect inline or display math with safe rendering, copy, and fallback behavior.", + "topic": "Rich responses", + "sourcePath": "docs/user/math-in-chat.md", + "sha256": "de0962f379c9b0860e722e143fe83907e0975020bbd9e0f439002552be9a1993" + }, + { + "slug": "images-in-chat", + "title": "Images in chat", + "summary": "Display workspace-relative and remote images without crossing workspace authority boundaries.", + "topic": "Rich responses", + "sourcePath": "docs/user/images-in-chat.md", + "sha256": "36c1b3610f372477695d77f1e41059d892896bfbceab9faceab6276048fb47ce" + }, + { + "slug": "charts-in-chat", + "title": "Interactive charts in chat", + "summary": "Use Vega-Lite and Plotly charts, preserve source fidelity, and export current views.", + "topic": "Rich responses", + "sourcePath": "docs/user/charts-in-chat.md", + "sha256": "e708e4ead43ef4bc54f7132f9098ab745799243800a9640545dce17c1bf01100" + }, + { + "slug": "content-direction", + "title": "Conversation text direction", + "summary": "Control automatic, RTL, or LTR conversation layout while preserving technical content.", + "topic": "Rich responses", + "sourcePath": "docs/user/content-direction.md", + "sha256": "5c6f065aac02b4be8ab8d7ad1c0c4d2cdc0dd114d970cc16648ec2f3dbaa16e7", + "secondary": true + }, + { + "slug": "diagrams-in-chat", + "title": "Diagrams in chat", + "summary": "Render, inspect, copy, and export secure Mermaid diagrams from settled messages.", + "topic": "Rich responses", + "sourcePath": "docs/user/diagrams-in-chat.md", + "sha256": "22fe76dab774e9eca947f32e30f156c85d2f64ce7cf29dd4008866ae864629ed", + "secondary": true + }, + { + "slug": "providers-codex", + "title": "Codex in Scient", + "summary": "Connect Codex, choose a runtime, and manage one or more compatible account homes.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-codex.md", + "sha256": "8c4ee79147bc3a1245e4b636b344d467a965379f8802f53efdf7f87583554f8c" + }, + { + "slug": "providers-claude", + "title": "Claude in Scient", + "summary": "Connect Claude Code and keep account, config-directory, runtime, and advanced routes distinct.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-claude.md", + "sha256": "61d9c309ba0a37ff0a5ee87828032966c0e833a944c7f1d42f287533db12fd3e" + }, + { + "slug": "providers-antigravity", + "title": "Antigravity in Scient", + "summary": "Use Google's account flow, managed runtime, dynamic models, and native session behavior.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-antigravity.md", + "sha256": "687519a02c58d8ee658a6260bb189fab51b0a7e9880cc429248de7f1cd662c8a", + "secondary": true + }, + { + "slug": "providers-grok", + "title": "Grok in Scient", + "summary": "Enable the Early Access Grok provider and distinguish subscription, API-key, and runtime state.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-grok.md", + "sha256": "fb7e37fd981985d27951ed731d25b41c8834bc71cf904ee4eed1d474db73428d", + "secondary": true + }, + { + "slug": "providers-droid", + "title": "Droid (Factory) in Scient", + "summary": "Connect Factory's Droid and understand ACP models, autonomy, steering, and limitations.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-droid.md", + "sha256": "31bf8c57efe58b1ff13dc91a4487741adb4ba44b2beb4cdcdb5e1565785b94a5", + "secondary": true + }, + { + "slug": "providers-cursor", + "title": "Cursor in Scient", + "summary": "Connect Cursor Agent and keep browser authentication, runtime ownership, and recovery clear.", + "topic": "Provider guides", + "sourcePath": "docs/user/providers-cursor.md", + "sha256": "68980e1a29429469b2e8e3de9485739fbe01de3f6aec54280ca359ed640a02d7", + "secondary": true + }, + { + "slug": "install", + "title": "Install Scient", + "summary": "Download the correct public installer and complete the first launch safely.", + "topic": "Installation & environments", + "sourcePath": "docs/user/install.md", + "sha256": "8fd1db97a3fde09683b0bafb07694f384fb894983c3686fc3abadad8d696d944" + }, + { + "slug": "updating", + "title": "Update Scient", + "summary": "Update the desktop app, keep connected servers aligned, and understand rollback outcomes.", + "topic": "Installation & environments", + "sourcePath": "docs/user/updating.md", + "sha256": "ae8e7c96e21e080429832c3e0f950017b8a89352375812089a715bdbd9a64c40" + }, + { + "slug": "remote-access", + "title": "Remote environments", + "summary": "Connect over desktop-managed SSH or an advanced direct/private-network path without crossing environment authority.", + "topic": "Installation & environments", + "sourcePath": "docs/user/remote-access.md", + "sha256": "52cf889cccbf10c636ecc1e1cdda93b58249be70b16b8d897f0585a19d2dcd3e" + }, + { + "slug": "background-service", + "title": "Run a Scient server in the background", + "summary": "Install, inspect, update, or remove a user-scoped Scient server service on Linux or macOS.", + "topic": "Installation & environments", + "sourcePath": "docs/user/background-service.md", + "sha256": "a5eae7a134d305b7d34551c4ec23d3bc7360a0dd52181b32e63682d08ad4fc60" + } + ] +} diff --git a/package.json b/package.json index 084ac00..20bcece 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,14 @@ "private": true, "type": "module", "scripts": { + "docs:sync": "node scripts/sync-scient-docs.mjs", + "predev": "bun run docs:sync", "dev": "astro dev", + "prebuild": "bun run docs:sync", "build": "astro build", "build:edge": "wrangler pages functions build --outdir .tmp/scientfactory-pages-worker", "preview": "astro preview", + "pretypecheck": "bun run docs:sync", "typecheck": "astro check && tsc --noEmit --project functions/tsconfig.json", "test": "vitest run", "db:migrate": "wrangler d1 migrations apply scientfactory-downloads --remote", diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..fc2d773 --- /dev/null +++ b/public/_headers @@ -0,0 +1,7 @@ +/docs/raw/* + Content-Type: text/markdown; charset=utf-8 + X-Content-Type-Options: nosniff + +/docs/index.json + Content-Type: application/json; charset=utf-8 + X-Content-Type-Options: nosniff diff --git a/scripts/sync-scient-docs.mjs b/scripts/sync-scient-docs.mjs new file mode 100644 index 0000000..4bdb9d3 --- /dev/null +++ b/scripts/sync-scient-docs.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, posix, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const defaultManifestPath = resolve(repositoryRoot, "docs/scient-docs-manifest.json"); +const defaultOutputRoot = resolve(repositoryRoot, "src/generated/scient-docs"); +const dangerousMarkdown = /<(script|iframe|object|embed|style)\b|\bon[a-z]+\s*=|javascript\s*:/i; + +function requiredString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +export function validateManifest(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Scient Docs manifest must be an object"); + } + if (value.schemaVersion !== 1) throw new Error("Unsupported Scient Docs manifest schema"); + if (value.channel !== "preview" && value.channel !== "stable") { + throw new Error("Scient Docs channel must be preview or stable"); + } + if ( + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test( + requiredString(value.source?.repository, "source.repository"), + ) + ) { + throw new Error("source.repository must be a GitHub owner/repository pair"); + } + if (!/^[0-9a-f]{40}$/.test(requiredString(value.source?.revision, "source.revision"))) { + throw new Error("source.revision must be a full 40-character Git SHA"); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(requiredString(value.source?.reviewedAt, "source.reviewedAt"))) { + throw new Error("source.reviewedAt must be an ISO date"); + } + requiredString(value.version?.label, "version.label"); + requiredString(value.version?.appliesTo, "version.appliesTo"); + if (!Array.isArray(value.version?.stableReleaseTags)) { + throw new Error("version.stableReleaseTags must be an array"); + } + if (value.channel === "stable" && value.version.stableReleaseTags.length === 0) { + throw new Error("Stable Scient Docs require at least one stable release tag"); + } + for (const [index, tag] of value.version.stableReleaseTags.entries()) { + requiredString(tag, `version.stableReleaseTags[${index}]`); + } + if ( + !Number.isInteger(value.publication?.maximumLagHours) || + value.publication.maximumLagHours < 1 + ) { + throw new Error("publication.maximumLagHours must be a positive integer"); + } + for (const key of ["releaseTrigger", "correctionTrigger", "failureMode", "rollback"]) { + requiredString(value.publication?.[key], `publication.${key}`); + } + if (!Array.isArray(value.pages) || value.pages.length === 0) { + throw new Error("Scient Docs manifest must select at least one page"); + } + + const slugs = new Set(); + const sourcePaths = new Set(); + for (const [index, page] of value.pages.entries()) { + const prefix = `pages[${index}]`; + const slug = requiredString(page.slug, `${prefix}.slug`); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { + throw new Error(`${prefix}.slug must be a lowercase URL slug`); + } + if (slugs.has(slug)) throw new Error(`Duplicate Scient Docs slug: ${slug}`); + slugs.add(slug); + const sourcePath = requiredString(page.sourcePath, `${prefix}.sourcePath`); + if ( + !sourcePath.startsWith("docs/user/") || + !sourcePath.endsWith(".md") || + sourcePath.includes("\\") || + posix.normalize(sourcePath) !== sourcePath + ) { + throw new Error(`${prefix}.sourcePath must be a docs/user Markdown path`); + } + if (sourcePaths.has(sourcePath)) throw new Error(`Duplicate Help source path: ${sourcePath}`); + sourcePaths.add(sourcePath); + requiredString(page.title, `${prefix}.title`); + requiredString(page.summary, `${prefix}.summary`); + requiredString(page.topic, `${prefix}.topic`); + if (page.secondary !== undefined && typeof page.secondary !== "boolean") { + throw new Error(`${prefix}.secondary must be a boolean when provided`); + } + if (!/^[0-9a-f]{64}$/.test(requiredString(page.sha256, `${prefix}.sha256`))) { + throw new Error(`${prefix}.sha256 must be a SHA-256 digest`); + } + } + return value; +} + +export function hashMarkdown(markdown) { + return createHash("sha256").update(markdown, "utf8").digest("hex"); +} + +function sourceWebUrl(manifest, sourcePath) { + return `https://github.com/${manifest.source.repository}/blob/${manifest.source.revision}/${sourcePath}`; +} + +export function rewriteHelpLinks(markdown, page, manifest) { + const selectedByPath = new Map( + manifest.pages.map((candidate) => [candidate.sourcePath, candidate]), + ); + return markdown.replace(/(\]\()([^)]+)(\))/g, (whole, open, rawTarget, close) => { + const target = rawTarget.trim(); + if (target.startsWith("#") || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target)) { + return whole; + } + const hashIndex = target.indexOf("#"); + const targetPath = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + if (!targetPath.endsWith(".md")) return whole; + const normalized = posix.normalize(posix.join(posix.dirname(page.sourcePath), targetPath)); + const selected = selectedByPath.get(normalized); + const rewritten = selected + ? `/docs/${selected.slug}/${fragment}` + : `${sourceWebUrl(manifest, normalized)}${fragment}`; + return `${open}${rewritten}${close}`; + }); +} + +function searchableText(markdown) { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/[#>*_|~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function frontmatter(page, manifest) { + const values = { + title: page.title, + summary: page.summary, + topic: page.topic, + slug: page.slug, + sourcePath: page.sourcePath, + sourceRevision: manifest.source.revision, + channel: manifest.channel, + versionLabel: manifest.version.label, + appliesTo: manifest.version.appliesTo, + }; + return [ + "---", + ...Object.entries(values).map(([key, value]) => `${key}: ${JSON.stringify(value)}`), + "---", + "", + ].join("\n"); +} + +export async function generateDocs({ manifest, loadSource, outputRoot = defaultOutputRoot }) { + validateManifest(manifest); + const rawBySlug = {}; + const pages = []; + + for (const page of manifest.pages) { + const markdown = await loadSource(page.sourcePath); + if (typeof markdown !== "string") + throw new Error(`Source loader returned no text for ${page.sourcePath}`); + const actualHash = hashMarkdown(markdown); + if (actualHash !== page.sha256) { + throw new Error( + `${page.sourcePath} SHA-256 mismatch: expected ${page.sha256}, received ${actualHash}`, + ); + } + if (dangerousMarkdown.test(markdown)) { + throw new Error(`${page.sourcePath} contains executable or unsafe raw Markdown content`); + } + if (!/^#\s+\S.+$/m.test(markdown)) { + throw new Error(`${page.sourcePath} must contain an H1 heading`); + } + + rawBySlug[page.slug] = markdown; + pages.push({ + ...page, + channel: manifest.channel, + versionLabel: manifest.version.label, + appliesTo: manifest.version.appliesTo, + sourceRepository: manifest.source.repository, + sourceRevision: manifest.source.revision, + sourceUrl: sourceWebUrl(manifest, page.sourcePath), + rawUrl: `/docs/raw/${page.slug}.md`, + url: `/docs/${page.slug}/`, + searchText: searchableText(markdown), + }); + } + + rmSync(outputRoot, { recursive: true, force: true }); + const pagesRoot = resolve(outputRoot, "pages"); + mkdirSync(pagesRoot, { recursive: true }); + for (const page of manifest.pages) { + const rendered = rewriteHelpLinks(rawBySlug[page.slug], page, manifest); + writeFileSync( + resolve(pagesRoot, `${page.slug}.md`), + `${frontmatter(page, manifest)}${rendered}`, + "utf8", + ); + } + + const index = { + schemaVersion: 1, + channel: manifest.channel, + source: manifest.source, + version: manifest.version, + corpusDefaults: manifest.corpusDefaults, + publication: manifest.publication, + pages, + }; + writeFileSync(resolve(outputRoot, "index.json"), `${JSON.stringify(index, null, 2)}\n`, "utf8"); + writeFileSync(resolve(outputRoot, "raw.json"), `${JSON.stringify(rawBySlug, null, 2)}\n`, "utf8"); + return index; +} + +function localSourceLoader(sourceRoot, expectedRevision) { + const root = realpathSync(sourceRoot); + const actualRevision = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + if (actualRevision !== expectedRevision) { + throw new Error( + `SCIENT_DOCS_SOURCE_ROOT is at ${actualRevision}, expected ${expectedRevision}`, + ); + } + return async (sourcePath) => readFileSync(resolve(root, sourcePath), "utf8"); +} + +function remoteSourceLoader(manifest) { + return async (sourcePath) => { + const url = `https://raw.githubusercontent.com/${manifest.source.repository}/${manifest.source.revision}/${sourcePath}`; + const response = await fetch(url, { redirect: "error" }); + if (!response.ok) throw new Error(`Could not fetch ${sourcePath}: HTTP ${response.status}`); + return response.text(); + }; +} + +export async function syncFromManifest({ + manifestPath = defaultManifestPath, + outputRoot = defaultOutputRoot, + sourceRoot = process.env.SCIENT_DOCS_SOURCE_ROOT, +} = {}) { + const manifest = validateManifest(JSON.parse(readFileSync(manifestPath, "utf8"))); + const loadSource = sourceRoot + ? localSourceLoader(sourceRoot, manifest.source.revision) + : remoteSourceLoader(manifest); + const index = await generateDocs({ manifest, loadSource, outputRoot }); + return { outputRoot, pageCount: index.pages.length, revision: manifest.source.revision }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const result = await syncFromManifest(); + process.stdout.write( + `Synced ${result.pageCount} Scient Docs pages from ${result.revision} into ${result.outputRoot}\n`, + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/sync-scient-docs.test.mjs b/scripts/sync-scient-docs.test.mjs new file mode 100644 index 0000000..d0ac6a1 --- /dev/null +++ b/scripts/sync-scient-docs.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "vitest"; + +import { + generateDocs, + hashMarkdown, + rewriteHelpLinks, + validateManifest, +} from "./sync-scient-docs.mjs"; + +const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function fixtureManifest(markdown = "# Getting started\n\nSee [Providers](./providers.md).\n") { + return { + schemaVersion: 1, + channel: "preview", + source: { + repository: "ScientFactory/scient-desktop", + revision: "a".repeat(40), + reviewedAt: "2026-08-28", + }, + version: { + label: "Pilot preview", + appliesTo: "Unreleased candidate", + stableReleaseTags: [], + }, + corpusDefaults: { product: "Scient Desktop", surface: "desktop" }, + publication: { + releaseTrigger: "Reviewed release manifest update", + correctionTrigger: "Reviewed Help correction manifest update", + maximumLagHours: 24, + failureMode: "Fail closed", + rollback: "Revert the pin", + }, + pages: [ + { + slug: "getting-started", + title: "Getting started", + summary: "Start using Scient.", + topic: "Start", + sourcePath: "docs/user/getting-started.md", + sha256: hashMarkdown(markdown), + }, + { + slug: "providers", + title: "Providers", + summary: "Connect a provider.", + topic: "Providers", + secondary: true, + sourcePath: "docs/user/providers.md", + sha256: hashMarkdown("# Providers\n"), + }, + ], + }; +} + +describe("Scient Docs manifest", () => { + it("serves raw Markdown and the machine index with explicit UTF-8 content types", () => { + const headers = readFileSync(join(repositoryRoot, "public/_headers"), "utf8"); + assert.match(headers, /\/docs\/raw\/\*\s+Content-Type: text\/markdown; charset=utf-8/); + assert.match(headers, /\/docs\/index\.json\s+Content-Type: application\/json; charset=utf-8/); + }); + + it("requires immutable revisions, unique pages, and stable release qualification", () => { + const manifest = fixtureManifest(); + assert.equal(validateManifest(manifest), manifest); + assert.throws(() => validateManifest({ ...manifest, channel: "stable" }), /release tag/); + assert.throws( + () => validateManifest({ ...manifest, source: { ...manifest.source, revision: "main" } }), + /40-character Git SHA/, + ); + assert.throws( + () => validateManifest({ ...manifest, pages: [manifest.pages[0], manifest.pages[0]] }), + /Duplicate Scient Docs slug/, + ); + assert.throws( + () => + validateManifest({ + ...manifest, + pages: [{ ...manifest.pages[0], sourcePath: "docs/user/../../private.md" }], + }), + /docs\/user Markdown path/, + ); + assert.throws( + () => + validateManifest({ + ...manifest, + pages: [{ ...manifest.pages[0], secondary: "yes" }], + }), + /secondary must be a boolean/, + ); + }); + + it("rewrites selected Help links to public routes and unselected Help to exact source", () => { + const manifest = fixtureManifest(); + const page = manifest.pages[0]; + const markdown = [ + "[Providers](./providers.md#setup)", + "[Other](./other.md)", + "[External](https://example.com)", + ].join("\n"); + const rewritten = rewriteHelpLinks(markdown, page, manifest); + assert.match(rewritten, /\/docs\/providers\/#setup/); + assert.match(rewritten, /blob\/a{40}\/docs\/user\/other\.md/); + assert.match(rewritten, /https:\/\/example\.com/); + }); + + it("generates deterministic page, raw, and index artifacts and rejects unsafe or changed source", async () => { + const markdown = "# Getting started\n\nSee [Providers](./providers.md).\n"; + const manifest = fixtureManifest(markdown); + const outputRoot = mkdtempSync(join(tmpdir(), "scient-docs-test-")); + try { + const sources = new Map([ + ["docs/user/getting-started.md", markdown], + ["docs/user/providers.md", "# Providers\n"], + ]); + const result = await generateDocs({ + manifest, + loadSource: async (path) => sources.get(path), + outputRoot, + }); + assert.equal(result.pages.length, 2); + assert.equal(result.pages[1].secondary, true); + assert.match( + readFileSync(join(outputRoot, "pages/getting-started.md"), "utf8"), + /\/docs\/providers\//, + ); + assert.equal( + JSON.parse(readFileSync(join(outputRoot, "raw.json"), "utf8"))["getting-started"], + markdown, + ); + + await assert.rejects( + generateDocs({ + manifest, + loadSource: async (path) => + path === "docs/user/getting-started.md" ? `${markdown}changed` : sources.get(path), + outputRoot, + }), + /SHA-256 mismatch/, + ); + const unsafe = "# Getting started\n\n\n"; + const unsafeManifest = fixtureManifest(unsafe); + await assert.rejects( + generateDocs({ + manifest: unsafeManifest, + loadSource: async (path) => + path === "docs/user/getting-started.md" ? unsafe : sources.get(path), + outputRoot, + }), + /unsafe raw Markdown content/, + ); + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/components/ScientDocsNavigation.astro b/src/components/ScientDocsNavigation.astro new file mode 100644 index 0000000..154000d --- /dev/null +++ b/src/components/ScientDocsNavigation.astro @@ -0,0 +1,330 @@ +--- +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; + +import { getScientDocsNavigationGroups } from "../lib/scient-docs-navigation"; + +interface DocsPage { + slug: string; + title: string; + topic: string; + secondary?: boolean; + url: string; +} + +interface Props { + pages: DocsPage[]; + currentSlug?: string; + compact?: boolean; +} + +const { pages, currentSlug, compact = false } = Astro.props; +const navigationGroups = getScientDocsNavigationGroups(pages); +const [chevronWidth, chevronHeight, , , chevronPathData] = faChevronDown.icon; +const chevronPath = Array.isArray(chevronPathData) ? chevronPathData[0] : chevronPathData; +--- + + + + + + diff --git a/src/components/ScientDocsShell.astro b/src/components/ScientDocsShell.astro new file mode 100644 index 0000000..386f10b --- /dev/null +++ b/src/components/ScientDocsShell.astro @@ -0,0 +1,545 @@ +--- +import Layout from "../layouts/Layout.astro"; +import ScientDocsNavigation from "./ScientDocsNavigation.astro"; +import { faDownload } from "@fortawesome/free-solid-svg-icons"; + +interface DocsPage { + slug: string; + title: string; + summary: string; + topic: string; + secondary?: boolean; + url: string; + rawUrl: string; + sourceUrl: string; +} + +interface Props { + index: { + channel: string; + source: { repository: string; revision: string; reviewedAt: string }; + version: { label: string; appliesTo: string; stableReleaseTags: string[] }; + pages: DocsPage[]; + }; + title: string; + description: string; + currentSlug?: string; + currentPage?: DocsPage; +} + +const { index, title, description, currentSlug, currentPage } = Astro.props; +const isPreview = index.channel === "preview"; +const [downloadIconWidth, downloadIconHeight, , , downloadIconPathData] = faDownload.icon; +const downloadIconPath = Array.isArray(downloadIconPathData) + ? downloadIconPathData[0] + : downloadIconPathData; +const shortRevision = index.source.revision.slice(0, 10); +const sourceRevisionUrl = `https://github.com/${index.source.repository}/tree/${index.source.revision}`; +--- + + +
+ + +
+ + Browse Scient Docs + {currentPage?.title ?? "All pages"} + + +
+ +
+
+ +
+ +
+
+ + +
+ + + + diff --git a/src/lib/scient-docs-navigation.test.ts b/src/lib/scient-docs-navigation.test.ts new file mode 100644 index 0000000..9117a04 --- /dev/null +++ b/src/lib/scient-docs-navigation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { getScientDocsNavigationGroups } from "./scient-docs-navigation"; + +describe("Scient Docs navigation", () => { + it("uses manifest order and topic metadata as the only navigation authority", () => { + const pages = [ + { slug: "start", topic: "Essentials" }, + { slug: "project", topic: "Essentials" }, + { slug: "voice", topic: "Work in Scient" }, + { slug: "keys", topic: "Work in Scient", secondary: true }, + ]; + + const groups = getScientDocsNavigationGroups(pages); + + expect(groups.map((group) => group.title)).toEqual(["Essentials", "Work in Scient"]); + expect(groups[0]?.primaryPages.map((page) => page.slug)).toEqual(["start", "project"]); + expect(groups[1]?.primaryPages.map((page) => page.slug)).toEqual(["voice"]); + expect(groups[1]?.secondaryPages.map((page) => page.slug)).toEqual(["keys"]); + expect(groups[1]?.pages.map((page) => page.slug)).toEqual(["voice", "keys"]); + }); + + it("treats pages as primary unless the manifest explicitly marks them secondary", () => { + const [group] = getScientDocsNavigationGroups([ + { slug: "one", topic: "Research" }, + { slug: "two", topic: "Research", secondary: false }, + ]); + + expect(group?.primaryPages).toHaveLength(2); + expect(group?.secondaryPages).toHaveLength(0); + }); +}); diff --git a/src/lib/scient-docs-navigation.ts b/src/lib/scient-docs-navigation.ts new file mode 100644 index 0000000..453bba2 --- /dev/null +++ b/src/lib/scient-docs-navigation.ts @@ -0,0 +1,43 @@ +interface ScientDocsNavigationPage { + slug: string; + topic: string; + secondary?: boolean; +} + +export function getScientDocsNavigationGroups( + pages: readonly T[], +) { + const groupsByTopic = new Map< + string, + { + title: string; + pages: T[]; + primaryPages: T[]; + secondaryPages: T[]; + } + >(); + + for (const page of pages) { + let group = groupsByTopic.get(page.topic); + if (!group) { + group = { + title: page.topic, + pages: [], + primaryPages: [], + secondaryPages: [], + }; + groupsByTopic.set(page.topic, group); + } + + group.pages.push(page); + if (page.secondary === true) group.secondaryPages.push(page); + else group.primaryPages.push(page); + } + + return [...groupsByTopic.values()] as Array<{ + title: string; + pages: T[]; + primaryPages: T[]; + secondaryPages: T[]; + }>; +} diff --git a/src/pages/docs.astro b/src/pages/docs.astro index c03f0d5..e456c81 100644 --- a/src/pages/docs.astro +++ b/src/pages/docs.astro @@ -1,103 +1,324 @@ --- -import Prose from "../components/Prose.astro"; -import Layout from "../layouts/Layout.astro"; +import ScientDocsShell from "../components/ScientDocsShell.astro"; +import docsIndex from "../generated/scient-docs/index.json"; +import { getScientDocsNavigationGroups } from "../lib/scient-docs-navigation"; + +const navigationGroups = getScientDocsNavigationGroups(docsIndex.pages); --- - - -
- -
-

Download the right build for your computer.

-

- The download page provides the current installers for macOS, Windows, and Linux directly from ScientFactory's official GitHub release. -

-

- It also explains how to choose between Apple Silicon and Intel on macOS and how to handle the first-launch security message on each platform. -

- -
-
- -
- -
-

Begin with a project you already understand.

-
    -
  1. -
    -

    Open a project folder

    -

    Add a local folder containing the files or work you want Scient and its agents to help with.

    -
    -
  2. -
  3. -
    -

    Choose an available provider

    -

    Scient works with supported AI tools installed and authorized on your computer. Choose the provider you want for the new thread.

    -
    -
  4. -
  5. -
    -

    Start a focused thread

    -

    Describe one concrete task, provide the necessary context, and review the agent's proposed work as it progresses.

    -
    -
  6. -
  7. -
    -

    Keep the work visible

    -

    Use Scient's chat, terminal, browser, and review surfaces to understand what changed before accepting the result.

    -
    -
  8. -
-
-
- -
- -
-

A few concepts are enough to find your way around.

-
-
-

Projects

-

A project connects Scient to a local folder and keeps related work together.

-
-
-

Threads

-

Each thread is a focused conversation with an agent about a particular task.

-
-
-

Providers

-

You choose which supported AI provider and model powers a session.

-
-
-

Workspace tools

-

Chats, terminals, browser previews, and reviews remain accessible in one workspace.

-
-
-
-
- -
- -
-

Expect change, and tell us when something breaks.

-

- Scient is under active development. Features may change between releases, and you may encounter unfinished behavior or platform-specific issues. -

-
-

When reporting a problem, include your operating system, Scient version, the provider you were using, and the shortest sequence that reproduces the issue.

-
-
-
-
-
+
+
+

Documentation

+

Learn Scient from the source.

+
+ + + +
+ { + navigationGroups.map((group) => { + const { title: topic, primaryPages, secondaryPages } = group; + + return ( +
+

{topic}

+
+ {primaryPages.map((page) => ( + + ))} +
+ {secondaryPages.length > 0 && ( +
+ + See {secondaryPages.length} more + Show less + + +
+ {secondaryPages.map((page) => ( + + ))} +
+
+ )} +
+ ); + }) + } +
+
+ + + + + diff --git a/src/pages/docs/[slug].astro b/src/pages/docs/[slug].astro new file mode 100644 index 0000000..7ee610f --- /dev/null +++ b/src/pages/docs/[slug].astro @@ -0,0 +1,34 @@ +--- +import ScientDocsShell from "../../components/ScientDocsShell.astro"; +import docsIndex from "../../generated/scient-docs/index.json"; + +interface MarkdownModule { + Content: any; +} + +const pageModules = import.meta.glob("../../generated/scient-docs/pages/*.md", { + eager: true, +}); + +export function getStaticPaths() { + return docsIndex.pages.map((page) => ({ params: { slug: page.slug } })); +} + +const page = docsIndex.pages.find((candidate) => candidate.slug === Astro.params.slug); +if (!page) throw new Error(`Unknown Scient Docs page: ${Astro.params.slug}`); +const pageModule = pageModules[`../../generated/scient-docs/pages/${page.slug}.md`]; +if (!pageModule) throw new Error(`Missing generated Scient Docs module: ${page.slug}`); +const Content = pageModule.Content; +--- + + +
+ +
+
diff --git a/src/pages/docs/index.json.ts b/src/pages/docs/index.json.ts new file mode 100644 index 0000000..33b9e1a --- /dev/null +++ b/src/pages/docs/index.json.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from "astro"; + +import docsIndex from "../../generated/scient-docs/index.json"; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(JSON.stringify(docsIndex, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=300", + }, + }); diff --git a/src/pages/docs/raw/[slug].md.ts b/src/pages/docs/raw/[slug].md.ts new file mode 100644 index 0000000..fc6c6f5 --- /dev/null +++ b/src/pages/docs/raw/[slug].md.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; + +import docsIndex from "../../../generated/scient-docs/index.json"; +import rawPages from "../../../generated/scient-docs/raw.json"; + +export const prerender = true; + +export function getStaticPaths() { + return docsIndex.pages.map((page) => ({ params: { slug: page.slug } })); +} + +export const GET: APIRoute = ({ params }) => { + const slug = params.slug ?? ""; + const markdown = rawPages[slug as keyof typeof rawPages]; + if (typeof markdown !== "string") return new Response("Not found\n", { status: 404 }); + return new Response(markdown, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=300", + "X-Scient-Docs-Source": docsIndex.source.revision, + }, + }); +};