Compare commits

..
57 changed files with 3872 additions and 2804 deletions
+92
View File
@@ -0,0 +1,92 @@
name: Sync Portal Docs
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
# repo fires a `docs-updated` repository_dispatch.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
ref:
description: "Docs repo ref (branch or tag) to sync from"
required: false
default: "main"
repository_dispatch:
types: [docs-updated]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Sync docs manifest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Regenerate docs manifest
working-directory: frontend
env:
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: npm run docs:sync
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Sync portal docs from docs repo"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync-portal-docs
base: main
title: "Sync portal docs from docs repo"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: documentation,github-actions,frontend
add-paths: frontend/editor/src/portal/generated/docsManifest.json
delete-branch: true
sign-commits: true
+2
View File
@@ -12,6 +12,8 @@ editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
editor/src/portal/generated/docsManifest.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
+1 -17
View File
@@ -43,30 +43,14 @@ function parseEnTranslation(): Record<string, unknown> {
}
}
const enTranslationResources = parseEnTranslation();
if (!i18next.isInitialized) {
// initImmediate: false → initialise synchronously from the inline resources
// (there's no async backend here), so i18next is ready before the first story
// renders. Without it the first render can beat init and stick on raw keys.
void i18next.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
resources: { en: { translation: enTranslationResources } },
resources: { en: { translation: parseEnTranslation() } },
interpolation: { escapeValue: false },
react: { useSuspense: false },
initImmediate: false,
});
} else {
// Something initialised i18next first (e.g. the app's async TOML backend):
// inject the shipped English copy synchronously so t() never renders raw keys.
i18next.addResourceBundle(
"en",
"translation",
enTranslationResources,
true,
true,
);
if (i18next.language !== "en") void i18next.changeLanguage("en");
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -6603,6 +6603,10 @@ title = "No components available"
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.docs]
browse = "Browse docs"
viewSource = "View source on GitHub"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -6688,11 +6692,19 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -7246,7 +7258,7 @@ storage = "Storage"
[portal.nav]
agent-builder = "Agent Builder"
components = "Components"
docs = "Developer Docs"
docs = "Documentation"
documents = "Documents"
editor = "Editor"
home = "Home"
@@ -6563,6 +6563,10 @@ bucket = "Bucket"
name = "Name"
region = "Region"
[portal.docs]
browse = "Browse docs"
viewSource = "View source on GitHub"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -6648,11 +6652,19 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -7317,7 +7329,7 @@ storage = "Storage"
[portal.nav]
agent-builder = "Agent Builder"
components = "Components"
docs = "Developer Docs"
docs = "Documentation"
documents = "Documents"
editor = "Editor"
home = "Home"
@@ -7708,48 +7720,6 @@ soon = "Soon"
managePlan = "Manage plan"
volumeSuffix = "PDFs processed · last 30 days"
[portal.processorFlow]
footnote = "Counts are over the last 24 hours. Flow speed is illustrative."
liveBadge = "Live"
stats = "{{connected}} connected · {{processed}} PDFs processed"
title = "PDF Processor"
[portal.processorFlow.lens]
ariaLabel = "View mode"
flow = "Flow"
sankey = "Sankey"
[portal.processorFlow.outcomes]
count = "{{n}} · 24h"
failed = "Failed"
heading = "Outcomes"
success = "Delivered"
[portal.processorFlow.policies]
activeCount = "{{n}} active"
count = "{{n}} · 24h"
heading = "Policies"
setUp = "Set up"
soon = "Soon"
[portal.processorFlow.sankey]
waist = "Policies · {{n}} active"
[portal.processorFlow.sankey.empty]
description = "Connect a source and switch on a policy to watch documents flow through the processor."
title = "No flow yet"
[portal.processorFlow.sources]
comingSoonTag = "Connect"
editor = "Stirling PDF Editor"
heading = "Sources"
perDay = "{{n}} / 24h"
[portal.processorFlow.sources.comingSoon]
apiMcp = "API · MCP"
cloud = "Drive · Box · S3"
email = "Email intake"
[portal.procurement]
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -0,0 +1,159 @@
/**
* Sync the portal Developer Docs from the Stirling docs repo.
*
* Fetches the docs repo tarball, extracts `docs/**` in-process (no external tar
* binary, no per-file GitHub rate limits), shapes it with the pure transforms in
* src/portal/docs/manifest/transform.ts, and writes the committed manifest that
* the portal docs view renders. Re-run with `npm run docs:sync`.
*
* Env: DOCS_REPO, DOCS_REF, DOCS_ROOT override the defaults below.
*/
import { gunzipSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
// eslint-disable-next-line no-restricted-imports
import {
buildManifest,
type CategoryMap,
type RawDoc,
} from "../src/portal/docs/manifest/transform.ts";
const REPO = process.env.DOCS_REPO ?? "Stirling-Tools/Stirling-Tools.github.io";
const REF = process.env.DOCS_REF ?? "main";
const ROOT = process.env.DOCS_ROOT ?? "docs";
const SITE = "https://docs.stirlingpdf.com";
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(HERE, "../src/portal/generated/docsManifest.json");
/* ── Minimal tar reader (ustar + pax/GNU long names) ─────────────────────── */
interface TarEntry {
name: string;
type: string;
data: Buffer;
}
function readTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
let longName: string | null = null;
let paxPath: string | null = null;
const str = (start: number, len: number) => {
const slice = buf.subarray(start, start + len);
const end = slice.indexOf(0);
return slice.toString("utf8", 0, end === -1 ? len : end);
};
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
// Two consecutive zero blocks mark the end of the archive.
if (header.every((b) => b === 0)) break;
const name = str(offset, 100);
const prefix = str(offset + 345, 155);
const sizeStr = str(offset + 124, 12).trim();
const size = parseInt(sizeStr, 8) || 0;
const type = String.fromCharCode(header[156]);
const dataStart = offset + 512;
const data = buf.subarray(dataStart, dataStart + size);
let fullName = prefix ? `${prefix}/${name}` : name;
if (longName) {
fullName = longName;
longName = null;
}
if (paxPath) {
fullName = paxPath;
paxPath = null;
}
if (type === "L") {
// GNU long name: the payload is the real name of the next entry.
longName = data.toString("utf8").replace(/\0+$/, "");
} else if (type === "x") {
// pax extended header: pull a `path=` record for the next entry.
const record = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString("utf8"));
if (record) paxPath = record[1];
} else if (type === "0" || type === "\0" || type === "") {
entries.push({ name: fullName, type, data: Buffer.from(data) });
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
return entries;
}
/* ── Fetch + shape ───────────────────────────────────────────────────────── */
async function main(): Promise<void> {
const url = `https://api.github.com/repos/${REPO}/tarball/${REF}`;
console.log(`Fetching ${REPO}@${REF}`);
const res = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "stirling-portal-docs-sync",
...(process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {}),
},
});
if (!res.ok) {
throw new Error(
`GitHub tarball fetch failed: ${res.status} ${res.statusText}`,
);
}
const gz = Buffer.from(await res.arrayBuffer());
const entries = readTar(gunzipSync(gz));
// Strip the "<repo>-<sha>/" wrapper dir and keep only files under docs root.
const prefix = `${ROOT}/`;
const rawDocs: RawDoc[] = [];
const categories: CategoryMap = {};
for (const entry of entries) {
const rel = entry.name.replace(/^[^/]+\//, "");
if (!rel.startsWith(prefix)) continue;
const inner = rel.slice(prefix.length);
if (!inner) continue;
if (inner.endsWith("/_category_.json")) {
const dir = inner.slice(0, -"/_category_.json".length);
try {
categories[dir] = JSON.parse(entry.data.toString("utf8"));
} catch {
console.warn(` skipping unparseable _category_.json in ${dir}`);
}
} else if (/\.mdx?$/i.test(inner)) {
rawDocs.push({ relPath: inner, content: entry.data.toString("utf8") });
}
}
if (rawDocs.length === 0) {
throw new Error(`No markdown found under ${ROOT}/ — wrong repo/ref/root?`);
}
const manifest = buildManifest(rawDocs, categories, {
repo: REPO,
ref: REF,
root: ROOT,
siteBaseUrl: SITE,
});
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n", "utf8");
const items = manifest.nav.reduce((n, s) => n + s.items.length, 0);
console.log(
`Wrote ${manifest.nav.length} sections, ${items} docs → ${OUT.replace(/.*[/\\]frontend[/\\]/, "frontend/")}`,
);
for (const s of manifest.nav) {
console.log(` ${s.icon} ${s.label} (${s.items.length})`);
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
+3 -1
View File
@@ -4,7 +4,9 @@
"module": "node16",
"moduleResolution": "node16",
"types": ["node"],
"noEmit": true
"noEmit": true,
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
"allowImportingTsExtensions": true
},
"include": ["./**/*.ts", "./**/*.mts"]
}
@@ -13,6 +13,7 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -198,6 +199,41 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,6 +197,31 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -1,37 +0,0 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -1,59 +0,0 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
+16 -2
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
@@ -12,10 +13,16 @@ import { Components } from "@portal/views/Components";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { Procurement } from "@portal/views/Procurement";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk.
const DeveloperDocs = lazy(() =>
import("@portal/views/DeveloperDocs").then((m) => ({
default: m.DeveloperDocs,
})),
);
// The portal mounts as a route-set under /processor/* in the editor app, so these
// child routes are relative to that base: strip the leading slash from the
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
@@ -59,7 +66,14 @@ export function ViewRouter() {
/>
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
<Route
path={rel(VIEW_PATHS.docs)}
element={
<Suspense fallback={null}>
<DeveloperDocs />
</Suspense>
}
/>
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
<Route
path="account-link"
+50 -22
View File
@@ -13,8 +13,6 @@ import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -68,7 +66,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: PolicyToolStep[];
defaultOperations: WirePipelineStep[];
}
export interface PolicyState {
@@ -130,11 +128,20 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Endpoint display labels */
/* Tool → endpoint registry */
/* ──────────────────────────────────────────────────────────────────────── */
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -147,8 +154,7 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -224,7 +230,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -251,16 +260,32 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
],
fields: [],
},
@@ -272,7 +297,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -315,7 +343,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("compress")],
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -346,7 +374,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("compress")],
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -1,176 +0,0 @@
/**
* Processor-flow assembler for the home visualiser.
*
* Fans in the three real portal surfaces — sources (`/api/v1/sources`), policies
* (`/api/v1/policies`) and their runs (`/api/v1/policies/runs`) — and derives the
* left→middle→right shape the {@link ProcessorFlow} component renders:
*
* sources → policies → outcomes
*
* Everything here is real backend data. Per-run source attribution does not
* exist (a `PolicyRunView` carries `policyId` but no source id), so the flow
* animation is illustrative; the node counts are not — each source's `docs24h`,
* each policy's trailing-24h run count, and the success/failure split all come
* straight from the API.
*/
import { apiClient } from "@portal/api/http";
import { fetchSources } from "@portal/api/sources";
import { POLICY_CATEGORIES } from "@portal/api/policies";
import { fromWirePolicy } from "@app/policies/codec";
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
/** A source that actually feeds the processor today (editor, folder, S3, …). */
export interface FlowSource {
id: string;
/** Display name (already resolved; editor rows get a friendly label). */
name: string;
type: string;
/** Documents this source fed into runs over the trailing 24h. */
docs24h: number;
}
/**
* A connector type shown in the sources column but not yet a real source type —
* a "coming soon" affordance only. `labelKey` is an i18n key.
*/
export interface FlowComingSoonSource {
key: string;
labelKey: string;
}
/**
* Row display state, mirroring the Policies page:
* - `active` — configured + enabled; shows its live 24h run count
* - `off` — available but not set up; offers a "Set up" CTA
* - `locked` — a coming-soon category that doesn't exist yet
*/
export type FlowPolicyState = "active" | "off" | "locked";
/**
* One row in the middle policies column — the full policy catalogue, in the
* same order the Policies page shows, including the coming-soon categories.
*/
export interface FlowPolicy {
/** Category id (also the lane key for the flow animation). */
key: string;
/** i18n key for the category label. */
labelKey: string;
/** Material Symbols icon name (from the catalogue). */
icon: string;
state: FlowPolicyState;
configured: boolean;
runs24h: number;
}
export type FlowOutcomeKey = "success" | "failed";
/** A terminal audit outcome node on the right, counted over the trailing 24h. */
export interface FlowOutcome {
key: FlowOutcomeKey;
labelKey: string;
count24h: number;
}
export interface ProcessorFlow {
sources: FlowSource[];
comingSoonSources: FlowComingSoonSource[];
policies: FlowPolicy[];
outcomes: FlowOutcome[];
}
const DAY_MS = 86_400_000;
/** Connector types the sources column advertises but can't create yet. */
const COMING_SOON_SOURCES: FlowComingSoonSource[] = [
{ key: "apiMcp", labelKey: "portal.processorFlow.sources.comingSoon.apiMcp" },
{
key: "cloud",
labelKey: "portal.processorFlow.sources.comingSoon.cloud",
},
{
key: "email",
labelKey: "portal.processorFlow.sources.comingSoon.email",
},
];
/**
* The full policy catalogue, in the Policies-page order, including the
* coming-soon categories (rendered as locked). `active` rows carry their
* trailing-24h run count.
*/
function buildPolicies(
wirePolicies: WirePolicy[],
runs: PolicyRunView[],
): FlowPolicy[] {
const cutoff = Date.now() - DAY_MS;
const decoded = wirePolicies.map(fromWirePolicy);
return POLICY_CATEGORIES.map((cat) => {
const dp = decoded.find((p) => p.categoryId === cat.id);
const configured = Boolean(dp?.enabled);
const state: FlowPolicyState = configured
? "active"
: cat.comingSoon
? "locked"
: "off";
const runs24h = dp
? runs.filter((r) => r.policyId === dp.id && r.createdAt >= cutoff).length
: 0;
return {
key: cat.id,
labelKey: cat.label,
icon: cat.icon,
state,
configured,
runs24h,
};
});
}
/** Terminal audit outcomes over the trailing 24h — success vs failure. */
function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] {
const cutoff = Date.now() - DAY_MS;
const recent = runs.filter((r) => r.createdAt >= cutoff);
const success = recent.filter((r) => r.status === "COMPLETED").length;
const failed = recent.filter(
(r) => r.status === "FAILED" || r.status === "CANCELLED",
).length;
return [
{
key: "success",
labelKey: "portal.processorFlow.outcomes.success",
count24h: success,
},
{
key: "failed",
labelKey: "portal.processorFlow.outcomes.failed",
count24h: failed,
},
];
}
/** Assemble the full flow model from the three live portal surfaces. */
export async function fetchProcessorFlow(): Promise<ProcessorFlow> {
const [sourcesResp, wirePolicies, runs] = await Promise.all([
fetchSources(),
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]),
]);
const sources: FlowSource[] = sourcesResp.sources.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
docs24h: s.docs24h,
}));
return {
sources,
comingSoonSources: COMING_SOON_SOURCES,
policies: buildPolicies(wirePolicies, runs),
outcomes: buildOutcomes(runs),
};
}
@@ -1,394 +0,0 @@
/* Processor-flow visualiser — sources → policies → outcomes. The flow is an
SVG overlay measured from the HTML cards: bézier wires underneath, and a
rAF-driven particle layer on top (see ProcessorFlow.tsx). */
.portal-pf {
--pf-accent: var(--color-green);
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.portal-pf__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.portal-pf__head-text {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
}
.portal-pf__head-actions {
display: flex;
align-items: center;
gap: 0.625rem;
flex: none;
}
.portal-pf__live {
align-self: center;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-border);
}
.portal-pf__live--on {
background: var(--pf-accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent);
animation: pf-pulse 2.4s ease-out infinite;
}
.portal-pf__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pf__connected {
font-size: 0.75rem;
color: var(--color-text-4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Stage: relative box the SVG overlays are measured against ───────────── */
.portal-pf__stage {
position: relative;
padding: 0.25rem 0 0.5rem;
}
.portal-pf__wires,
.portal-pf__particles {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.portal-pf__wires {
z-index: 0;
}
.portal-pf__particles {
z-index: 2;
}
.portal-pf__wire-path {
fill: none;
stroke: var(--color-border-light);
stroke-width: 1.25;
}
/* ── Columns ─────────────────────────────────────────────────────────────── */
.portal-pf__cols {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 1rem;
}
.portal-pf__col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
flex: none;
width: 15rem;
}
.portal-pf__col-head,
.portal-pf__policies-head {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-5);
margin-bottom: 0.125rem;
}
/* ── Nodes (source + outcome cards) ─────────────────────────────────────────── */
.portal-pf__node {
height: auto;
width: 100%;
text-align: left;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
transition:
background var(--motion-fast),
border-color var(--motion-fast);
}
.portal-pf__node .mantine-Button-label {
flex: 1 1 auto;
justify-content: flex-start;
overflow: visible;
}
.portal-pf__node:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.portal-pf__node:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.portal-pf__node--soon {
background: transparent;
border-style: dashed;
opacity: 0.75;
}
.portal-pf__node-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
font-size: 0.9375rem;
background: var(--color-bg-muted);
color: var(--color-text-3);
}
.portal-pf__node--soon .portal-pf__node-icon {
color: var(--color-text-5);
font-weight: 600;
}
.portal-pf__node--success .portal-pf__node-icon {
background: var(--color-green-light);
color: var(--color-green-dark);
}
.portal-pf__node--failed .portal-pf__node-icon {
background: var(--color-red-light);
color: var(--color-red-dark);
}
.portal-pf__node-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-pf__node-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portal-pf__node-text span {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* ── Policies card (core) ───────────────────────────────────────────────────── */
.portal-pf__policies {
align-self: center;
flex: none;
width: 18rem;
padding: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.portal-pf__policies-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.5rem;
}
.portal-pf__policies-active {
text-transform: none;
letter-spacing: 0;
color: var(--color-green-dark);
}
.portal-pf__policy {
padding: 0.5rem 0.25rem;
}
.portal-pf__policy + .portal-pf__policy {
border-top: 1px solid var(--color-border-light);
}
.portal-pf__policy-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pf__policy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 1.5rem;
height: 1.5rem;
border-radius: 6px;
font-size: 0.875rem;
color: var(--color-text-4);
transition:
color 0.3s ease,
background-color 0.3s ease,
box-shadow 0.3s ease;
}
.portal-pf__policy--active .portal-pf__policy-icon {
color: var(--pf-accent);
}
.portal-pf__policy--locked .portal-pf__policy-icon {
color: var(--color-text-5);
}
/* Leading-LED blink: added for 150ms as a particle threads this row's lane,
then eased back out by the transition above. */
.portal-pf__policy-icon.is-pulse {
color: var(--color-green-dark);
background-color: var(--color-green-light);
box-shadow: 0 0 8px 1px
color-mix(in srgb, var(--color-green) 55%, transparent);
}
.portal-pf__policy-label {
flex: 1 1 auto;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-2);
min-width: 0;
}
.portal-pf__policy--active .portal-pf__policy-label {
color: var(--color-text-1);
}
.portal-pf__policy--locked .portal-pf__policy-label {
color: var(--color-text-4);
font-weight: 500;
}
.portal-pf__policy-count {
font-size: 0.75rem;
color: var(--color-text-4);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.portal-pf__policy-soon {
flex: none;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-5);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Vertical padding via the `py` prop; height auto so it grows with it. */
.portal-pf__setup {
flex: none;
height: auto;
}
/* ── Footnote / loading ─────────────────────────────────────────────────────── */
.portal-pf__foot {
margin: 0.875rem 0 0;
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-pf__loading {
padding: 0.5rem 0;
}
/* ── Sankey lens ────────────────────────────────────────────────────────────── */
.portal-pf__sankey {
max-width: 46rem;
margin: 0.5rem auto 0;
}
.portal-pf__sankey svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.portal-pf__sankey-label {
font-size: 12px;
font-weight: 600;
fill: var(--color-text-2);
font-variant-numeric: tabular-nums;
}
.portal-pf__sankey-caption {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
fill: var(--color-text-5);
}
.portal-pf__sankey-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 11rem;
}
@keyframes pf-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent);
}
70% {
box-shadow: 0 0 0 0.4rem transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
/* ── Responsive: stack the columns; the measured-geometry flow overlay only
makes sense on the wide 3-column layout, so drop it below the breakpoint. ── */
@media (max-width: 60rem) {
.portal-pf__wires,
.portal-pf__particles {
display: none;
}
.portal-pf__cols {
flex-direction: column;
gap: 0.75rem;
}
.portal-pf__col,
.portal-pf__policies {
width: 100%;
align-self: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
.portal-pf__live--on {
animation: none;
}
}
@@ -1,60 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { ProcessorFlow } from "@portal/components/ProcessorFlow";
/**
* The home processor visualiser. Data is served by the global portal MSW
* handlers (seeded sources + one active Security policy + its runs), so the
* middle column shows Security "active" and Classification with a "Set up" CTA,
* and the outcomes reflect the seeded 24h success/failure split.
*
* NB: the flow particles animate via requestAnimationFrame, which browsers
* pause while the tab/preview is hidden — open the story in a focused tab to
* see the dots move.
*/
const meta: Meta<typeof ProcessorFlow> = {
title: "Portal/Components/ProcessorFlow",
component: ProcessorFlow,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof ProcessorFlow>;
/** Live machine: Security configured + real throughput → the flow runs. */
export const Default: Story = {};
/**
* Nothing set up and no activity — the empty state from the design. In
* production the flow stays still here; the DEV_KEEP_FLOWING dev flag forces it
* on with synthetic rates so the animation is visible while iterating.
*/
export const IdleEmpty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/sources", () =>
HttpResponse.json({
kpis: [],
sources: [
{
id: "editor",
name: "Editor",
type: "editor",
status: "active",
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: 0,
docs24h: 0,
docs30d: 0,
},
],
}),
),
http.get("/api/v1/policies", () => HttpResponse.json([])),
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
],
},
},
};
@@ -1,184 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Card, SegmentedControl, Skeleton, StatusBadge } from "@app/ui";
import {
useView,
VIEW_PATHS,
toPortalPath,
} from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchProcessorFlow,
type ProcessorFlow as ProcessorFlowModel,
} from "@portal/api/processorFlow";
import {
DEV_KEEP_FLOWING,
DEV_SYNTH_RATE,
type Lens,
} from "@portal/components/processor-flow/flowTypes";
import { useFlowGeometry } from "@portal/components/processor-flow/useFlowGeometry";
import { useFlowParticles } from "@portal/components/processor-flow/useFlowParticles";
import { FlowSources } from "@portal/components/processor-flow/FlowSources";
import { FlowPolicies } from "@portal/components/processor-flow/FlowPolicies";
import { FlowOutcomes } from "@portal/components/processor-flow/FlowOutcomes";
import { FlowSankey } from "@portal/components/processor-flow/FlowSankey";
import "@portal/components/ProcessorFlow.css";
/**
* Animated processor visualiser for the home surface: connected sources on the
* left flow through the standing policies in the middle to their audit outcomes
* on the right. Two lenses — a live particle flow and a Sankey summary.
*
* This module wires the data + gating together; the moving parts live under
* `processor-flow/`: geometry ({@link useFlowGeometry}), the rAF particle loop
* ({@link useFlowParticles}), the three columns, and the Sankey. The flow only
* runs when something is set up AND there's activity; an idle machine stays
* still (unless {@link DEV_KEEP_FLOWING} forces it while iterating).
*/
export function ProcessorFlow() {
const { t } = useTranslation();
const { setActiveView } = useView();
const navigate = useNavigate();
const { data, loading } = useAsync<ProcessorFlowModel>(
() => fetchProcessorFlow(),
[],
);
const [lens, setLens] = useState<Lens>("flow");
const isLoading = loading && data === null;
/** Deep-link to the Policies page and auto-open that policy's setup wizard. */
const openPolicySetup = (key: string) =>
navigate(
`${toPortalPath(VIEW_PATHS.policies)}?setup=${encodeURIComponent(key)}`,
);
/** Deep-link to Infrastructure with the audit-log tab open. */
const openAuditLog = () =>
navigate(`${toPortalPath(VIEW_PATHS.infrastructure)}?tab=audit`);
const sources = data?.sources ?? [];
const policies = data?.policies ?? [];
const outcomes = data?.outcomes ?? [];
const comingSoonSources = data?.comingSoonSources ?? [];
// ── Flow gating: run only when something is set up AND there's activity.
const totalRate = sources.reduce((sum, s) => sum + s.docs24h, 0);
const hasConfigured = policies.some((p) => p.configured);
const liveFlow = hasConfigured && totalRate > 0;
// When forcing for dev with no live flow, synthesise rates + thread every row.
const devForced = DEV_KEEP_FLOWING && !liveFlow;
const animate = liveFlow || devForced;
// Particles only thread configured (active) policies; while dev-forcing with
// no live flow, thread the available (non-locked) rows so the demo has lanes.
const laneKeys = policies
.filter((p) => (devForced ? p.state !== "locked" : p.state === "active"))
.map((p) => p.key);
const activeCount = policies.filter((p) => p.state === "active").length;
const pdfsProcessed = outcomes.reduce((sum, o) => sum + o.count24h, 0);
const statsLabel = t("portal.processorFlow.stats", {
connected: sources.length,
processed: pdfsProcessed.toLocaleString(),
});
// Per-source rates + outcome weights feeding the particle loop.
const rates = sources.map((s) => (devForced ? DEV_SYNTH_RATE : s.docs24h));
const weights = (() => {
const raw = outcomes.map((o) => o.count24h);
const sum = raw.reduce((a, b) => a + b, 0);
if (sum > 0) return raw.map((v) => v / sum);
// No real outcomes yet (dev flow): success-heavy default.
return outcomes.map((o) => (o.key === "failed" ? 0.15 : 0.85));
})();
const outcomeKeys = outcomes.map((o) => o.key);
const { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires } =
useFlowGeometry();
const pGroupRef = useFlowParticles({
geoRef,
animate,
lens,
rates,
weights,
laneKeys,
outcomeKeys,
});
return (
<Card padding="loose" className="portal-pf">
<header className="portal-pf__head">
<div className="portal-pf__head-text">
<span
className={
"portal-pf__live" + (animate ? " portal-pf__live--on" : "")
}
aria-hidden
/>
<h2 className="portal-pf__title">
{t("portal.processorFlow.title")}
</h2>
<span className="portal-pf__connected">{statsLabel}</span>
</div>
<div className="portal-pf__head-actions">
<StatusBadge tone={animate ? "success" : "neutral"} size="sm">
{t("portal.processorFlow.liveBadge")}
</StatusBadge>
<SegmentedControl<Lens>
size="xs"
value={lens}
onChange={setLens}
ariaLabel={t("portal.processorFlow.lens.ariaLabel")}
options={[
{ label: t("portal.processorFlow.lens.flow"), value: "flow" },
{ label: t("portal.processorFlow.lens.sankey"), value: "sankey" },
]}
/>
</div>
</header>
{isLoading ? (
<div className="portal-pf__loading" aria-hidden>
<Skeleton height="9rem" />
</div>
) : lens === "sankey" ? (
<FlowSankey sources={sources} outcomes={outcomes} policies={policies} />
) : (
<div className="portal-pf__stage" ref={wrapRef}>
<svg className="portal-pf__wires" aria-hidden>
{wires}
</svg>
<div className="portal-pf__cols">
<FlowSources
sources={sources}
comingSoonSources={comingSoonSources}
srcRefs={srcRefs}
onOpen={() => setActiveView("sources")}
/>
<FlowPolicies
policies={policies}
activeCount={activeCount}
coreRef={coreRef}
laneRefs={laneRefs}
onSetup={openPolicySetup}
/>
<FlowOutcomes
outcomes={outcomes}
outRefs={outRefs}
onOpen={openAuditLog}
/>
</div>
<svg className="portal-pf__particles" aria-hidden>
<g ref={pGroupRef} />
</svg>
</div>
)}
<p className="portal-pf__foot">{t("portal.processorFlow.footnote")}</p>
</Card>
);
}
@@ -1,8 +1,45 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Skeleton, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { DocsNavSection } from "@portal/api/docs";
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
/**
* Left-hand documentation nav: a hierarchical accordion. Section ids encode their
* path ("functionality/security" is a child of "functionality"), so sub-sections
* nest under their parent. The root "Overview" section is static (always open, no
* toggle); every other section collapses, and only the branch leading to the
* active doc opens by default. (Search lives in DocsSearch above this.)
*/
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
const STATIC_SECTION_ID = "overview";
interface NavNode {
section: DocsNavSection;
children: NavNode[];
}
/** Split "a/b/c" → "a/b"; null for a top-level id. */
function parentId(id: string): string | null {
const i = id.lastIndexOf("/");
return i === -1 ? null : id.slice(0, i);
}
/** Build the section tree from the flat, pre-sorted section list. */
function buildTree(sections: DocsNavSection[]): NavNode[] {
const byId = new Map<string, NavNode>(
sections.map((s) => [s.id, { section: s, children: [] }]),
);
const roots: NavNode[] = [];
for (const node of byId.values()) {
const pid = parentId(node.section.id);
const parent = pid ? byId.get(pid) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
export function DocsNav({
sections,
active,
@@ -13,50 +50,125 @@ export function DocsNav({
onSelect: (id: string) => void;
}) {
const { t } = useTranslation();
// Per-section manual open/close, overriding the "active branch only" default.
const [toggled, setToggled] = useState<Record<string, boolean>>({});
const activeRef = useRef<HTMLButtonElement>(null);
const activeSectionId = useMemo(
() => sections.find((s) => s.items.some((i) => i.id === active))?.id,
[sections, active],
);
const tree = useMemo(() => buildTree(sections), [sections]);
// Keep the active item in view when navigating (e.g. via a cross-link).
useEffect(() => {
activeRef.current?.scrollIntoView?.({ block: "nearest" });
}, [active]);
const isOpen = (id: string): boolean => {
if (id === STATIC_SECTION_ID) return true;
// Default-open the branch containing the active doc (self or ancestor).
const onActivePath =
!!activeSectionId &&
(activeSectionId === id || activeSectionId.startsWith(id + "/"));
return toggled[id] ?? onActivePath;
};
const renderNode = (node: NavNode) => {
const { section, children } = node;
const isStatic = section.id === STATIC_SECTION_ID;
const open = isOpen(section.id);
return (
<div key={section.id} className="portal-docs__nav-group">
{isStatic ? (
<div className="portal-docs__nav-head">{section.label}</div>
) : (
<Button
variant="tertiary"
fullWidth
justify="between"
className="portal-docs__nav-head portal-docs__nav-head--button"
aria-expanded={open}
onClick={() =>
setToggled((prev) => ({ ...prev, [section.id]: !open }))
}
rightSection={
<span className="portal-docs__nav-count">
{section.items.length}
</span>
}
>
<span className="portal-docs__nav-head-main">
<span
className={
"portal-docs__nav-chevron" + (open ? " is-open" : "")
}
aria-hidden
>
</span>
<span className="portal-docs__nav-headlabel">
{section.label}
</span>
</span>
</Button>
)}
{open && (
<>
{section.items.length > 0 && (
<ul className="portal-docs__nav-list">
{section.items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<Button
ref={isActive ? activeRef : undefined}
variant="tertiary"
justify="start"
fullWidth
className={
"portal-docs__nav-link" +
(isActive ? " is-active" : "")
}
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.id)}
>
<span className="portal-docs__nav-label">
{item.label}
</span>
{item.badge && (
<StatusBadge
tone={item.badge === "New" ? "success" : "info"}
size="sm"
>
{item.badge}
</StatusBadge>
)}
</Button>
</li>
);
})}
</ul>
)}
{children.length > 0 && (
<div className="portal-docs__nav-children">
{children.map(renderNode)}
</div>
)}
</>
)}
</div>
);
};
return (
<nav
className="portal-docs__nav"
aria-label={t("portal.docs.nav.ariaLabel")}
>
{sections.map((section) => (
<div key={section.id} className="portal-docs__nav-group">
<div className="portal-docs__nav-head">
<span className="portal-docs__nav-icon" aria-hidden>
{section.icon}
</span>
{section.label}
</div>
<ul className="portal-docs__nav-list">
{section.items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<Button
variant="tertiary"
justify="start"
fullWidth
className={
"portal-docs__nav-link" + (isActive ? " is-active" : "")
}
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.id)}
>
<span className="portal-docs__nav-label">{item.label}</span>
{item.badge && (
<StatusBadge
tone={item.badge === "New" ? "success" : "info"}
size="sm"
>
{item.badge}
</StatusBadge>
)}
</Button>
</li>
);
})}
</ul>
</div>
))}
{tree.map(renderNode)}
</nav>
);
}
@@ -64,14 +176,16 @@ export function DocsNav({
export function DocsNavSkeleton() {
return (
<nav className="portal-docs__nav" aria-hidden>
{Array.from({ length: 4 }).map((_, gi) => (
{Array.from({ length: 5 }).map((_, gi) => (
<div key={gi} className="portal-docs__nav-group">
<Skeleton width="7rem" height="0.75rem" />
<div className="portal-docs__nav-list">
{Array.from({ length: 3 }).map((_, li) => (
<Skeleton key={li} width="80%" height="0.875rem" />
))}
</div>
{gi === 0 && (
<div className="portal-docs__nav-list">
{Array.from({ length: 4 }).map((_, li) => (
<Skeleton key={li} width="80%" height="0.875rem" />
))}
</div>
)}
</div>
))}
</nav>
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { SearchResult, Segment } from "@portal/docs/search";
/** Render highlighted segments, wrapping matched runs in <mark>. */
function Highlighted({ segments }: { segments: Segment[] }) {
return (
<>
{segments.map((s, i) =>
s.hit ? (
<mark key={i} className="portal-docs__hl">
{s.text}
</mark>
) : (
<span key={i}>{s.text}</span>
),
)}
</>
);
}
/**
* Docs search box + results. While a query is active it shows a ranked list of
* matching docs — each with its section, a highlighted title, and a content
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
*/
export function DocsSearch({
query,
onQueryChange,
results,
onSelect,
}: {
query: string;
onQueryChange: (q: string) => void;
results: SearchResult[];
onSelect: (docId: string) => void;
}) {
const { t } = useTranslation();
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
const hasQuery = query.trim().length > 0;
useEffect(() => setActiveIndex(-1), [query]);
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-active="true"]')
?.scrollIntoView?.({ block: "nearest" });
}, [activeIndex]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
onQueryChange("");
return;
}
if (!results.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const hit = results[activeIndex >= 0 ? activeIndex : 0];
if (hit) onSelect(hit.id);
}
};
return (
<div className="portal-docs__search">
<div className="portal-docs__search-box">
<span className="portal-docs__search-icon" aria-hidden>
</span>
<input
type="search"
className="portal-docs__search-input"
placeholder={t("portal.docs.search.placeholder")}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("portal.docs.search.placeholder")}
/>
</div>
{hasQuery && (
<div className="portal-docs__results">
{results.length === 0 ? (
<p className="portal-docs__nav-empty">
{t("portal.docs.search.empty")}
</p>
) : (
<>
<div className="portal-docs__results-count">
{t("portal.docs.search.results", { count: results.length })}
</div>
<ul ref={listRef} className="portal-docs__results-list">
{results.map((r, i) => (
<li key={r.id}>
<Button
variant="tertiary"
fullWidth
justify="start"
className={
"portal-docs__result" +
(i === activeIndex ? " is-active" : "")
}
data-active={i === activeIndex}
onClick={() => onSelect(r.id)}
>
<span className="portal-docs__result-body">
<span className="portal-docs__result-head">
<span className="portal-docs__result-title">
<Highlighted segments={r.titleSegments} />
</span>
<span className="portal-docs__result-section">
{r.sectionLabel}
</span>
</span>
<span className="portal-docs__result-snippet">
<Highlighted segments={r.snippet} />
</span>
</span>
</Button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { useEffect, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { Heading } from "@portal/docs/headings";
/**
* "On this page" table of contents. Lists the current doc's H2/H3 headings,
* scrolls the reading pane to a heading on click, and highlights the section
* currently in view (scroll-spy against the pane's scroll container).
*/
export function DocsToc({
headings,
scrollRef,
}: {
headings: Heading[];
scrollRef: RefObject<HTMLElement | null>;
}) {
const { t } = useTranslation();
const [active, setActive] = useState<string>(headings[0]?.slug ?? "");
useEffect(() => {
const root = scrollRef.current;
if (!root || headings.length === 0) return;
setActive(headings[0].slug);
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
}
// The topmost heading currently within the active zone wins.
const current = headings.find((h) => visible.has(h.slug));
if (current) setActive(current.slug);
},
// Active zone = the top ~30% of the reading pane.
{ root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
);
const els = headings
.map((h) => root.querySelector(`[id="${h.slug}"]`))
.filter((el): el is Element => el !== null);
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [headings, scrollRef]);
const onSelect = (slug: string) => {
scrollRef.current
?.querySelector(`[id="${slug}"]`)
?.scrollIntoView({ block: "start", behavior: "smooth" });
setActive(slug);
};
return (
<nav className="portal-docs__toc" aria-label={t("portal.docs.toc.title")}>
<div className="portal-docs__toc-title">{t("portal.docs.toc.title")}</div>
<ul className="portal-docs__toc-list">
{headings.map((h) => (
<li key={h.slug}>
<a
href={`#${h.slug}`}
className={
"portal-docs__toc-link" +
(h.level === 3 ? " is-sub" : "") +
(active === h.slug ? " is-active" : "")
}
aria-current={active === h.slug ? "location" : undefined}
onClick={(e) => {
e.preventDefault();
onSelect(h.slug);
}}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,131 @@
import { isValidElement, useState, type ReactNode } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
} from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@app/ui";
import { makeSlugger } from "@portal/docs/headings";
/** Flatten a heading's React children to plain text for its anchor id. */
function childText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(childText).join("");
if (isValidElement(node)) {
return childText((node.props as { children?: ReactNode }).children);
}
return "";
}
// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown
// would by default (it strips unknown protocols, which would kill doc: links).
function urlTransform(url: string): string {
return url.startsWith("doc:") ? url : defaultUrlTransform(url);
}
/**
* Renders a doc's normalised markdown. Internal cross-doc links carry the
* `doc:` scheme (see the sync transform) and are intercepted here so they
* navigate within the portal instead of leaving the app.
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
variant="tertiary"
size="sm"
className="portal-docs__md-copy"
onClick={() =>
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
}
>
{copied ? "✓ Copied" : "Copy"}
</Button>
);
}
function buildComponents(
onNavigate: (docId: string) => void,
slug: (text: string) => string,
): Components {
return {
h2: ({ children }) => <h2 id={slug(childText(children))}>{children}</h2>,
h3: ({ children }) => <h3 id={slug(childText(children))}>{children}</h3>,
a: ({ href, children }) => {
if (href?.startsWith("doc:")) {
const id = href.slice(4);
return (
<a
href={`#${id}`}
onClick={(e) => {
e.preventDefault();
onNavigate(id);
}}
>
{children}
</a>
);
}
const external = /^https?:/i.test(href ?? "");
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
},
// Eager, not lazy: lazy-loading inside the docs' own scroll container isn't
// reliably triggered, and docs pages have only a handful of images.
img: ({ node: _node, ...props }) => (
<img {...props} className="portal-docs__md-img" />
),
pre: ({ children }) => {
const code = isValidElement(children)
? String(
(children.props as { children?: unknown }).children ?? "",
).replace(/\n$/, "")
: String(children ?? "");
return (
<div className="portal-docs__md-pre">
<pre>{children}</pre>
<CopyButton text={code} />
</div>
);
},
table: ({ children }) => (
<div className="portal-docs__md-tablewrap">
<table>{children}</table>
</div>
),
};
}
export function MarkdownDoc({
markdown,
onNavigate,
}: {
markdown: string;
onNavigate: (docId: string) => void;
}) {
// A fresh de-duping slugger per render; react-markdown invokes h2/h3 in
// document order, so ids line up with the TOC's extractHeadings slugs.
const slug = makeSlugger();
return (
<div className="portal-docs__md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={urlTransform}
components={buildComponents(onNavigate, slug)}
>
{markdown}
</ReactMarkdown>
</div>
);
}
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
function TooltipSettings() {
return (
<Tooltip content="help">
<button type="button">field</button>
</Tooltip>
);
}
const step = {
support: "editable",
toolId: "compress",
params: {},
} as unknown as WorkingToolStep;
const registry = {
compress: { automationSettings: TooltipSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
render(
<MantineProvider>
<PipelineStepSettings
step={step}
registry={registry}
onChange={() => {}}
/>
</MantineProvider>,
),
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
});
@@ -1,8 +1,6 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
@@ -48,18 +46,14 @@ export function PipelineStepSettings({
}
return (
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
);
}
@@ -1,134 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PolicySetupResult,
type PipelineStep,
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Second arg is a string fallback in some call sites and an interpolation object in others;
// only treat a string as the fallback.
t: (key: string, fallback?: unknown) =>
typeof fallback === "string" ? fallback : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const securityConfig = POLICY_CONFIG.security;
function editEntry(steps: PipelineStep[]): CatalogueEntry {
const policy: DecoratedPolicy = {
category: security,
config: securityConfig,
state: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
runOn: "upload",
outputMode: "new_version",
outputName: "",
outputNamePosition: "suffix",
maxRetries: 0,
retryDelayMinutes: 0,
backendId: "pol-1",
isDefault: true,
},
steps,
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
activity: [],
};
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
});
it("round-trips a saved step's backend params on edit", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "foo\nbar", useRegex: true },
},
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
expect(result.steps).toEqual([
expect.objectContaining({
operation: "/api/v1/security/auto-redact",
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
}),
]);
});
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
const endpoints = result.steps.map((s) => s.operation);
expect(endpoints).toEqual([
"/api/v1/security/auto-redact",
"/api/v1/security/sanitize-pdf",
]);
// Redact carries the preset PII patterns as the backend's listOfText.
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
});
@@ -13,24 +13,23 @@ import {
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import {
TOOL_ENDPOINTS,
humanizeEndpoint,
type CatalogueEntry,
type PipelineStep,
type PolicySetupResult,
} from "@portal/api/policies";
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
policyEndpoint,
policyStepFromWire,
policyStepToWire,
type PolicyParams,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
deserializeToolStep,
serializeStepFromEndpoint,
} from "@app/hooks/tools/shared/toolAutomation";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import "@portal/views/Policies.css";
@@ -48,8 +47,12 @@ interface PolicySetupWizardProps {
type Step = "workflow" | "settings";
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
/** A configurable tool in the workflow step: whether it runs + its params. */
interface ToolState {
operation: string;
enabled: boolean;
parameters: Record<string, unknown>;
}
/** Resolve each field's effective value: saved override, else definition default. */
function resolveFieldValues(
@@ -66,8 +69,9 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled — the user toggles tools off in the workflow.
*/
// Temporary until the catalogue carries a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
// Temporary: tracks which tools start disabled until the tool registry lands in
// the portal and can drive this via registry metadata or a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
/**
* Policy-facing framing for each capability a policy can include. Labels and
@@ -77,43 +81,43 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
* the humanised endpoint name with no description.
*/
const CAPABILITY_META: Record<
PolicyToolId,
string,
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
> = {
redact: {
[TOOL_ENDPOINTS.redact]: {
labelKey: "portal.policies.wizard.capability.redact.label",
labelEn: "Redact sensitive information",
descKey: "portal.policies.wizard.capability.redact.desc",
descEn:
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
},
sanitize: {
[TOOL_ENDPOINTS.sanitize]: {
labelKey: "portal.policies.wizard.capability.sanitize.label",
labelEn: "Strip active content",
descKey: "portal.policies.wizard.capability.sanitize.desc",
descEn:
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
},
watermark: {
[TOOL_ENDPOINTS.watermark]: {
labelKey: "portal.policies.wizard.capability.watermark.label",
labelEn: "Apply a watermark",
descKey: "portal.policies.wizard.capability.watermark.desc",
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
},
ocr: {
[TOOL_ENDPOINTS.ocr]: {
labelKey: "portal.policies.wizard.capability.ocr.label",
labelEn: "Make text searchable",
descKey: "portal.policies.wizard.capability.ocr.desc",
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
},
flatten: {
[TOOL_ENDPOINTS.flatten]: {
labelKey: "portal.policies.wizard.capability.flatten.label",
labelEn: "Flatten the document",
descKey: "portal.policies.wizard.capability.flatten.desc",
descEn:
"Merges form fields and annotations into the page so they can't be edited.",
},
compress: {
[TOOL_ENDPOINTS.compress]: {
labelKey: "portal.policies.wizard.capability.compress.label",
labelEn: "Reduce file size",
descKey: "portal.policies.wizard.capability.compress.desc",
@@ -121,24 +125,29 @@ const CAPABILITY_META: Record<
},
};
function seedTools(entry: CatalogueEntry): ToolState[] {
function seedTools(
entry: CatalogueEntry,
registry: Partial<ToolRegistry>,
): ToolState[] {
const savedSteps = entry.policy?.steps ?? [];
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
for (const wire of savedSteps) {
const step = policyStepFromWire(wire);
if (step) savedByTool.set(step.toolId, step);
}
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
// step's params win over the preset.
return entry.config.defaultOperations.map((preset) => {
const saved = savedByTool.get(preset.toolId);
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
// Always use defaultOperations as the canonical list so tools added after a
// policy was first saved still appear when editing.
return entry.config.defaultOperations.map((s) => {
const saved = savedByOp.get(s.operation);
return {
...(saved ?? preset),
operation: s.operation,
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(preset.toolId),
: !DISABLED_BY_DEFAULT.has(s.operation),
// Saved steps are in the backend contract shape; map them back to the UI
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
// Presets are already authored in the UI shape, so use them as-is.
parameters: saved
? deserializeToolStep(saved, registry).params
: s.parameters,
};
});
}
@@ -176,12 +185,26 @@ function PolicySetupWizardBody({
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
}) {
const { t } = useTranslation();
const { allTools: toolRegistry } = useToolRegistry();
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
// Build a reverse map so we can look up icons and display names by endpoint.
const registryByEndpoint = useMemo(() => {
const map = new Map<string, ToolRegistryEntry>();
for (const entry of Object.values(toolRegistry)) {
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
}
return map;
}, [toolRegistry]);
const { category, config, policy } = entry;
const isEdit = policy != null;
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [tools, setTools] = useState<ToolState[]>(() =>
seedTools(entry, toolRegistry),
);
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -190,11 +213,22 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -222,20 +256,9 @@ function PolicySetupWizardBody({
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
function patchTool(operation: string, patch: Partial<ToolState>) {
setTools((prev) =>
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
);
}
function setToolParams<Id extends PolicyToolId>(
toolId: Id,
params: PolicyParams<Id>,
) {
setTools((prev) =>
prev.map((tl) =>
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
),
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
);
}
@@ -254,8 +277,11 @@ function PolicySetupWizardBody({
}
setError(null);
setSubmitting(true);
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
// UI shape verbatim would drop those fields and the step would run with none.
const steps: PipelineStep[] = enabledTools.map((tl) =>
policyStepToWire(tl),
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
);
try {
await onSubmit(entry, {
@@ -360,16 +386,20 @@ function PolicySetupWizardBody({
<Card padding="none">
<div className="portal-policies__capabilities">
{tools.map((tl) => {
const meta = CAPABILITY_META[tl.toolId];
const meta = CAPABILITY_META[tl.operation];
const label = meta
? t(meta.labelKey, meta.labelEn)
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
: (registryByEndpoint.get(tl.operation)?.name ??
humanizeEndpoint(tl.operation, t));
const description = meta
? t(meta.descKey, meta.descEn)
: undefined;
const hasConfig =
tl.operation === TOOL_ENDPOINTS.redact ||
tl.operation === TOOL_ENDPOINTS.watermark;
return (
<div
key={tl.toolId}
key={tl.operation}
className="portal-policies__capability"
data-on={tl.enabled || undefined}
>
@@ -381,27 +411,27 @@ function PolicySetupWizardBody({
size="sm"
checked={tl.enabled}
onChange={(checked) =>
setToolEnabled(tl.toolId, checked)
patchTool(tl.operation, { enabled: checked })
}
label=""
/>
}
/>
{tl.enabled && (
{tl.enabled && hasConfig && (
<div className="portal-policies__capability-config">
{tl.toolId === "redact" && (
{tl.operation === TOOL_ENDPOINTS.redact && (
<PolicyRedactConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("redact", params)
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
}
/>
)}
{tl.toolId === "watermark" && (
{tl.operation === TOOL_ENDPOINTS.watermark && (
<PolicyWatermarkConfig
parameters={tl.params}
onChange={(params) =>
setToolParams("watermark", params)
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
}
/>
)}
@@ -445,8 +475,9 @@ function PolicySetupWizardBody({
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
// The editor is always an available source (unconditionally prepended
// to availableSources), so the list is never empty no "no sources"
// state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
@@ -1,28 +0,0 @@
import LocalIcon from "@app/components/shared/LocalIcon";
import type { FlowOutcomeKey } from "@portal/api/processorFlow";
import {
EDITOR_TYPE,
ICON_SIZE,
} from "@portal/components/processor-flow/flowTypes";
/** Real Material Symbols icon for a live source node, keyed off its `type`. */
export function SourceIcon({ type }: { type: string }) {
switch (type) {
case EDITOR_TYPE:
return <LocalIcon icon="edit-document" width={ICON_SIZE} />;
case "s3":
return <LocalIcon icon="cloud" width={ICON_SIZE} />;
case "folder":
return <LocalIcon icon="folder" width={ICON_SIZE} />;
default:
return <LocalIcon icon="database" width={ICON_SIZE} />;
}
}
/** Real Material Symbols icon for an audit outcome node. Literal icon names
* (not a ternary) so the icon extractor bundles them. */
export function OutcomeIcon({ outcome }: { outcome: FlowOutcomeKey }) {
if (outcome === "success")
return <LocalIcon icon="check-circle" width={ICON_SIZE} />;
return <LocalIcon icon="cancel" width={ICON_SIZE} />;
}
@@ -1,59 +0,0 @@
import { type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { FlowOutcome } from "@portal/api/processorFlow";
import { OutcomeIcon } from "@portal/components/processor-flow/FlowIcons";
interface FlowOutcomesProps {
outcomes: FlowOutcome[];
/** One ref slot per outcome, in order, for geometry measurement. */
outRefs: RefObject<(HTMLElement | null)[]>;
onOpen: () => void;
}
/** Right column: terminal audit outcomes (delivered / failed). */
export function FlowOutcomes({ outcomes, outRefs, onOpen }: FlowOutcomesProps) {
const { t } = useTranslation();
return (
<section
className="portal-pf__col portal-pf__col--outcomes"
aria-label={t("portal.processorFlow.outcomes.heading")}
>
<span className="portal-pf__col-head">
{t("portal.processorFlow.outcomes.heading")}
</span>
{outcomes.map((outcome, j) => (
<Button
key={outcome.key}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className={
"portal-pf__node portal-pf__node--outcome portal-pf__node--" +
outcome.key
}
onClick={onOpen}
ref={(el: HTMLButtonElement | null) => {
outRefs.current[j] = el;
}}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<OutcomeIcon outcome={outcome.key} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>{t(outcome.labelKey)}</strong>
<span>
{t("portal.processorFlow.outcomes.count", {
n: outcome.count24h,
})}
</span>
</span>
</Button>
))}
</section>
);
}
@@ -1,80 +0,0 @@
import { type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import LocalIcon from "@app/components/shared/LocalIcon";
import type { FlowPolicy } from "@portal/api/processorFlow";
import { ICON_SIZE } from "@portal/components/processor-flow/flowTypes";
interface FlowPoliciesProps {
policies: FlowPolicy[];
activeCount: number;
/** Ref for the core card (measured as the particle waist). */
coreRef: RefObject<HTMLDivElement | null>;
/** Per-policy lane-line refs, keyed by policy id, for particle threading. */
laneRefs: RefObject<Record<string, HTMLElement>>;
/** Deep-link into that policy's setup wizard. */
onSetup: (key: string) => void;
}
/** Centre column: the standing-policy catalogue — the particle "waist". */
export function FlowPolicies({
policies,
activeCount,
coreRef,
laneRefs,
onSetup,
}: FlowPoliciesProps) {
const { t } = useTranslation();
return (
<div className="portal-pf__policies" ref={coreRef}>
<div className="portal-pf__policies-head">
<span>{t("portal.processorFlow.policies.heading")}</span>
<span className="portal-pf__policies-active">
{t("portal.processorFlow.policies.activeCount", { n: activeCount })}
</span>
</div>
{policies.map((policy) => (
<div
key={policy.key}
className={"portal-pf__policy portal-pf__policy--" + policy.state}
>
<div
className="portal-pf__policy-line"
ref={(el: HTMLDivElement | null) => {
if (el) laneRefs.current[policy.key] = el;
else delete laneRefs.current[policy.key];
}}
>
<span className="portal-pf__policy-icon" aria-hidden>
<LocalIcon icon={policy.icon} width={ICON_SIZE} />
</span>
<span className="portal-pf__policy-label">
{t(policy.labelKey)}
</span>
{policy.state === "active" ? (
<span className="portal-pf__policy-count">
{t("portal.processorFlow.policies.count", {
n: policy.runs24h,
})}
</span>
) : policy.state === "off" ? (
<Button
size="sm"
py="xs"
variant="primary"
className="portal-pf__setup"
onClick={() => onSetup(policy.key)}
>
{t("portal.processorFlow.policies.setUp")}
</Button>
) : (
<span className="portal-pf__policy-soon">
{t("portal.processorFlow.policies.soon")}
</span>
)}
</div>
</div>
))}
</div>
);
}
@@ -1,237 +0,0 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@app/ui";
import type {
FlowOutcome,
FlowOutcomeKey,
FlowPolicy,
FlowSource,
} from "@portal/api/processorFlow";
import {
EDITOR_TYPE,
OUTCOME_FILL,
} from "@portal/components/processor-flow/flowTypes";
interface FlowSankeyProps {
sources: FlowSource[];
outcomes: FlowOutcome[];
policies: FlowPolicy[];
}
/**
* Sankey lens: sources → policies waist → outcomes, ribbon width ∝ 24h volume.
* The waist splits into one segment per active policy. Shows a friendly empty
* state when nothing has flowed yet.
*/
export function FlowSankey({ sources, outcomes, policies }: FlowSankeyProps) {
const { t } = useTranslation();
const activePolicies = policies.filter((p) => p.state === "active");
const activeCount = activePolicies.length;
const flows = sources.filter((s) => s.docs24h > 0);
const srcSum = flows.reduce((sum, s) => sum + s.docs24h, 0);
if (!srcSum) {
return (
<div className="portal-pf__sankey-empty">
<EmptyState
size="compact"
title={t("portal.processorFlow.sankey.empty.title")}
description={t("portal.processorFlow.sankey.empty.description")}
/>
</div>
);
}
const SW = 720;
const SH = 220;
const padY = 22;
const xL = 180;
const xR = 560;
const xM = (xL + xR) / 2;
const barW = 9;
const midW = 11;
const gap = 12;
const H = SH - padY * 2;
const k = (H - (flows.length - 1) * gap) / srcSum;
const lt = flows.map((s) => Math.max(3, s.docs24h * k));
const midH = lt.reduce((a, b) => a + b, 0);
const y0L = padY + (H - (midH + (flows.length - 1) * gap)) / 2;
const midY = padY + (H - midH) / 2;
const outSum = outcomes.reduce((a, o) => a + o.count24h, 0);
const rawRt = outcomes.map((o) =>
outSum > 0
? Math.max(3, midH * (o.count24h / outSum))
: midH / outcomes.length,
);
const rtSum = rawRt.reduce((a, b) => a + b, 0);
const rt = rawRt.map((v) => (v * midH) / rtSum);
const y0R = padY + (H - (midH + (outcomes.length - 1) * gap)) / 2;
const outFill = (key: FlowOutcomeKey) => OUTCOME_FILL[key];
const srcFill = "var(--color-blue)";
const waistFill = "var(--color-text-4)";
const ribbon = (
x0: number,
t0: number,
b0: number,
x1: number,
t1: number,
b1: number,
fill: string,
key: string,
) => {
const mx = (x0 + x1) / 2;
return (
<path
key={key}
d={`M ${x0} ${t0} C ${mx} ${t0}, ${mx} ${t1}, ${x1} ${t1} L ${x1} ${b1} C ${mx} ${b1}, ${mx} ${b0}, ${x0} ${b0} Z`}
style={{ fill }}
opacity={0.3}
/>
);
};
const wires: ReactNode[] = [];
const bars: ReactNode[] = [];
const texts: ReactNode[] = [];
// Left stage: sources → waist.
let accL = y0L;
let accM = midY;
flows.forEach((s, i) => {
wires.push(
ribbon(
xL + barW,
accL,
accL + lt[i],
xM,
accM,
accM + lt[i],
srcFill,
"wl" + i,
),
);
bars.push(
<rect
key={"bl" + i}
x={xL}
y={accL}
width={barW}
height={lt[i]}
rx={2}
style={{ fill: srcFill }}
opacity={0.9}
/>,
);
const label =
s.type === EDITOR_TYPE
? t("portal.processorFlow.sources.editor")
: s.name;
texts.push(
<text
key={"tl" + i}
x={xL - 10}
y={accL + lt[i] / 2 + 4}
textAnchor="end"
className="portal-pf__sankey-label"
>
{label} · {s.docs24h}
</text>,
);
accL += lt[i] + gap;
accM += lt[i];
});
// Waist: one segment per active policy (sized by its 24h runs) so the centre
// reads as distinct policies rather than a single bar.
const segGap = 4;
const nSeg = Math.max(activePolicies.length, 1);
const segAvail = midH - (nSeg - 1) * segGap;
const polSum = activePolicies.reduce((a, p) => a + p.runs24h, 0);
const segPolicies = activePolicies.length ? activePolicies : [null];
let segY = midY;
segPolicies.forEach((p, i) => {
const wgt = p && polSum > 0 ? p.runs24h / polSum : 1 / nSeg;
const h = Math.max(2, segAvail * wgt);
bars.push(
<rect
key={"waist" + i}
x={xM}
y={segY}
width={midW}
height={h}
rx={2}
style={{ fill: waistFill }}
opacity={0.9}
/>,
);
segY += h + segGap;
});
texts.push(
<text
key="waist-cap"
x={xM + midW / 2}
y={midY - 8}
textAnchor="middle"
className="portal-pf__sankey-caption"
>
{t("portal.processorFlow.sankey.waist", { n: activeCount })}
</text>,
);
// Right stage: waist → outcomes.
let accWaist = midY;
let accR = y0R;
outcomes.forEach((o, j) => {
wires.push(
ribbon(
xM + midW,
accWaist,
accWaist + rt[j],
xR,
accR,
accR + rt[j],
outFill(o.key),
"wr" + j,
),
);
bars.push(
<rect
key={"br" + j}
x={xR}
y={accR}
width={barW}
height={rt[j]}
rx={2}
style={{ fill: outFill(o.key) }}
opacity={0.9}
/>,
);
texts.push(
<text
key={"tr" + j}
x={xR + barW + 10}
y={accR + rt[j] / 2 + 4}
textAnchor="start"
className="portal-pf__sankey-label"
>
{t(o.labelKey)} · {o.count24h}
</text>,
);
accWaist += rt[j];
accR += rt[j] + gap;
});
return (
<div className="portal-pf__sankey">
<svg viewBox={`0 0 ${SW} ${SH}`} width="100%" aria-hidden>
{wires}
{bars}
{texts}
</svg>
</div>
);
}
@@ -1,94 +0,0 @@
import { type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import LocalIcon from "@app/components/shared/LocalIcon";
import type {
FlowComingSoonSource,
FlowSource,
} from "@portal/api/processorFlow";
import {
EDITOR_TYPE,
ICON_SIZE,
} from "@portal/components/processor-flow/flowTypes";
import { SourceIcon } from "@portal/components/processor-flow/FlowIcons";
interface FlowSourcesProps {
sources: FlowSource[];
comingSoonSources: FlowComingSoonSource[];
/** One ref slot per live source, in order, for geometry measurement. */
srcRefs: RefObject<(HTMLElement | null)[]>;
onOpen: () => void;
}
/** Left column: live source cards (measured) + coming-soon connect cards. */
export function FlowSources({
sources,
comingSoonSources,
srcRefs,
onOpen,
}: FlowSourcesProps) {
const { t } = useTranslation();
return (
<section
className="portal-pf__col portal-pf__col--sources"
aria-label={t("portal.processorFlow.sources.heading")}
>
<span className="portal-pf__col-head">
{t("portal.processorFlow.sources.heading")}
</span>
{sources.map((source, i) => (
<Button
key={source.id}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className="portal-pf__node portal-pf__node--source"
onClick={onOpen}
ref={(el: HTMLButtonElement | null) => {
srcRefs.current[i] = el;
}}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<SourceIcon type={source.type} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>
{source.type === EDITOR_TYPE
? t("portal.processorFlow.sources.editor")
: source.name}
</strong>
<span>
{t("portal.processorFlow.sources.perDay", { n: source.docs24h })}
</span>
</span>
</Button>
))}
{comingSoonSources.map((cs) => (
<Button
key={cs.key}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className="portal-pf__node portal-pf__node--soon"
onClick={onOpen}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<LocalIcon icon="add" width={ICON_SIZE} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>{t(cs.labelKey)}</strong>
<span>{t("portal.processorFlow.sources.comingSoonTag")}</span>
</span>
</Button>
))}
</section>
);
}
@@ -1,103 +0,0 @@
import type { FlowOutcomeKey } from "@portal/api/processorFlow";
/** Which lens the visualiser is showing. */
export type Lens = "flow" | "sankey";
/**
* DEV ONLY: force the flow animation on even when nothing is set up / no
* activity has taken place, using synthetic rates. Off by design — an idle
* machine shows no animated dots; flip to true only to preview the motion
* while iterating on an empty workspace.
*/
export const DEV_KEEP_FLOWING = false;
export const EDITOR_TYPE = "editor";
/** Emission tuning: particles/sec for a source ≈ rate / 86400 × SPEED. */
export const SPEED = 300;
/** Synthetic per-source rate used only while DEV_KEEP_FLOWING forces the flow. */
export const DEV_SYNTH_RATE = 320;
/** Hard cap on live particles (matches the reference). */
export const MAX_PARTICLES = 36;
/** No two dots leave the same source within this window (ms). */
export const MIN_EMIT_GAP = 200;
export const ICON_SIZE = "1.125rem";
/** SVG `fill` (a CSS property, so var() resolves per-theme) for each outcome. */
export const OUTCOME_FILL: Record<FlowOutcomeKey, string> = {
success: "var(--color-green)",
failed: "var(--color-red)",
};
/* ── Measured geometry ──────────────────────────────────────────────────── */
export interface Rect {
l: number;
r: number;
t: number;
b: number;
cy: number;
}
export interface Lane {
key: string;
cy: number;
el: HTMLElement;
}
export interface Geo {
w: number;
h: number;
srcs: (Rect | undefined)[];
outs: (Rect | undefined)[];
core: Rect | null;
lanes: Lane[];
}
export interface Point {
x: number;
y: number;
}
export interface Particle {
el: SVGCircleElement;
src: number;
out: number;
lane: string | null;
phase: 0 | 1 | 2;
t: number;
d0: number;
d1: number;
d2: number;
pulsed: boolean;
}
/** Cubic bézier point at t. */
export function cbez(a: Point, b: Point, c: Point, d: Point, t: number): Point {
const m = 1 - t;
return {
x:
m * m * m * a.x +
3 * m * m * t * b.x +
3 * m * t * t * c.x +
t * t * t * d.x,
y:
m * m * m * a.y +
3 * m * m * t * b.y +
3 * m * t * t * c.y +
t * t * t * d.y,
};
}
/** Smoothstep easing used for the in-card lane glide. */
export function smooth(t: number): number {
return t * t * (3 - 2 * t);
}
/** Where each source's wire enters the core (spread across its height). */
export function coreEntryY(g: Geo, i: number): number {
if (!g.core) return 0;
const n = Math.max(g.srcs.length, 2);
return g.core.t + 34 + (g.core.b - g.core.t - 68) * (i / (n - 1));
}
@@ -1,123 +0,0 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
coreEntryY,
type Geo,
type Rect,
} from "@portal/components/processor-flow/flowTypes";
/**
* Owns the measured-geometry seam for the flow visualiser: refs for the source,
* outcome and core cards (plus the per-policy lane lines), a `measure()` that
* projects their edges into a wrapper-relative {@link Geo}, and the SVG wires
* drawn between them. The particle loop reads the same `geoRef` live.
*
* Callers spread the returned refs onto the cards and render `wires` inside the
* underlay `<svg>`; geometry re-measures on every layout + on resize, and the
* wires re-render only when the measured signature actually changes.
*/
export function useFlowGeometry() {
const wrapRef = useRef<HTMLDivElement>(null);
const srcRefs = useRef<(HTMLElement | null)[]>([]);
const outRefs = useRef<(HTMLElement | null)[]>([]);
const coreRef = useRef<HTMLDivElement>(null);
const laneRefs = useRef<Record<string, HTMLElement>>({});
const geoRef = useRef<Geo | null>(null);
const geoSigRef = useRef("");
const [, setGeoTick] = useState(0);
const measure = () => {
const w = wrapRef.current;
if (!w) return;
const wr = w.getBoundingClientRect();
if (!wr.width) return;
const rel = (r: DOMRect): Rect => ({
l: r.left - wr.left,
r: r.right - wr.left,
t: r.top - wr.top,
b: r.bottom - wr.top,
cy: r.top - wr.top + r.height / 2,
});
const g: Geo = {
w: wr.width,
h: wr.height,
srcs: [],
outs: [],
core: null,
lanes: [],
};
srcRefs.current.forEach((el, i) => {
if (el) g.srcs[i] = rel(el.getBoundingClientRect());
});
outRefs.current.forEach((el, j) => {
if (el) g.outs[j] = rel(el.getBoundingClientRect());
});
if (coreRef.current) g.core = rel(coreRef.current.getBoundingClientRect());
Object.entries(laneRefs.current).forEach(([key, el]) => {
if (el && el.isConnected)
g.lanes.push({ key, cy: rel(el.getBoundingClientRect()).cy, el });
});
geoRef.current = g;
let cySum = 0;
g.srcs.forEach((s) => s && (cySum += s.cy));
g.outs.forEach((o) => o && (cySum += o.cy));
const sig = [
Math.round(g.w),
Math.round(g.h),
g.srcs.length,
g.outs.length,
g.core ? Math.round(g.core.t) + ":" + Math.round(g.core.b) : 0,
Math.round(cySum),
].join(":");
if (sig !== geoSigRef.current) {
geoSigRef.current = sig;
setGeoTick((n) => n + 1);
}
};
useLayoutEffect(measure);
useEffect(() => {
const onResize = () => measure();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Wires (SVG underlay); recomputed whenever the geometry signature changes.
const g = geoRef.current;
let wires: ReactNode = null;
if (g && g.core) {
const core = g.core;
const paths: ReactNode[] = [];
g.srcs.forEach((s, i) => {
if (!s) return;
const ty = coreEntryY(g, i);
paths.push(
<path
key={"ws" + i}
className="portal-pf__wire-path"
d={`M ${s.r} ${s.cy} C ${s.r + 44} ${s.cy}, ${core.l - 44} ${ty}, ${core.l} ${ty}`}
/>,
);
});
g.outs.forEach((o, j) => {
if (!o) return;
paths.push(
<path
key={"wo" + j}
className="portal-pf__wire-path"
d={`M ${core.r} ${o.cy} C ${core.r + 44} ${o.cy}, ${o.l - 44} ${o.cy}, ${o.l} ${o.cy}`}
/>,
);
});
wires = paths;
}
return { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires };
}
@@ -1,261 +0,0 @@
import { useEffect, useRef, type RefObject } from "react";
import type { FlowOutcomeKey } from "@portal/api/processorFlow";
import {
cbez,
coreEntryY,
smooth,
MAX_PARTICLES,
MIN_EMIT_GAP,
OUTCOME_FILL,
SPEED,
type Geo,
type Lens,
type Particle,
type Point,
} from "@portal/components/processor-flow/flowTypes";
interface FlowParticlesOptions {
geoRef: RefObject<Geo | null>;
animate: boolean;
lens: Lens;
/** Per-source emission rate (docs/24h, or synthetic while dev-forcing). */
rates: number[];
/** Outcome share for the weighted round-robin destination picker. */
weights: number[];
/** Policy lane keys a dot may thread through the core. */
laneKeys: string[];
/** Outcome keys, index-aligned with `weights`, for recolouring on arrival. */
outcomeKeys: FlowOutcomeKey[];
}
/**
* Drives the rAF particle loop: emits dots per source on a jittered schedule
* (min-gap floored), routes each to an outcome via a weighted round-robin so
* the split matches the counts, threads it through a policy lane (blinking that
* row's LED), and recolours it to the outcome on arrival. Reads geometry live
* from `geoRef`, so it tracks card movement without restarting.
*
* Returns the `<g>` ref the caller mounts inside the particle overlay `<svg>`.
* The loop only runs on the flow lens, when `animate` is set, and outside
* reduced-motion; browsers pause rAF for hidden tabs (desirable).
*/
export function useFlowParticles({
geoRef,
animate,
lens,
rates,
weights,
laneKeys,
outcomeKeys,
}: FlowParticlesOptions): RefObject<SVGGElement | null> {
const pGroupRef = useRef<SVGGElement>(null);
// Restart the loop only when the meaningful inputs change.
const flowSig = [
animate,
lens,
rates.join(","),
laneKeys.join(","),
weights.map((w) => w.toFixed(3)).join(","),
outcomeKeys.join(","),
].join("|");
useEffect(() => {
if (!animate || lens !== "flow") return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) return;
const pg = pGroupRef.current;
if (!pg) return;
const NS = "http://www.w3.org/2000/svg";
const particles: Particle[] = [];
let last = performance.now();
let raf = 0;
const glowTimers: Record<string, ReturnType<typeof setTimeout>> = {};
// Per-source emission schedule. Mean interval keeps each source's share of
// the flow proportional to its rate; the scheduled model (vs. a steady
// accumulator) is what lets us jitter departures and floor the gap.
const meanInterval = rates.map((r) => {
const perSec = (r / 86400) * SPEED;
return perSec > 0 ? 1000 / perSec : Infinity;
});
// Stagger the first emission so sources don't all fire together at t=0.
const nextEmit = meanInterval.map((mi) =>
Number.isFinite(mi) ? last + Math.random() * mi : Infinity,
);
// Random departure within [0.5×, 1.5×] the mean, but never closer than the
// minimum gap — a random flow with no two dots out at once per source.
const scheduleNext = (i: number, now: number): number =>
now + Math.max(MIN_EMIT_GAP, meanInterval[i] * (0.5 + Math.random()));
// Weighted round-robin so the outcome split visibly matches the counts
// (e.g. 3 failed / 30 delivered → ~1 in 11 dots to Failed), interleaved
// rather than clustered like independent random draws.
const outAcc = weights.map(() => 0);
const pickOut = (): number => {
if (!weights.length) return 0;
for (let j = 0; j < weights.length; j++) outAcc[j] += weights[j];
let best = 0;
for (let j = 1; j < weights.length; j++) {
if (outAcc[j] > outAcc[best]) best = j;
}
outAcc[best] -= 1;
return best;
};
const pickLane = (): string | null => {
if (!laneKeys.length) return null;
return laneKeys[Math.floor(Math.random() * laneKeys.length)];
};
const laneY = (g: Geo, key: string | null): number | null => {
if (!key) return null;
const l = g.lanes.find((x) => x.key === key);
return l ? l.cy : null;
};
// Blink the row's leading LED (its icon) for 150ms as a particle threads it.
const pulseLane = (g: Geo, key: string | null) => {
if (!key) return;
const lane = g.lanes.find((x) => x.key === key);
const led = lane?.el.firstElementChild;
if (!led || !led.classList.contains("portal-pf__policy-icon")) return;
led.classList.add("is-pulse");
if (glowTimers[key]) clearTimeout(glowTimers[key]);
glowTimers[key] = setTimeout(() => led.classList.remove("is-pulse"), 150);
};
const frame = (now: number) => {
const g = geoRef.current;
const dt = Math.min(now - last, 200);
last = now;
if (g && g.core) {
// At most one dot per source per frame, once its scheduled (jittered)
// departure time is reached — guarantees the per-source minimum gap.
for (let i = 0; i < meanInterval.length; i++) {
if (!Number.isFinite(meanInterval[i]) || !g.srcs[i]) continue;
if (now >= nextEmit[i] && particles.length < MAX_PARTICLES) {
const c = document.createElementNS(
NS,
"circle",
) as SVGCircleElement;
c.setAttribute("r", "2.5");
c.setAttribute("opacity", "0.75");
c.style.fill = "var(--color-blue)";
pg.appendChild(c);
particles.push({
el: c,
src: i,
out: pickOut(),
lane: pickLane(),
phase: 0,
t: 0,
d0: 900 + Math.random() * 300,
d1: 760,
d2: 780 + Math.random() * 200,
pulsed: false,
});
nextEmit[i] = scheduleNext(i, now);
}
}
for (let k = particles.length - 1; k >= 0; k--) {
const p = particles[k];
p.t += dt;
const s = g.srcs[p.src];
if (!s) {
p.el.remove();
particles.splice(k, 1);
continue;
}
let pos: Point;
if (p.phase === 0) {
const ey = coreEntryY(g, p.src);
const f0 = Math.min(1, p.t / p.d0);
pos = cbez(
{ x: s.r, y: s.cy },
{ x: s.r + 44, y: s.cy },
{ x: g.core.l - 44, y: ey },
{ x: g.core.l, y: ey },
f0,
);
if (f0 >= 1) {
p.phase = 1;
p.t = 0;
p.pulsed = false;
p.el.setAttribute("r", "2");
p.el.setAttribute("opacity", "0.45");
}
} else if (p.phase === 1) {
const o1 = g.outs[p.out];
if (!o1) {
p.el.remove();
particles.splice(k, 1);
continue;
}
const f1 = Math.min(1, p.t / p.d1);
const entY = coreEntryY(g, p.src);
const exitY = o1.cy;
const ly1 = laneY(g, p.lane);
let yy: number;
if (ly1 == null) {
yy = entY + (exitY - entY) * f1;
} else if (f1 < 0.25) {
yy = entY + (ly1 - entY) * smooth(f1 / 0.25);
} else if (f1 < 0.75) {
yy = ly1;
if (!p.pulsed) {
p.pulsed = true;
pulseLane(g, p.lane);
}
} else {
yy = ly1 + (exitY - ly1) * smooth((f1 - 0.75) / 0.25);
}
pos = { x: g.core.l + (g.core.r - g.core.l) * f1, y: yy };
if (f1 >= 1) {
p.phase = 2;
p.t = 0;
p.el.style.fill =
OUTCOME_FILL[outcomeKeys[p.out]] ?? "var(--color-blue)";
p.el.setAttribute("r", "2.5");
p.el.setAttribute("opacity", "0.75");
}
} else {
const o2 = g.outs[p.out];
if (!o2) {
p.el.remove();
particles.splice(k, 1);
continue;
}
const f2 = Math.min(1, p.t / p.d2);
pos = cbez(
{ x: g.core.r, y: o2.cy },
{ x: g.core.r + 44, y: o2.cy },
{ x: o2.l - 44, y: o2.cy },
{ x: o2.l, y: o2.cy },
f2,
);
if (f2 >= 1) {
p.el.remove();
particles.splice(k, 1);
continue;
}
}
p.el.setAttribute("cx", String(pos.x));
p.el.setAttribute("cy", String(pos.y));
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
Object.values(glowTimers).forEach(clearTimeout);
while (pg.firstChild) pg.removeChild(pg.firstChild);
};
}, [flowSig, geoRef]);
return pGroupRef;
}
@@ -20,10 +20,6 @@ export interface NavEntry {
externalUrl?: string;
}
// Developer docs has no built-in portal page yet, so the tab opens the hosted docs
// site in a new tab rather than routing to an empty page.
const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/";
// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to
// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups).
export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: <HomeIcon /> }];
@@ -40,5 +36,5 @@ export const GROUP_OPERATIONAL: NavEntry[] = [
export const GROUP_PLATFORM: NavEntry[] = [
{ id: "infrastructure", icon: <InfrastructureIcon /> },
{ id: "usage", icon: <UsageIcon /> },
{ id: "docs", icon: <DocsIcon />, externalUrl: DEVELOPER_DOCS_URL },
{ id: "docs", icon: <DocsIcon /> },
];
@@ -30,7 +30,7 @@ export const VIEW_LABELS: Record<ViewId, string> = {
components: "Components",
infrastructure: "Infrastructure",
usage: "Usage & Billing",
docs: "Developer Docs",
docs: "Documentation",
procurement: "Procurement",
settings: "Settings",
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { extractHeadings, slugify } from "@portal/docs/headings";
describe("slugify", () => {
it("lowercases, hyphenates, and trims punctuation", () => {
expect(slugify("How it Works!")).toBe("how-it-works");
expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces");
});
});
describe("extractHeadings", () => {
it("extracts H2/H3 only, with slugs matching the rendered ids", () => {
const md = [
"# Page Title",
"## How it Works",
"text",
"### Sub Section",
"#### Too Deep",
"## Operations",
].join("\n");
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "How it Works", slug: "how-it-works" },
{ level: 3, text: "Sub Section", slug: "sub-section" },
{ level: 2, text: "Operations", slug: "operations" },
]);
});
it("de-duplicates repeated heading text into unique slugs", () => {
const md = ["## What Changed", "### What Changed", "## What Changed"].join(
"\n",
);
expect(extractHeadings(md).map((h) => h.slug)).toEqual([
"what-changed",
"what-changed-1",
"what-changed-2",
]);
});
it("ignores headings inside fenced code and strips inline marks", () => {
const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join(
"\n",
);
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "Code and em", slug: "code-and-em" },
]);
});
});
@@ -0,0 +1,48 @@
/**
* Heading extraction for the "On this page" table of contents. The same
* `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links
* and the rendered heading ids always match.
*/
export interface Heading {
/** 2 or 3 (H2/H3). */
level: number;
text: string;
slug: string;
}
/** "How it Works!" → "how-it-works" (the base id, before de-duplication). */
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* A stateful slugger that de-duplicates: repeated heading text gets `-1`, `-2`, …
* The TOC extraction and MarkdownDoc's heading renderer each make one and feed it
* headings in document order, so their slugs (and thus link ↔ id) always match.
*/
export function makeSlugger(): (text: string) => string {
const seen = new Map<string, number>();
return (text: string) => {
const base = slugify(text) || "section";
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return n === 0 ? base : `${base}-${n}`;
};
}
/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */
export function extractHeadings(markdown: string): Heading[] {
const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, "");
const slug = makeSlugger();
const headings: Heading[] = [];
for (const m of noCode.matchAll(/^ {0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) {
const text = m[2].replace(/[`*_]/g, "").trim();
if (text) headings.push({ level: m[1].length, text, slug: slug(text) });
}
return headings;
}
@@ -0,0 +1,38 @@
/**
* Runtime accessors over the generated docs manifest. This is the only module
* that imports the (large) JSON, so lazy-loading the docs view keeps it in its
* own chunk. Regenerate the JSON with `npm run docs:sync`.
*/
// Imported as a raw string (not a JSON module) so tsc doesn't infer a ~half-MB
// literal type; parsed once here into the typed manifest.
import manifestRaw from "@portal/generated/docsManifest.json?raw";
import type {
DocEntry,
DocsManifest,
DocsNavSection,
} from "@portal/docs/manifest/transform";
const manifest = JSON.parse(manifestRaw) as DocsManifest;
/** Provenance of the current manifest (repo + ref it was generated from). */
export const docsSource = manifest.source;
/** The auto-sorted nav tree (sections → items). */
export function loadDocsNav(): DocsNavSection[] {
return manifest.nav;
}
/** A single doc by id, or undefined if it isn't in the manifest. */
export function loadDoc(id: string): DocEntry | undefined {
return manifest.docs[id];
}
/** Every doc, for building the search index. */
export function allDocs(): DocEntry[] {
return Object.values(manifest.docs);
}
/** The first doc id (first item of the first section) — the default landing. */
export function firstDocId(): string | undefined {
return manifest.nav[0]?.items[0]?.id;
}
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import {
buildManifest,
convertAdmonitions,
demoteHeadings,
docIdForPath,
humanize,
parseFrontmatter,
resolveRelative,
rewriteReferences,
sectionIcon,
stripJsxTags,
stripMdxImports,
stripRedundantH1,
type CategoryMap,
type RawDoc,
} from "@portal/docs/manifest/transform";
const OPTS = {
repo: "Owner/Repo",
ref: "main",
root: "docs",
siteBaseUrl: "https://docs.example.com",
};
describe("parseFrontmatter", () => {
it("splits scalar YAML frontmatter from the body", () => {
const { data, body } = parseFrontmatter(
"---\ntitle: OCR\nsidebar_position: 7\n---\n# Heading\ntext",
);
expect(data.title).toBe("OCR");
expect(data.sidebar_position).toBe(7);
expect(body).toBe("# Heading\ntext");
});
it("returns the whole content as body when there is no frontmatter", () => {
const { data, body } = parseFrontmatter("# Just a doc\nbody");
expect(data).toEqual({});
expect(body).toBe("# Just a doc\nbody");
});
it("normalises CRLF line endings", () => {
const { data } = parseFrontmatter("---\r\nid: x\r\n---\r\nbody");
expect(data.id).toBe("x");
});
});
describe("id + label helpers", () => {
it("slugifies nested paths", () => {
expect(docIdForPath("Configuration/OCR.md")).toBe("configuration/ocr");
expect(docIdForPath("Getting Started.md")).toBe("getting-started");
});
it("humanises file/dir names", () => {
expect(humanize("Getting-Started.md")).toBe("Getting Started");
});
it("picks a section icon from the label", () => {
expect(sectionIcon("Configuration")).toBe("⚙");
expect(sectionIcon("Totally Unknown")).toBe("◇");
});
});
describe("MDX normalisation", () => {
it("converts admonitions to titled blockquotes", () => {
const out = convertAdmonitions(":::tip Upgrading?\nread this\n:::");
expect(out).toContain("> **💡 Tip: Upgrading?**");
expect(out).toContain("> read this");
});
it("strips import/export statements", () => {
const out = stripMdxImports("import Tabs from '@theme/Tabs';\n# Keep");
expect(out).toBe("# Keep");
});
it("removes JSX component tags but keeps inner content", () => {
expect(
stripJsxTags("<Tabs>\n<TabItem value='a'>keep</TabItem>\n</Tabs>"),
).toContain("keep");
expect(stripJsxTags("<TabItem>x</TabItem>")).not.toMatch(/<TabItem/);
});
it("demotes body H1 to H2 but leaves code comments alone", () => {
const md = "# Title\n\n```bash\n# a shell comment\n```";
const out = demoteHeadings(md);
expect(out).toContain("## Title");
expect(out).toContain("# a shell comment");
});
it("strips a leading H1 that duplicates the page title", () => {
expect(stripRedundantH1("# OCR\nbody", "OCR")).toBe("body");
expect(stripRedundantH1("# Other\nbody", "OCR")).toBe("# Other\nbody");
});
});
describe("resolveRelative", () => {
it("collapses ./ and ../ against a base dir", () => {
expect(resolveRelative("Configuration", "./OCR.md")).toBe(
"Configuration/OCR.md",
);
expect(
resolveRelative("Configuration", "../Functionality/Compare.md"),
).toBe("Functionality/Compare.md");
});
});
describe("rewriteReferences", () => {
const ctx = {
dir: "Configuration",
// Keys are lowercased file paths (spaces preserved), as buildManifest builds them.
pathToId: new Map([
[
"configuration/system and security",
"configuration/system-and-security",
],
]),
rawBase: "https://raw.example.com/Owner/Repo/main",
siteBaseUrl: "https://docs.example.com",
};
it("rewrites resolvable internal links to the doc: scheme (decoded + case-insensitive)", () => {
const out = rewriteReferences(
"see [sec](./System%20and%20Security.md)",
ctx,
"docs",
);
expect(out).toBe("see [sec](doc:configuration/system-and-security)");
});
it("falls back to the live docs site for unresolved internal links", () => {
const out = rewriteReferences("[x](./Missing.md)", ctx, "docs");
expect(out).toBe("[x](https://docs.example.com/Configuration/Missing)");
});
it("leaves absolute and anchor links untouched", () => {
const md = "[a](https://x.com) and [b](#top)";
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
});
it("rewrites relative images to absolute raw URLs", () => {
expect(rewriteReferences("![a](./img/x.png)", ctx, "docs")).toBe(
"![a](https://raw.example.com/Owner/Repo/main/docs/Configuration/img/x.png)",
);
expect(rewriteReferences("![a](/img/y.png)", ctx, "docs")).toBe(
"![a](https://raw.example.com/Owner/Repo/main/static/img/y.png)",
);
});
it("does not rewrite inside fenced code blocks", () => {
const md = "```\n[x](./y.md)\n```";
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
});
});
describe("buildManifest", () => {
const rawDocs: RawDoc[] = [
{
relPath: "Getting Started.md",
content: "---\nsidebar_position: 0\n---\nintro",
},
{
relPath: "Configuration/OCR.md",
content: "---\ntitle: OCR\nsidebar_position: 7\n---\n# OCR\nbody",
},
{
relPath: "Configuration/DATABASE.md",
content: "---\nsidebar_position: 1\n---\n# Database\nsee [ocr](./OCR.md)",
},
];
const categories: CategoryMap = {
Configuration: { label: "Configuration", position: 5 },
};
it("auto-sorts sections (root Overview first, then by category position)", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.nav.map((s) => s.id)).toEqual(["overview", "configuration"]);
expect(m.nav[0].label).toBe("Overview");
expect(m.nav[1].label).toBe("Configuration");
});
it("orders nav items by sidebar_position", () => {
const m = buildManifest(rawDocs, categories, OPTS);
const config = m.nav.find((s) => s.id === "configuration")!;
expect(config.items.map((i) => i.id)).toEqual([
"configuration/database",
"configuration/ocr",
]);
});
it("derives titles from frontmatter, heading, then filename", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.docs["configuration/ocr"].title).toBe("OCR");
expect(m.docs["getting-started"].title).toBe("Getting Started");
});
it("resolves cross-doc links and records source/edit urls", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.docs["configuration/database"].markdown).toContain(
"[ocr](doc:configuration/ocr)",
);
expect(m.docs["configuration/ocr"].sourcePath).toBe(
"docs/Configuration/OCR.md",
);
expect(m.docs["configuration/ocr"].editUrl).toBe(
"https://github.com/Owner/Repo/blob/main/docs/Configuration/OCR.md",
);
});
});
@@ -0,0 +1,481 @@
/**
* Pure transforms that turn the Docusaurus docs repo into the portal docs
* manifest. No I/O and no external deps so `tsx` (the sync CLI) and vitest can
* both use it. The sync CLI does the fetching; this module does the shaping.
*
* The auto-sort rules:
* - every directory that directly holds markdown becomes a nav section,
* labelled + ordered by its `_category_.json` (root files → "Overview"),
* - each `.md`/`.mdx` file becomes a nav item, ordered by frontmatter
* `sidebar_position` then title,
* - Docusaurus MDX is normalised to plain GitHub-flavoured markdown that
* react-markdown can render (admonitions, JSX, relative links, images).
*/
/* ──────────────────────────────────────────────────────────────────────── */
/* Manifest shape (mirrored structurally by @portal/api/docs) */
/* ──────────────────────────────────────────────────────────────────────── */
export interface DocsNavItem {
id: string;
label: string;
badge?: string;
}
export interface DocsNavSection {
id: string;
label: string;
icon: string;
items: DocsNavItem[];
}
export interface DocEntry {
id: string;
title: string;
description?: string;
section: string;
markdown: string;
sourcePath: string;
editUrl: string;
}
export interface DocsManifest {
source: { repo: string; ref: string; root: string };
nav: DocsNavSection[];
docs: Record<string, DocEntry>;
}
/** One markdown file read from the repo, before shaping. */
export interface RawDoc {
/** Posix path relative to the docs root, e.g. "Configuration/OCR.md". */
relPath: string;
content: string;
}
/** `_category_.json` contents, keyed by posix dir path relative to docs root. */
export type CategoryMap = Record<string, { label?: string; position?: number }>;
export interface BuildOptions {
repo: string;
ref: string;
/** Docs root within the repo, e.g. "docs". */
root: string;
/** Live docs site base, used as the fallback for unresolved internal links. */
siteBaseUrl: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Small helpers */
/* ──────────────────────────────────────────────────────────────────────── */
const SECTION_ICONS: Array<[RegExp, string]> = [
[/overview|getting started|start/i, "▶"],
[/config/i, "⚙"],
[/function|tool|feature/i, "▤"],
[/install|deploy/i, "⤓"],
[/migrat|upgrade/i, "⇄"],
[/security|sign|auth/i, "🛡"],
[/convert/i, "⇋"],
[/page/i, "▦"],
[/api|develop/i, "{ }"],
];
/** A single-glyph icon for a section, chosen from its label. */
export function sectionIcon(label: string): string {
for (const [re, glyph] of SECTION_ICONS) if (re.test(label)) return glyph;
return "◇";
}
/** "Getting-Started_Guide" → "Getting Started Guide". */
export function humanize(name: string): string {
return name
.replace(/\.mdx?$/i, "")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Lowercase, hyphenated, url-safe id for a path segment. */
export function slugifySegment(name: string): string {
return name
.replace(/\.mdx?$/i, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** Docs-root-relative posix path → stable doc id, e.g. "configuration/ocr". */
export function docIdForPath(relPath: string): string {
return relPath.split("/").map(slugifySegment).filter(Boolean).join("/");
}
/** Posix dirname ("" for a root-level file). */
export function dirOf(relPath: string): string {
const i = relPath.lastIndexOf("/");
return i === -1 ? "" : relPath.slice(0, i);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Frontmatter */
/* ──────────────────────────────────────────────────────────────────────── */
export interface Frontmatter {
data: Record<string, string | number>;
body: string;
}
/** Split leading `--- ... ---` YAML frontmatter (scalar keys only) from body. */
export function parseFrontmatter(content: string): Frontmatter {
const normalised = content.replace(/\r\n/g, "\n");
if (!normalised.startsWith("---\n")) return { data: {}, body: normalised };
const end = normalised.indexOf("\n---", 4);
if (end === -1) return { data: {}, body: normalised };
const raw = normalised.slice(4, end);
const rest = normalised.slice(end + 4).replace(/^\n/, "");
const data: Record<string, string | number> = {};
for (const line of raw.split("\n")) {
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
if (!m) continue;
let value: string | number = m[2].trim().replace(/^["']|["']$/g, "");
if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value);
data[m[1]] = value;
}
return { data, body: rest };
}
/** First `# H1` heading text in a body, if any. */
export function firstHeading(body: string): string | undefined {
const m = /^#\s+(.+?)\s*$/m.exec(stripCodeFences(body));
return m ? m[1].trim() : undefined;
}
/** Blank out fenced code blocks so heading/link scans ignore their contents. */
function stripCodeFences(md: string): string {
return md.replace(/^(```|~~~)[\s\S]*?^\1\s*$/gm, "");
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Body normalisation (MDX → plain markdown) */
/* ──────────────────────────────────────────────────────────────────────── */
/** Run `fn` over the non-fenced-code spans of `md`, leaving code blocks intact. */
function mapOutsideCode(md: string, fn: (text: string) => string): string {
const parts = md.split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)\s*$)/gm);
return parts.map((part, i) => (i % 2 === 0 ? fn(part) : part)).join("");
}
const ADMONITION_META: Record<string, { icon: string; label: string }> = {
tip: { icon: "💡", label: "Tip" },
note: { icon: "📝", label: "Note" },
info: { icon: "️", label: "Info" },
warning: { icon: "⚠️", label: "Warning" },
caution: { icon: "⚠️", label: "Caution" },
danger: { icon: "🚫", label: "Danger" },
};
/** `:::tip Title\n…\n:::` → a blockquote with a bold titled first line. */
export function convertAdmonitions(md: string): string {
const re = /^:::(\w+)[ \t]*(.*)\n([\s\S]*?)^:::[ \t]*$/gm;
return md.replace(re, (_all, type: string, title: string, body: string) => {
const meta = ADMONITION_META[type.toLowerCase()] ?? {
icon: "•",
label: humanize(type),
};
const heading = title.trim()
? `${meta.label}: ${title.trim()}`
: meta.label;
const quoted = body
.replace(/\s+$/, "")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"))
.join("\n");
return `> **${meta.icon} ${heading}**\n>\n${quoted}\n`;
});
}
/** Drop MDX `import`/`export` statement lines (outside code). */
export function stripMdxImports(md: string): string {
return md
.split("\n")
.filter((l) => !/^\s*(import|export)\s.+from\s.+;?\s*$/.test(l))
.filter((l) => !/^\s*import\s+['"][^'"]+['"];?\s*$/.test(l))
.join("\n");
}
/** Remove JSX component tags (Capitalised), keeping any inner content. */
export function stripJsxTags(md: string): string {
return md.replace(/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*?)?\/?>/g, "");
}
/** Resolve a relative posix path against a base dir, collapsing `.`/`..`. */
export function resolveRelative(baseDir: string, target: string): string {
const stack = baseDir ? baseDir.split("/") : [];
for (const seg of target.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") stack.pop();
else stack.push(seg);
}
return stack.join("/");
}
interface LinkContext {
dir: string;
pathToId: Map<string, string>;
rawBase: string;
siteBaseUrl: string;
}
/** Split "path#anchor" → [path, "#anchor" | ""]. */
function splitAnchor(target: string): [string, string] {
const i = target.indexOf("#");
return i === -1 ? [target, ""] : [target.slice(0, i), target.slice(i)];
}
/** Percent-decode a link path, tolerating malformed escapes. */
function decodePath(p: string): string {
try {
return decodeURIComponent(p);
} catch {
return p;
}
}
/**
* Look up the doc id for a relative link target. Keys in pathToId are lowercased
* so links work whether they use the filename or a slug, in any case, and with
* percent-encoded spaces (the repo links to "System%20and%20Security").
*/
function resolveDocId(ctx: LinkContext, rawPath: string): string | undefined {
const resolved = resolveRelative(ctx.dir, decodePath(rawPath)).toLowerCase();
const noExt = resolved.replace(/\.mdx?$/i, "");
const candidates = [
resolved,
noExt,
`${noExt.replace(/\/$/, "")}/index`,
noExt.replace(/\/index$/i, ""),
];
for (const c of candidates) {
const id = ctx.pathToId.get(c);
if (id) return id;
}
return undefined;
}
/** Rewrite a single markdown link target to a portal-usable href. */
function rewriteLinkTarget(ctx: LinkContext, target: string): string {
const trimmed = target.trim();
if (/^(https?:|mailto:|tel:|#|doc:)/i.test(trimmed)) return trimmed;
const [path, anchor] = splitAnchor(trimmed);
if (!path) return trimmed;
const id = resolveDocId(ctx, path);
if (id) return `doc:${id}`;
// Unresolved internal link → fall back to the live docs site (encode spaces).
const slug = resolveRelative(ctx.dir, decodePath(path)).replace(
/\.mdx?$/i,
"",
);
const encoded = slug.split("/").map(encodeURIComponent).join("/");
return `${ctx.siteBaseUrl}/${encoded}${anchor}`;
}
/** Rewrite a relative image src to an absolute raw-content URL. */
function rewriteImageSrc(ctx: LinkContext, src: string, root: string): string {
const trimmed = src.trim();
if (/^(https?:|data:)/i.test(trimmed)) return trimmed;
if (trimmed.startsWith("/")) return `${ctx.rawBase}/static${trimmed}`;
const resolved = resolveRelative(`${root}/${ctx.dir}`, trimmed);
return `${ctx.rawBase}/${resolved}`;
}
/** Rewrite markdown links + images (outside code) to portal/absolute targets. */
export function rewriteReferences(
md: string,
ctx: LinkContext,
root: string,
): string {
return mapOutsideCode(md, (text) => {
// Images first so their `!` prefix isn't eaten by the link pattern.
let out = text.replace(
/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g,
(_m, alt: string, src: string, tail: string) =>
`![${alt}](${rewriteImageSrc(ctx, src, root)}${tail})`,
);
out = out.replace(
/(^|[^!])\[([^\]]+)\]\(([^)\s]+)([^)]*)\)/g,
(_m, pre: string, label: string, href: string, tail: string) =>
`${pre}[${label}](${rewriteLinkTarget(ctx, href)}${tail})`,
);
return out;
});
}
/** Drop a leading `# H1` whose text equals the page title (avoids a dup head). */
export function stripRedundantH1(md: string, title: string): string {
const m = /^\s*#\s+(.+?)\s*(\n|$)/.exec(md);
if (m && m[1].trim().toLowerCase() === title.trim().toLowerCase()) {
return md.slice(m[0].length).replace(/^\n+/, "");
}
return md;
}
/** Demote body `# H1` headings to `## H2` so the page title is the sole H1. */
export function demoteHeadings(md: string): string {
return mapOutsideCode(md, (text) => text.replace(/^# (?=\S)/gm, "## "));
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Section ordering */
/* ──────────────────────────────────────────────────────────────────────── */
const ROOT_SECTION_ID = "overview";
/** Composite order key: `_category_.json.position` down the dir path. */
function sectionOrderKey(dir: string, categories: CategoryMap): number[] {
if (dir === "") return [-1];
const key: number[] = [];
const segs = dir.split("/");
for (let i = 0; i < segs.length; i++) {
const sub = segs.slice(0, i + 1).join("/");
key.push(categories[sub]?.position ?? 999);
}
return key;
}
function compareKeys(a: number[], b: number[]): number {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = (a[i] ?? 0) - (b[i] ?? 0);
if (d !== 0) return d;
}
return 0;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Build */
/* ──────────────────────────────────────────────────────────────────────── */
interface ShapedDoc extends DocEntry {
navLabel: string;
sidebarPosition: number;
orderKey: number[];
sectionLabel: string;
}
/** Turn raw docs + category metadata into the full portal docs manifest. */
export function buildManifest(
rawDocs: RawDoc[],
categories: CategoryMap,
opts: BuildOptions,
): DocsManifest {
const rawBase = `https://raw.githubusercontent.com/${opts.repo}/${opts.ref}`;
const editBase = `https://github.com/${opts.repo}/blob/${opts.ref}`;
// First pass: assign stable ids so links between docs can resolve. Keys are
// lowercased (case-insensitive link matching); both with and without ext.
const pathToId = new Map<string, string>();
for (const doc of rawDocs) {
const id = docIdForPath(doc.relPath);
const lower = doc.relPath.toLowerCase();
pathToId.set(lower, id);
pathToId.set(lower.replace(/\.mdx?$/i, ""), id);
}
const shaped: ShapedDoc[] = rawDocs.map((doc) => {
const dir = dirOf(doc.relPath);
const { data, body } = parseFrontmatter(doc.content);
const title =
(typeof data.title === "string" && data.title) ||
firstHeading(body) ||
humanize(doc.relPath.split("/").pop() ?? doc.relPath);
const navLabel =
(typeof data.sidebar_label === "string" && data.sidebar_label) || title;
const description =
typeof data.description === "string" ? data.description : undefined;
const ctx: LinkContext = {
dir,
pathToId,
rawBase,
siteBaseUrl: opts.siteBaseUrl.replace(/\/$/, ""),
};
let markdown = body;
markdown = stripMdxImports(markdown);
markdown = convertAdmonitions(markdown);
markdown = stripJsxTags(markdown);
markdown = rewriteReferences(markdown, ctx, opts.root);
markdown = stripRedundantH1(markdown, title);
markdown = demoteHeadings(markdown).trim();
const sectionId = dir === "" ? ROOT_SECTION_ID : docIdForPath(dir);
const sectionLabel =
dir === ""
? "Overview"
: (categories[dir]?.label ?? humanize(dir.split("/").pop() ?? dir));
return {
id: docIdForPath(doc.relPath),
title,
navLabel,
description,
section: sectionId,
markdown,
sourcePath: `${opts.root}/${doc.relPath}`,
editUrl: `${editBase}/${opts.root}/${doc.relPath}`,
sidebarPosition:
typeof data.sidebar_position === "number" ? data.sidebar_position : 999,
orderKey: sectionOrderKey(dir, categories),
sectionLabel,
};
});
// Group into sections and order everything deterministically.
const bySection = new Map<string, ShapedDoc[]>();
for (const doc of shaped) {
const list = bySection.get(doc.section) ?? [];
list.push(doc);
bySection.set(doc.section, list);
}
const nav: DocsNavSection[] = [...bySection.entries()]
.map(([id, docs]) => {
const first = docs[0];
const items = [...docs]
.sort(
(a, b) =>
a.sidebarPosition - b.sidebarPosition ||
a.navLabel.localeCompare(b.navLabel),
)
.map<DocsNavItem>((d) => ({ id: d.id, label: d.navLabel }));
return {
id,
label: first.sectionLabel,
icon: sectionIcon(first.sectionLabel),
items,
_key: first.orderKey,
};
})
.sort(
(a, b) => compareKeys(a._key, b._key) || a.label.localeCompare(b.label),
)
.map(({ _key, ...section }) => section);
const docs: Record<string, DocEntry> = {};
for (const d of shaped) {
docs[d.id] = {
id: d.id,
title: d.title,
description: d.description,
section: d.section,
markdown: d.markdown,
sourcePath: d.sourcePath,
editUrl: d.editUrl,
};
}
return {
source: { repo: opts.repo, ref: opts.ref, root: opts.root },
nav,
docs,
};
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
buildSnippet,
highlight,
searchDocs,
toPlainText,
type SearchDoc,
} from "@portal/docs/search";
const DOCS: SearchDoc[] = [
{
id: "ocr",
title: "OCR Guide",
sectionLabel: "Configuration",
text: "Stirling PDF uses Tesseract for its text recognition and language packs.",
},
{
id: "docker",
title: "Docker Install",
sectionLabel: "Installation",
text: "Run Stirling with docker compose up to start the container.",
},
{
id: "ranky",
title: "Something else",
sectionLabel: "Misc",
text: "docker docker docker appears many times in the body here.",
},
];
describe("toPlainText", () => {
it("strips headings, links, inline code, and code fences", () => {
const md =
"# Title\n\nSee [the guide](doc:x) and run `npm i`.\n\n```bash\nnpm run build\n```";
const out = toPlainText(md);
expect(out).toContain("Title");
expect(out).toContain("the guide");
expect(out).toContain("npm i");
expect(out).toContain("npm run build"); // code text kept, fences dropped
expect(out).not.toContain("#");
expect(out).not.toContain("```");
expect(out).not.toContain("](");
});
});
describe("highlight", () => {
it("splits text into hit/non-hit segments", () => {
expect(highlight("Hello world", ["world"])).toEqual([
{ text: "Hello ", hit: false },
{ text: "world", hit: true },
]);
});
it("is safe against regex metacharacters in terms", () => {
expect(() => highlight("a (b) c", ["("])).not.toThrow();
});
});
describe("buildSnippet", () => {
it("windows around the first match and marks it", () => {
const text =
"lorem ipsum ".repeat(20) + "the TARGET keyword " + "dolor ".repeat(20);
const segs = buildSnippet(text, ["target"]);
expect(segs.some((s) => s.hit && /target/i.test(s.text))).toBe(true);
// Windowed, so it should be far shorter than the full text.
expect(segs.map((s) => s.text).join("").length).toBeLessThan(text.length);
});
});
describe("searchDocs", () => {
it("returns nothing for an empty query", () => {
expect(searchDocs(DOCS, " ")).toEqual([]);
});
it("matches body content, not just titles (with a snippet)", () => {
const res = searchDocs(DOCS, "tesseract");
expect(res.map((r) => r.id)).toEqual(["ocr"]);
expect(res[0].snippet.some((s) => s.hit && /tesseract/i.test(s.text))).toBe(
true,
);
});
it("ranks title matches above body matches", () => {
const res = searchDocs(DOCS, "docker");
// "Docker Install" (title hit) outranks "Something else" (body-only hits).
expect(res[0].id).toBe("docker");
expect(res.map((r) => r.id)).toContain("ranky");
});
it("requires every term to match (AND)", () => {
expect(searchDocs(DOCS, "docker compose").map((r) => r.id)).toEqual([
"docker",
]);
expect(searchDocs(DOCS, "docker tesseract")).toEqual([]);
});
it("does not throw on regex-special-character queries", () => {
expect(() => searchDocs(DOCS, "a(b")).not.toThrow();
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Full-text search over the docs manifest. Pure + dependency-free so it's unit
* testable. Indexes each doc's title + plaintext body; ranks title matches above
* body matches; returns highlighted title + a content snippet per hit.
*/
export interface SearchDoc {
id: string;
title: string;
sectionLabel: string;
/** Plaintext body (markdown stripped), original case. */
text: string;
}
/** A run of result text, flagged when it matches a query term (for <mark>). */
export interface Segment {
text: string;
hit: boolean;
}
export interface SearchResult {
id: string;
title: string;
sectionLabel: string;
titleSegments: Segment[];
snippet: Segment[];
score: number;
}
/** Strip markdown/MDX down to readable plaintext for indexing + snippets. */
export function toPlainText(md: string): string {
return md
.replace(/^(```|~~~).*$/gm, " ") // fence delimiters (keep the code text)
.replace(/`([^`]+)`/g, "$1") // inline code
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
.replace(/^\s{0,3}>\s?/gm, "") // blockquotes
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
.replace(/[*_~]/g, "") // emphasis marks
.replace(/\|/g, " ") // table pipes
.replace(/\s+/g, " ") // collapse whitespace
.trim();
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function countOccurrences(haystack: string, needle: string): number {
let count = 0;
let i = haystack.indexOf(needle);
while (i !== -1) {
count++;
i = haystack.indexOf(needle, i + needle.length);
}
return count;
}
/** Split `text` into segments, flagging any run that matches a query term. */
export function highlight(text: string, terms: string[]): Segment[] {
const cleaned = terms.map(escapeRegExp).filter(Boolean);
if (!cleaned.length) return [{ text, hit: false }];
const re = new RegExp(`(${cleaned.join("|")})`, "gi");
const segments: Segment[] = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
segments.push({ text: text.slice(last, m.index), hit: false });
}
segments.push({ text: m[0], hit: true });
last = m.index + m[0].length;
if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width
}
if (last < text.length) segments.push({ text: text.slice(last), hit: false });
return segments.length ? segments : [{ text, hit: false }];
}
/** Build a ~context-window snippet around the earliest term match. */
export function buildSnippet(
text: string,
terms: string[],
radius = 90,
): Segment[] {
const lower = text.toLowerCase();
let pos = -1;
for (const term of terms) {
const i = lower.indexOf(term);
if (i !== -1 && (pos === -1 || i < pos)) pos = i;
}
if (pos === -1) {
const head = text.slice(0, radius * 2);
return highlight(head + (text.length > head.length ? "…" : ""), terms);
}
let start = Math.max(0, pos - radius);
let end = Math.min(text.length, pos + radius);
// Snap to word boundaries so we don't slice mid-word.
if (start > 0) {
const space = text.indexOf(" ", start);
if (space !== -1 && space < pos) start = space + 1;
}
if (end < text.length) {
const space = text.lastIndexOf(" ", end);
if (space > pos) end = space;
}
let snippet = text.slice(start, end).trim();
if (start > 0) snippet = "…" + snippet;
if (end < text.length) snippet = snippet + "…";
return highlight(snippet, terms);
}
/**
* Rank docs against a query. A doc matches when every term appears in its title
* or body; title hits score highest.
*/
export function searchDocs(
docs: SearchDoc[],
query: string,
limit = 40,
): SearchResult[] {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return [];
const results: SearchResult[] = [];
for (const doc of docs) {
const titleLower = doc.title.toLowerCase();
const textLower = doc.text.toLowerCase();
const matchesAll = terms.every(
(term) => titleLower.includes(term) || textLower.includes(term),
);
if (!matchesAll) continue;
let score = 0;
for (const term of terms) {
if (titleLower.includes(term)) score += 10;
if (titleLower.startsWith(term)) score += 5;
score += Math.min(countOccurrences(textLower, term), 5);
}
results.push({
id: doc.id,
title: doc.title,
sectionLabel: doc.sectionLabel,
titleSegments: highlight(doc.title, terms),
snippet: buildSnippet(doc.text, terms),
score,
});
}
results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
return results.slice(0, limit);
}
File diff suppressed because one or more lines are too long
+48 -91
View File
@@ -4,33 +4,13 @@
* only builds seed data for the MSW handlers and tests.
*/
import type {
PolicyRunView,
WirePipelineStep,
WirePolicy,
} from "@app/policies/types";
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
import { POLICY_CONFIG } from "@portal/api/policies";
/* ──────────────────────────────────────────────────────────────────────── */
/* Seed data — real backend wire format */
/* ──────────────────────────────────────────────────────────────────────── */
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
// @portal/api/policies and its heavy tool-operation import graph.
const SECURITY_STEPS: WirePipelineStep[] = [
{
operation: "/api/v1/security/auto-redact",
parameters: {
listOfText: "",
useRegex: true,
convertPDFToImage: true,
},
},
{
operation: "/api/v1/security/sanitize-pdf",
parameters: { removeJavaScript: true },
},
];
export function seedPolicies(): WirePolicy[] {
return [
{
@@ -39,7 +19,7 @@ export function seedPolicies(): WirePolicy[] {
owner: "security@acme.com",
enabled: true,
trigger: null,
steps: SECURITY_STEPS,
steps: POLICY_CONFIG.security.defaultOperations,
output: {
type: "inline",
options: {
@@ -57,80 +37,57 @@ export function seedPolicies(): WirePolicy[] {
},
},
},
{
id: "pol_ingestion_default",
name: "Ingestion Policy",
owner: "data-eng@acme.com",
enabled: true,
trigger: null,
steps: POLICY_CONFIG.ingestion.defaultOperations,
output: {
type: "inline",
options: {
runOn: "upload",
mode: "new_version",
name: "",
position: "suffix",
maxRetries: 3,
retryDelayMinutes: 5,
categoryId: "ingestion",
sources: ["src-contracts"],
scopeTypes: [],
reviewerEmail: "data-eng@acme.com",
fieldValues: {},
},
},
},
];
}
const NOW = Date.now();
const M = 60000;
const H = 3600000;
const D = 86400000;
/** Seed `PolicyRunView` records that drive the activity feed + stats.
* 40 delivered + 3 failed within the trailing 24h so the home visualiser shows
* a lively flow; a tail of older completed runs keeps the lifetime stats real.
* Runs are split across the two active policies so the Sankey waist divides. */
/** Seed `PolicyRunView` records that drive the activity feed + stats. */
export function seedPolicyRuns(): PolicyRunView[] {
// Split the throughput across the two active policies (security / ingestion)
// so both show a 24h count and the Sankey waist splits into two segments.
const policyFor = (i: number, total: number) =>
i < Math.round(total * 0.6)
? "pol_security_default"
: "pol_ingestion_default";
// 40 successful runs spread across the last ~13h.
const delivered = Array.from({ length: 40 }, (_, i) => ({
runId: `run_ok_${i}`,
policyId: policyFor(i, 40),
status: "COMPLETED" as const,
currentStep: 2,
stepCount: 2,
error: null,
outputs: [{ fileId: `f${i}`, fileName: `document-${i + 1}.pdf` }],
createdAt: NOW - i * 20 * M,
}));
// 3 failures within the last few hours.
const failed = Array.from({ length: 3 }, (_, i) => ({
runId: `run_fail_${i}`,
policyId: policyFor(i, 3),
status: "FAILED" as const,
currentStep: 1,
stepCount: 2,
error: "Low-confidence match — routed for review",
outputs: [{ fileId: `ff${i}`, fileName: `flagged-${i + 1}.pdf` }],
createdAt: NOW - (i + 1) * 90 * M,
}));
// Older completed runs (>24h) for lifetime stats — excluded from 24h counts.
const older = Array.from({ length: 4800 }, (_, i) => ({
runId: `run_old_${i}`,
policyId: "pol_security_default",
status: "COMPLETED" as const,
currentStep: 2,
stepCount: 2,
error: null,
outputs: [] as { fileId: string; fileName: string }[],
createdAt: NOW - (34 * D + i * 10 * M),
}));
return [...delivered, ...failed, ...older];
return [
{
runId: "run_001",
policyId: "pol_security_default",
status: "COMPLETED",
currentStep: 2,
stepCount: 2,
error: null,
outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }],
createdAt: NOW - 12 * M,
},
{
runId: "run_002",
policyId: "pol_security_default",
status: "FAILED",
currentStep: 1,
stepCount: 2,
error: "Low-confidence match — routed for review",
outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }],
createdAt: NOW - 1 * H,
},
{
runId: "run_003",
policyId: "pol_security_default",
status: "RUNNING",
currentStep: 1,
stepCount: 2,
error: null,
outputs: [{ fileId: "f3", fileName: "invoice-7782.pdf" }],
createdAt: NOW - 2 * M,
},
// Older completed runs for stats
...Array.from({ length: 4818 }, (_, i) => ({
runId: `run_old_${i}`,
policyId: "pol_security_default",
status: "COMPLETED" as const,
currentStep: 2,
stepCount: 2,
error: null,
outputs: [] as { fileId: string; fileName: string }[],
createdAt: NOW - (34 * D + i * 10 * M),
})),
];
}
@@ -1,73 +1,254 @@
/* Full-height docs surface: the sidebar and reading pane each scroll on their
own inside the portal shell's scroll container, so a 70+ item nav no longer
drags the whole page around. */
.portal-docs {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
gap: 2rem;
padding: 1.5rem;
max-width: 84rem;
margin: 0 auto;
align-items: start;
grid-template-columns: 16rem minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: hidden;
}
@media (max-width: 60rem) {
.portal-docs {
grid-template-columns: 1fr;
gap: 1.25rem;
/* With an "On this page" TOC, add a right-hand column. */
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr) 15rem;
}
/* Not enough room for three columns → drop the TOC. */
@media (max-width: 72rem) {
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr);
}
.portal-docs__toc-col {
display: none;
}
}
/* ── Left nav ──────────────────────────────────────────────────────────── */
.portal-docs--empty {
display: block;
padding: 3rem 1.5rem;
}
/* ── Left nav (own scroll column) ──────────────────────────────────────── */
.portal-docs__sidebar {
position: sticky;
top: 1.5rem;
align-self: start;
}
@media (max-width: 60rem) {
.portal-docs__sidebar {
position: static;
}
min-height: 0;
height: 100%;
overflow-y: auto;
border-right: 1px solid var(--color-border-light);
padding: 1.25rem 0;
}
.portal-docs__nav {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 0.25rem;
}
/* Search */
.portal-docs__search {
margin-bottom: 0.75rem;
padding: 0 0.75rem;
}
.portal-docs__search-box {
position: relative;
}
.portal-docs__search-icon {
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.9375rem;
color: var(--color-text-4);
pointer-events: none;
}
.portal-docs__search-input {
width: 100%;
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
font-size: 0.8125rem;
color: var(--color-text-1);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
outline: none;
}
.portal-docs__search-input:focus {
border-color: var(--color-blue);
}
.portal-docs__nav-empty {
font-size: 0.8125rem;
color: var(--color-text-4);
padding: 0.5rem 0.75rem;
margin: 0;
}
/* ── Search results ────────────────────────────────────────────────────── */
.portal-docs__results {
margin-top: 0.5rem;
}
.portal-docs__results-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-4);
padding: 0 0.75rem 0.5rem;
}
.portal-docs__results-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
/* Hairline divider between results for clear, calm separation. */
.portal-docs__results-list li + li {
border-top: 1px solid var(--color-border-light);
}
.portal-docs__result {
height: auto;
padding: 0.5rem 0.75rem;
border-radius: 0;
}
.portal-docs__result:hover,
.portal-docs__result.is-active {
background: var(--color-bg-hover);
}
.portal-docs__result-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
width: 100%;
min-width: 0;
text-align: left;
white-space: normal;
}
/* Title + section share one line; the title truncates before the section. */
.portal-docs__result-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
.portal-docs__result-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.3;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.portal-docs__result-section {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-docs__result-snippet {
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-4);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Subtle match emphasis — coloured text, not a filled block. */
.portal-docs__hl {
color: var(--color-blue);
font-weight: 600;
background: none;
}
.portal-docs__nav-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.0625rem;
}
/* Section label: shared typography for the static Overview label and the
collapsible accordion headers below. */
.portal-docs__nav-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem 0.2rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-4);
padding: 0 0.5rem;
}
.portal-docs__nav-icon {
display: inline-flex;
/* Collapsible section header (Overview stays a static label). */
.portal-docs__nav-head--button:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.portal-docs__nav-head-main {
display: flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
font-size: 0.6875rem;
border-radius: var(--radius-sm);
gap: 0.4rem;
min-width: 0;
}
.portal-docs__nav-headlabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-docs__nav-chevron {
font-size: 0.625rem;
color: var(--color-text-4);
transition: transform var(--motion-fast);
}
.portal-docs__nav-chevron.is-open {
transform: rotate(90deg);
}
.portal-docs__nav-count {
font-size: 0.625rem;
font-weight: 600;
color: var(--color-text-4);
background: var(--color-bg-subtle);
color: var(--color-text-3);
border-radius: var(--radius-pill);
padding: 0.05rem 0.4rem;
}
/* Space consecutive sections apart (not before the first). */
.portal-docs__nav-group + .portal-docs__nav-group {
margin-top: 0.4rem;
}
/* Nested sub-sections: indented under their parent with a guide line. */
.portal-docs__nav-children {
margin: 0.125rem 0 0.25rem 0.85rem;
padding-left: 0.4rem;
border-left: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-docs__nav-list {
list-style: none;
margin: 0;
margin: 0 0 0.375rem;
padding: 0;
display: flex;
flex-direction: column;
@@ -80,10 +261,10 @@
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.5rem;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
border-radius: 0;
font-size: 0.8125rem;
color: var(--color-text-3);
cursor: pointer;
@@ -110,10 +291,129 @@
white-space: nowrap;
}
/* ── Content shell ─────────────────────────────────────────────────────── */
/* Mobile-only nav toggle (a drawer button); hidden on desktop. */
.portal-docs__nav-toggle {
display: none;
}
/* ── Reading pane (own scroll column) ──────────────────────────────────── */
.portal-docs__content {
min-width: 0;
min-height: 0;
height: 100%;
overflow-y: auto;
padding: 1.5rem 2rem 4rem;
}
.portal-docs__content-inner {
max-width: 46rem;
margin: 0 auto;
}
/* ── On this page (right rail) ─────────────────────────────────────────── */
.portal-docs__toc-col {
min-height: 0;
height: 100%;
overflow-y: auto;
border-left: 1px solid var(--color-border-light);
padding: 1.5rem 0.75rem;
}
.portal-docs__toc {
position: sticky;
top: 0;
}
.portal-docs__toc-title {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-4);
padding: 0 0.5rem 0.5rem;
}
.portal-docs__toc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-docs__toc-link {
display: block;
padding: 0.25rem 0.5rem;
border-left: 2px solid transparent;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--color-text-3);
text-decoration: none;
transition: color var(--motion-fast);
}
.portal-docs__toc-link.is-sub {
padding-left: 1.25rem;
font-size: 0.75rem;
}
.portal-docs__toc-link:hover {
color: var(--color-text-1);
}
.portal-docs__toc-link.is-active {
color: var(--color-blue);
border-left-color: var(--color-blue);
font-weight: 600;
}
/* Scroll-to-heading lands just below the pane top, not flush against it. */
.portal-docs__md h2,
.portal-docs__md h3 {
scroll-margin-top: 1rem;
}
/* ── Small screens: nav collapses into a drawer above the content ──────── */
@media (max-width: 60rem) {
.portal-docs {
grid-template-columns: 1fr;
grid-template-rows: auto auto minmax(0, 1fr);
}
.portal-docs__nav-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
cursor: pointer;
}
.portal-docs__sidebar {
display: none;
height: auto;
max-height: 60vh;
border-right: none;
border-bottom: 1px solid var(--color-border-light);
}
.portal-docs__sidebar.is-open {
display: block;
}
.portal-docs__content {
padding: 1.25rem 1.25rem 3rem;
}
}
.portal-docs__section {
@@ -523,3 +823,160 @@
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Markdown content ──────────────────────────────────────────────────── */
.portal-docs__md {
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-text-2);
}
.portal-docs__md h1,
.portal-docs__md h2,
.portal-docs__md h3,
.portal-docs__md h4 {
color: var(--color-text-1);
font-weight: 650;
line-height: 1.25;
margin: 1.75rem 0 0.75rem;
}
.portal-docs__md h2 {
font-size: 1.3125rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-docs__md h3 {
font-size: 1.0625rem;
}
.portal-docs__md h4 {
font-size: 0.9375rem;
}
.portal-docs__md > :first-child {
margin-top: 0;
}
.portal-docs__md p,
.portal-docs__md ul,
.portal-docs__md ol {
margin: 0 0 0.875rem;
}
.portal-docs__md li {
margin: 0.25rem 0;
}
.portal-docs__md a {
color: var(--color-blue);
text-decoration: none;
}
.portal-docs__md a:hover {
text-decoration: underline;
}
.portal-docs__md code {
font-family: var(--font-mono, monospace);
font-size: 0.85em;
background: var(--color-bg-subtle);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.portal-docs__md blockquote {
margin: 0 0 0.875rem;
padding: 0.125rem 0.875rem;
border-left: 3px solid var(--color-blue);
background: var(--color-bg-subtle);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
color: var(--color-text-3);
}
.portal-docs__md blockquote p {
margin: 0.5rem 0;
}
.portal-docs__md-img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.portal-docs__md hr {
border: none;
border-top: 1px solid var(--color-border-light);
margin: 1.5rem 0;
}
.portal-docs__md-pre {
position: relative;
margin: 0 0 0.875rem;
}
.portal-docs__md-pre pre {
margin: 0;
padding: 0.875rem 4rem 0.875rem 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.5;
}
.portal-docs__md-pre pre code {
background: none;
padding: 0;
font-size: inherit;
}
.portal-docs__md-copy {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.portal-docs__md-tablewrap {
overflow-x: auto;
margin: 0 0 0.875rem;
}
.portal-docs__md-tablewrap table {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
.portal-docs__md-tablewrap th,
.portal-docs__md-tablewrap td {
border: 1px solid var(--color-border-light);
padding: 0.4rem 0.625rem;
text-align: left;
}
.portal-docs__md-tablewrap th {
background: var(--color-bg-subtle);
font-weight: 600;
}
.portal-docs__source {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
font-size: 0.8125rem;
}
.portal-docs__source a {
color: var(--color-text-3);
text-decoration: none;
}
.portal-docs__source a:hover {
color: var(--color-blue);
text-decoration: underline;
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
// Renders the real docs manifest (generated by scripts/sync-portal-docs.mts), so
// this story is a live view of the auto-built Documentation browser. The view
// fills its container's height (independent sidebar/content scroll), so the
// decorator gives it a viewport-height frame.
const meta: Meta<typeof DeveloperDocs> = {
title: "Portal/DeveloperDocs/View",
component: DeveloperDocs,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<div style={{ height: "100vh" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeveloperDocs>;
export const Default: Story = {};
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
// Deterministic labels; the view + DocsNav + MarkdownDoc all use useTranslation.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// 2nd arg may be a default string or i18next interpolation options ({count}).
t: (key: string, opts?: unknown) => (typeof opts === "string" ? opts : key),
i18n: { changeLanguage: vi.fn() },
}),
}));
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
const renderDocs = (ui: ReactElement) =>
render(
<MemoryRouter initialEntries={["/processor/docs"]}>
<MantineProvider>{ui}</MantineProvider>
</MemoryRouter>,
);
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
renderDocs(<DeveloperDocs />);
expect(screen.getByRole("searchbox")).toBeInTheDocument();
// Overview is static: its items show, and it has no toggle button.
expect(
screen.getByRole("button", { name: "Production Deployment Guide" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^Overview/i }),
).not.toBeInTheDocument();
// Other sections collapse, so their items are hidden until expanded.
expect(
screen.queryByRole("button", { name: "Kubernetes Guide" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/locally hosted web application/i),
).toBeInTheDocument();
});
it("expands a collapsible section when its header is clicked", () => {
renderDocs(<DeveloperDocs />);
fireEvent.click(screen.getByRole("button", { name: /Installation/i }));
expect(
screen.getByRole("button", { name: "Kubernetes Guide" }),
).toBeInTheDocument();
});
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
renderDocs(<DeveloperDocs />);
// "Tesseract" appears in the OCR doc body but in no doc title — a result
// whose snippet contains it proves full-text (content) search.
fireEvent.change(screen.getByRole("searchbox"), {
target: { value: "Tesseract" },
});
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
expect(hits.length).toBeGreaterThan(0);
fireEvent.click(hits[0]);
await waitFor(() =>
expect(
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
it("follows an internal doc: link inside the rendered markdown", async () => {
renderDocs(<DeveloperDocs />);
// The Getting Started body links to the Migration guide via the doc: scheme.
fireEvent.click(screen.getByRole("link", { name: /Migration Guide/i }));
await waitFor(() =>
expect(
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
});
@@ -1,97 +1,140 @@
import { useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState } from "@app/ui";
import { DocsNav } from "@portal/components/docs/DocsNav";
import { DocsSearch } from "@portal/components/docs/DocsSearch";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { DocsToc } from "@portal/components/docs/DocsToc";
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
import { extractHeadings } from "@portal/docs/headings";
import {
fetchDocsContent,
fetchDocsNav,
type DocsContent,
type DocsNavSection,
} from "@portal/api/docs";
import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav";
import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection";
import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection";
import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection";
import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection";
import { ErrorsSection } from "@portal/components/docs/ErrorsSection";
import { WebhooksSection } from "@portal/components/docs/WebhooksSection";
import { SdksSection } from "@portal/components/docs/SdksSection";
import { ComponentsSection } from "@portal/components/docs/ComponentsSection";
import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection";
import { SkillsSection } from "@portal/components/docs/SkillsSection";
allDocs,
firstDocId,
loadDoc,
loadDocsNav,
} from "@portal/docs/manifest/registry";
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
import "@portal/views/DeveloperDocs.css";
/** Renders the content pane for the active nav leaf against fetched content. */
function DocsContentPane({
active,
content,
}: {
active: string;
content: DocsContent;
}) {
switch (active) {
case "authentication":
return <AuthenticationSection />;
case "rate-limits":
return <RateLimitsSection rateLimit={content.rateLimit} />;
case "endpoints":
return <EndpointReferenceSection />;
case "errors":
return <ErrorsSection errors={content.errors} />;
case "webhooks":
return <WebhooksSection />;
case "sdk-overview":
return <SdksSection sdks={content.sdks} />;
case "component-library":
return <ComponentsSection components={content.components} />;
case "recipes":
return <PlaybooksSection playbooks={content.playbooks} />;
case "skill-catalog":
return <SkillsSection skills={content.skills} />;
default:
return (
<GettingStartedSection
samples={content.quickstartSamples}
response={content.quickstartResponse}
/>
);
}
}
/**
* Developer Docs — a markdown browser over the docs manifest generated from the
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
* from the repo's folders + frontmatter; content is the repo markdown, and the
* search box does full-text search across every doc.
*/
export function DeveloperDocs() {
const { t } = useTranslation();
const { tier } = useTier();
const [active, setActive] = useState("quickstart");
const { hash } = useLocation();
const navigate = useNavigate();
const contentRef = useRef<HTMLElement>(null);
const [navOpen, setNavOpen] = useState(false);
const [query, setQuery] = useState("");
const navState = useAsync<DocsNavSection[]>(() => fetchDocsNav(), []);
const { data: nav } = navState;
const { isLoading, isEmpty } = useSectionFlags(navState);
const nav = useMemo(() => loadDocsNav(), []);
const fallback = useMemo(() => firstDocId(), []);
const { data: content } = useAsync<DocsContent>(
() => fetchDocsContent(tier),
[tier],
// Full-text index over every doc's plaintext body (built once).
const index = useMemo<SearchDoc[]>(() => {
const labels = new Map(nav.map((s) => [s.id, s.label]));
return allDocs().map((d) => ({
id: d.id,
title: d.title,
sectionLabel: labels.get(d.section) ?? "",
text: toPlainText(d.markdown),
}));
}, [nav]);
const results = useMemo(() => searchDocs(index, query), [index, query]);
const searching = query.trim().length > 0;
// Deep-link support: the active doc id lives in the URL hash.
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
const doc = activeId ? loadDoc(activeId) : undefined;
const section = useMemo(
() => nav.find((s) => s.items.some((i) => i.id === activeId)),
[nav, activeId],
);
// "On this page" headings for the current doc.
const headings = useMemo(
() => (doc ? extractHeadings(doc.markdown) : []),
[doc],
);
// Navigating closes the mobile drawer, clears the search, and resets the pane.
const onSelect = useCallback(
(id: string) => {
navigate({ hash: id });
setNavOpen(false);
setQuery("");
},
[navigate],
);
useEffect(() => {
contentRef.current?.scrollTo?.({ top: 0 });
}, [activeId]);
if (nav.length === 0 || !doc) {
return (
<div className="portal-docs portal-docs--empty">
<EmptyState
title={t("portal.docs.nav.empty.title")}
description={t("portal.docs.nav.empty.description")}
/>
</div>
);
}
const hasToc = headings.length > 0;
return (
<div className="portal-docs">
<aside className="portal-docs__sidebar">
{isLoading && <DocsNavSkeleton />}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.docs.nav.empty.title")}
description={t("portal.docs.nav.empty.description")}
/>
)}
{nav && nav.length > 0 && (
<DocsNav sections={nav} active={active} onSelect={setActive} />
<div className={"portal-docs" + (hasToc ? " portal-docs--with-toc" : "")}>
<Button
variant="tertiary"
className="portal-docs__nav-toggle"
aria-expanded={navOpen}
onClick={() => setNavOpen((open) => !open)}
leftSection={<span aria-hidden></span>}
>
{t("portal.docs.browse")}
</Button>
<aside className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
<DocsSearch
query={query}
onQueryChange={setQuery}
results={results}
onSelect={onSelect}
/>
{!searching && (
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
)}
</aside>
<main className="portal-docs__content">
{content && <DocsContentPane active={active} content={content} />}
<main className="portal-docs__content" ref={contentRef}>
<div className="portal-docs__content-inner">
<DocsSection
id={doc.id}
eyebrow={section?.label ?? ""}
title={doc.title}
lead={doc.description}
>
<MarkdownDoc markdown={doc.markdown} onNavigate={onSelect} />
<div className="portal-docs__source">
<a href={doc.editUrl} target="_blank" rel="noopener noreferrer">
{t("portal.docs.viewSource")}
</a>
</div>
</DocsSection>
</div>
</main>
{hasToc && (
<aside className="portal-docs__toc-col">
<DocsToc headings={headings} scrollRef={contentRef} />
</aside>
)}
</div>
);
}
@@ -4,7 +4,6 @@ import { useTier } from "@portal/contexts/TierContext";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { HomeHero } from "@portal/components/HomeHero";
import { HomeGreeting } from "@portal/components/HomeGreeting";
import { ProcessorFlow } from "@portal/components/ProcessorFlow";
import { RecentActivity } from "@portal/components/RecentActivity";
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
import { PolicySummary } from "@portal/components/PolicySummary";
@@ -143,7 +142,6 @@ export function Home() {
{/* Per-tier hero. Its footer is the deal-status hero while a procurement
deal is underway (a bolt-on to any tier), otherwise the setup checklist. */}
<HomeHero tier={tier} />
<ProcessorFlow />
{/* One unified layout across tiers: real processed-PDF volume, real audit
activity, quick actions, and the standing-policy summary. */}
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Tabs, type TabItem } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
@@ -19,33 +18,10 @@ type InfraTab =
| "storage"
| "audit";
const INFRA_TABS: InfraTab[] = [
"deployments",
"api-keys",
"security",
"models",
"storage",
"audit",
];
export function Infrastructure() {
const { t } = useTranslation();
const [tab, setTab] = useState<InfraTab>("deployments");
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link (?tab=<key>) from elsewhere (e.g. the home visualiser's outcome
// cards → audit log): open that tab, then drop the param.
useEffect(() => {
const requested = searchParams.get("tab");
if (!requested) return;
if ((INFRA_TABS as string[]).includes(requested)) {
setTab(requested as InfraTab);
}
const next = new URLSearchParams(searchParams);
next.delete("tab");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const tabs: TabItem<InfraTab>[] = [
{ key: "deployments", label: t("portal.infrastructure.tabs.deployments") },
+1 -16
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Skeleton } from "@app/ui";
import { errorMessage } from "@portal/api/http";
@@ -34,20 +33,6 @@ export function Policies() {
const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const setupId = searchParams.get("setup");
if (!setupId || !data) return;
const entry = data.catalogue.find((e) => e.category.id === setupId);
if (entry && !entry.category.comingSoon) {
if (entry.policy) setDetail(entry);
else setWizard(entry);
}
const next = new URLSearchParams(searchParams);
next.delete("setup");
setSearchParams(next, { replace: true });
}, [searchParams, data, setSearchParams]);
const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
@@ -1,7 +1,6 @@
import { MultiSelect } from "@app/ui/MultiSelect";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
/** The set of preset regexes — used to separate preset words from custom ones. */
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
@@ -9,8 +8,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
interface PolicyPiiFieldProps {
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
disabled?: boolean;
}
@@ -27,7 +26,9 @@ export function PolicyPiiField({
disabled,
}: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = parameters.wordsToRedact;
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
const selected = words
.map((w) => VALUE_BY_PATTERN.get(w))
.filter((v): v is string => Boolean(v));
@@ -1,10 +1,9 @@
import { useEffect } from "react";
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
interface PolicyRedactConfigProps {
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
disabled?: boolean;
}
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
interface PolicyWatermarkConfigProps {
parameters: AddWatermarkParameters;
onChange: (parameters: AddWatermarkParameters) => void;
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
disabled?: boolean;
}
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
disabled,
}: PolicyWatermarkConfigProps) {
useEffect(() => {
const patch: Partial<AddWatermarkParameters> = {};
const patch: Record<string, unknown> = {};
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
// Policies only support text watermarks.
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
return (
<AddWatermarkSingleStepSettings
parameters={parameters}
parameters={parameters as unknown as AddWatermarkParameters}
onParameterChange={(key, value) =>
onChange({ ...parameters, [key]: value })
}
@@ -1,112 +0,0 @@
import { describe, expect, test } from "vitest";
import {
POLICY_OPERATIONS,
policyEndpoint,
policyStep,
policyStepFromWire,
policyStepToWire,
policyToolIdForEndpoint,
type PolicyToolId,
} from "@app/policies/operations";
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these six across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"compress",
"flatten",
"ocr",
"redact",
"sanitize",
"watermark",
]);
for (const id of ALL_TOOL_IDS) {
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
}
});
test("policyEndpoint returns the pinned endpoint literal", () => {
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
});
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
for (const id of ALL_TOOL_IDS) {
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
}
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
expect(step.toolId).toBe("redact");
// Overrides applied...
expect(step.params.useRegex).toBe(true);
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
// ...and untouched fields fall back to the tool's defaults.
expect(step.params.mode).toBe("automatic");
expect(step.params.redactColor).toBe("#000000");
});
});
describe("wire conversion", () => {
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
const wire = policyStepToWire(
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: ["ssn", "card"],
}),
);
expect(wire.operation).toBe("/api/v1/security/auto-redact");
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
expect(wire.parameters).toMatchObject({
listOfText: "ssn\ncard",
useRegex: true,
convertPDFToImage: true,
});
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
expect(wire.parameters).not.toHaveProperty("mode");
});
test("every policy operation round-trips through wire and back", () => {
for (const id of ALL_TOOL_IDS) {
const step = policyStep(id);
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe(id);
}
});
test("redact round-trip preserves the configured patterns", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe("redact");
if (back?.toolId === "redact") {
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
expect(back.params.useRegex).toBe(true);
}
});
test("a non-policy endpoint decodes to null", () => {
expect(
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
).toBeNull();
});
});
@@ -1,131 +0,0 @@
/**
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
* policy - the catalogue can't reference an untyped operation.
*/
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
redactOperationConfig,
),
sanitize: describeToolOperation(
"/api/v1/security/sanitize-pdf",
sanitizeOperationConfig,
),
watermark: describeToolOperation(
"/api/v1/security/add-watermark",
addWatermarkOperationConfig,
),
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
flatten: describeToolOperation(
"/api/v1/misc/flatten",
flattenOperationConfig,
),
compress: describeToolOperation(
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
}[PolicyToolId];
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
PolicyToolStep,
{ toolId: Id }
>;
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
}
/** A step for `toolId`, partial params merged over the tool's defaults. */
export function policyStep<Id extends PolicyToolId>(
toolId: Id,
params: Partial<PolicyParams<Id>> = {},
): PolicyToolStepOf<Id> {
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
return {
toolId,
params: { ...defaults, ...(params as object) },
} as PolicyToolStepOf<Id>;
}
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
};
}
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
export function policyStepFromWire(
wire: WirePipelineStep,
): PolicyToolStep | null {
const toolId = policyToolIdForEndpoint(wire.operation);
if (!toolId) return null;
return deserializeStep(toolId, wire.parameters);
}
function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}
+1
View File
@@ -83,6 +83,7 @@
"web-vitals": "^5.1.0"
},
"scripts": {
"docs:sync": "tsx editor/scripts/sync-portal-docs.mts",
"update:minor": "node scripts/update-minor.js",
"update:major": "npx npm-check-updates -u && npm install",
"update:interactive": "npx npm-check-updates -i",