@panaversity/ksor 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +24 -6
- package/dist/cli.mjs +345 -2
- package/docs/index.md +38 -6
- package/package.json +3 -1
- package/templates/LICENSE +23 -0
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +46 -0
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +46 -0
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +991 -0
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +61 -0
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +46 -0
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +46 -0
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +991 -0
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +61 -0
- package/templates/scaffold/.gemini/settings.json +5 -0
- package/templates/scaffold/.gitattributes +5 -0
- package/templates/scaffold/.github/workflows/validate.yml +23 -0
- package/templates/scaffold/AGENTS.md +133 -0
- package/templates/scaffold/CLAUDE.md +1 -0
- package/templates/scaffold/README.md +90 -0
- package/templates/scaffold/gitignore +16 -0
- package/templates/scaffold/instance.md +26 -0
- package/templates/scaffold/knowledge/example.md +23 -0
- package/templates/scaffold/package.json +15 -0
- package/templates/scaffold/pnpm-lock.yaml +4041 -0
- package/templates/scaffold/pnpm-workspace.yaml +19 -0
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +83 -0
- package/templates/scaffold/system/site/app/api/search/route.ts +11 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +53 -0
- package/templates/scaffold/system/site/app/docs/layout.tsx +24 -0
- package/templates/scaffold/system/site/app/global.css +26 -0
- package/templates/scaffold/system/site/app/icon.png +0 -0
- package/templates/scaffold/system/site/app/layout.tsx +41 -0
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +10 -0
- package/templates/scaffold/system/site/app/llms.txt/route.ts +15 -0
- package/templates/scaffold/system/site/components/built-with.tsx +18 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +22 -0
- package/templates/scaffold/system/site/components/mdx.tsx +15 -0
- package/templates/scaffold/system/site/lib/audience.ts +178 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +17 -0
- package/templates/scaffold/system/site/lib/shared.ts +60 -0
- package/templates/scaffold/system/site/lib/source.ts +119 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +301 -0
- package/templates/scaffold/system/site/next-env.d.ts +6 -0
- package/templates/scaffold/system/site/next.config.mjs +32 -0
- package/templates/scaffold/system/site/package.json +29 -0
- package/templates/scaffold/system/site/postcss.config.mjs +7 -0
- package/templates/scaffold/system/site/source.config.ts +41 -0
- package/templates/scaffold/system/site/tsconfig.json +35 -0
- package/templates/scaffold/vercel.json +8 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
packages:
|
|
2
|
+
- system/site
|
|
3
|
+
# reserved homes for future system components (gateways, MCP surface, packages)
|
|
4
|
+
- system/gateways/*
|
|
5
|
+
- system/packages/*
|
|
6
|
+
|
|
7
|
+
# 48-hour quarantine on newly published dependency versions — a routine
|
|
8
|
+
# `pnpm install` here never picks up a day-zero compromised release.
|
|
9
|
+
minimumReleaseAge: 2880
|
|
10
|
+
|
|
11
|
+
# Dependency install scripts are denied by default. Flip an entry to true
|
|
12
|
+
# only with a comment naming what breaks without it. esbuild and sharp are
|
|
13
|
+
# reviewed and stay denied: both ship prebuilt platform binaries as
|
|
14
|
+
# optionalDependencies, so their install scripts are download fallbacks the
|
|
15
|
+
# site never needs — but pnpm 11 exits 1 on every install/dev until each is
|
|
16
|
+
# explicitly decided. (found live: fresh-scaffold pnpm dev, 2026-08-18)
|
|
17
|
+
allowBuilds:
|
|
18
|
+
esbuild: false
|
|
19
|
+
sharp: false
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
// The same file Next serves as the favicon (app/icon.png) — one mark, one
|
|
4
|
+
// asset. Replace it with your own and the tab icon changes with the page.
|
|
5
|
+
import mark from "@/app/icon.png";
|
|
6
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
7
|
+
import { appName, appTitle } from "@/lib/shared";
|
|
8
|
+
import { basePath, getSortedPages } from "@/lib/source";
|
|
9
|
+
|
|
10
|
+
export default function HomePage() {
|
|
11
|
+
// The first document in sidebar order — never a hardcoded path, so deleting
|
|
12
|
+
// the example the scaffold ships cannot leave a link pointing at nothing.
|
|
13
|
+
const pages = getSortedPages();
|
|
14
|
+
const [first] = pages;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<main className="flex flex-1 flex-col">
|
|
18
|
+
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col justify-center px-6 py-24">
|
|
19
|
+
<Image
|
|
20
|
+
src={mark}
|
|
21
|
+
alt=""
|
|
22
|
+
width={56}
|
|
23
|
+
height={56}
|
|
24
|
+
priority
|
|
25
|
+
className="mb-7 size-14 rounded-xl ring-1 ring-fd-border"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
{/* The frame is KSoR's; the title is the record's. The headline is
|
|
29
|
+
instance.md's own H1 — a human name, not the machine slug — so a
|
|
30
|
+
fresh scaffold reads "Knowledge System of Record" until the
|
|
31
|
+
intake interview writes the real one. */}
|
|
32
|
+
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-fd-muted-foreground">
|
|
33
|
+
KSoR
|
|
34
|
+
</p>
|
|
35
|
+
<h1 className="text-4xl font-semibold tracking-tight text-balance break-words sm:text-5xl">
|
|
36
|
+
{appTitle}
|
|
37
|
+
</h1>
|
|
38
|
+
<p className="mt-3 text-lg text-fd-muted-foreground">
|
|
39
|
+
Knowledge you can govern. Answers you can trace. Boundaries agents can respect.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
{first ? (
|
|
43
|
+
<div className="mt-9 flex flex-col gap-4">
|
|
44
|
+
<Link
|
|
45
|
+
href={first.url}
|
|
46
|
+
className="group inline-flex w-fit items-center gap-2 rounded-lg bg-fd-primary px-4 py-2.5 text-sm font-medium text-fd-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
47
|
+
>
|
|
48
|
+
Open the record
|
|
49
|
+
<span
|
|
50
|
+
aria-hidden
|
|
51
|
+
className="transition-transform group-hover:translate-x-0.5 motion-reduce:transform-none"
|
|
52
|
+
>
|
|
53
|
+
→
|
|
54
|
+
</span>
|
|
55
|
+
</Link>
|
|
56
|
+
{/* The machine identity and the agent door — the slug is what
|
|
57
|
+
citations will carry, so it stays visible where agents look. */}
|
|
58
|
+
<p className="text-xs text-fd-muted-foreground">
|
|
59
|
+
<span className="font-mono">{appName}</span> · {pages.length} document
|
|
60
|
+
{pages.length === 1 ? "" : "s"} · agents read{" "}
|
|
61
|
+
<a
|
|
62
|
+
href={`${basePath}/llms.txt`}
|
|
63
|
+
className="underline underline-offset-4 transition-colors hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
64
|
+
>
|
|
65
|
+
llms.txt
|
|
66
|
+
</a>
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
) : (
|
|
70
|
+
<p className="mt-9 text-fd-muted-foreground">
|
|
71
|
+
the record is empty — add a document to <code>knowledge/</code>
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<footer className="mx-auto w-full max-w-2xl px-6 pb-10">
|
|
77
|
+
<p className="border-t border-fd-border pt-6 text-xs">
|
|
78
|
+
<FooterMark />
|
|
79
|
+
</p>
|
|
80
|
+
</footer>
|
|
81
|
+
</main>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { source } from "@/lib/source";
|
|
2
|
+
import { createFromSource } from "fumadocs-core/search/server";
|
|
3
|
+
|
|
4
|
+
// Static export: this route is prerendered into a JSON index file that the
|
|
5
|
+
// search dialog downloads and queries client-side (see app/layout.tsx).
|
|
6
|
+
export const revalidate = false;
|
|
7
|
+
|
|
8
|
+
export const { staticGET: GET } = createFromSource(source, {
|
|
9
|
+
// https://docs.orama.com/docs/orama-js/supported-languages
|
|
10
|
+
language: "english",
|
|
11
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { source } from "@/lib/source";
|
|
2
|
+
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
|
3
|
+
import { notFound } from "next/navigation";
|
|
4
|
+
import { getMDXComponents } from "@/components/mdx";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
import { createRelativeLink } from "fumadocs-ui/mdx";
|
|
7
|
+
|
|
8
|
+
export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
|
|
9
|
+
const params = await props.params;
|
|
10
|
+
const page = source.getPage(params.slug);
|
|
11
|
+
if (!page) notFound();
|
|
12
|
+
|
|
13
|
+
const MDX = page.data.body;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<DocsPage toc={page.data.toc} full={page.data.full}>
|
|
17
|
+
<DocsTitle>{page.data.title}</DocsTitle>
|
|
18
|
+
<DocsDescription>{page.data.description}</DocsDescription>
|
|
19
|
+
<DocsBody>
|
|
20
|
+
<MDX
|
|
21
|
+
components={getMDXComponents({
|
|
22
|
+
// relative links between documents in knowledge/ resolve to
|
|
23
|
+
// their rendered pages
|
|
24
|
+
a: createRelativeLink(source, page),
|
|
25
|
+
})}
|
|
26
|
+
/>
|
|
27
|
+
</DocsBody>
|
|
28
|
+
</DocsPage>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function generateStaticParams() {
|
|
33
|
+
const params = source.generateParams();
|
|
34
|
+
if (params.length === 0) {
|
|
35
|
+
// Without this, Next fails the empty-record build with an error that
|
|
36
|
+
// names neither the record nor the rule (found live, 2026-08-18).
|
|
37
|
+
throw new Error(
|
|
38
|
+
"the record has no documents — a KSoR is never empty; add one to knowledge/ or restore one from git history (pnpm check says the same).",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return params;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function generateMetadata(props: PageProps<"/docs/[[...slug]]">): Promise<Metadata> {
|
|
45
|
+
const params = await props.params;
|
|
46
|
+
const page = source.getPage(params.slug);
|
|
47
|
+
if (!page) notFound();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
title: page.data.title,
|
|
51
|
+
description: page.data.description,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getSortedPageTree } from "@/lib/source";
|
|
2
|
+
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
3
|
+
import { baseOptions } from "@/lib/layout.shared";
|
|
4
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
5
|
+
|
|
6
|
+
export default function Layout({ children }: LayoutProps<"/docs">) {
|
|
7
|
+
return (
|
|
8
|
+
<DocsLayout
|
|
9
|
+
tree={getSortedPageTree()}
|
|
10
|
+
{...baseOptions()}
|
|
11
|
+
// After the spread: a future sidebar key in baseOptions must not
|
|
12
|
+
// silently swallow the attribution (review finding, 2026-08-18).
|
|
13
|
+
sidebar={{
|
|
14
|
+
footer: (
|
|
15
|
+
<p className="mt-3 text-xs">
|
|
16
|
+
<FooterMark />
|
|
17
|
+
</p>
|
|
18
|
+
),
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</DocsLayout>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "fumadocs-ui/css/neutral.css";
|
|
3
|
+
@import "fumadocs-ui/css/preset.css";
|
|
4
|
+
|
|
5
|
+
/* The KSoR default accent, drawn from the mark — the one brand value here.
|
|
6
|
+
Re-brand by changing this pair; every accented element follows. */
|
|
7
|
+
:root {
|
|
8
|
+
--color-fd-primary: #1d4ed8;
|
|
9
|
+
--color-fd-primary-foreground: #ffffff;
|
|
10
|
+
--color-fd-ring: #1d4ed8;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.dark {
|
|
14
|
+
--color-fd-primary: #7fb0f9;
|
|
15
|
+
--color-fd-primary-foreground: #081226;
|
|
16
|
+
--color-fd-ring: #7fb0f9;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
html {
|
|
20
|
+
scrollbar-gutter: stable;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
html > body[data-scroll-locked] {
|
|
24
|
+
margin-right: 0px !important;
|
|
25
|
+
--removed-body-scroll-bar-size: 0px !important;
|
|
26
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RootProvider } from "fumadocs-ui/provider/next";
|
|
2
|
+
import "./global.css";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import { appTitle } from "@/lib/shared";
|
|
5
|
+
|
|
6
|
+
// No next/font/google: it fetches the face from Google at BUILD time, so a
|
|
7
|
+
// scaffolded project could not build offline and two builds of one commit
|
|
8
|
+
// could differ byte-wise (review finding, 2026-08-18). The system UI stack
|
|
9
|
+
// costs zero bytes and zero network; replace it with a self-hosted @font-face
|
|
10
|
+
// if the project wants a specific face.
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: {
|
|
14
|
+
default: appTitle,
|
|
15
|
+
template: `%s | ${appTitle}`,
|
|
16
|
+
},
|
|
17
|
+
description: "The Knowledge System of Record for humans and AI agents.",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default function Layout({ children }: LayoutProps<"/">) {
|
|
21
|
+
return (
|
|
22
|
+
<html lang="en" suppressHydrationWarning>
|
|
23
|
+
<body className="flex flex-col min-h-screen">
|
|
24
|
+
<RootProvider
|
|
25
|
+
search={{
|
|
26
|
+
// Static search: the browser downloads the index that
|
|
27
|
+
// app/api/search exports at build time (staticGET) and runs
|
|
28
|
+
// Orama client-side — no server needed, so search keeps
|
|
29
|
+
// working on any static host.
|
|
30
|
+
options: {
|
|
31
|
+
type: "static",
|
|
32
|
+
api: `${process.env.KSOR_BASE_PATH ?? ""}/api/search`,
|
|
33
|
+
},
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</RootProvider>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getLLMText, getSortedPages } from "@/lib/source";
|
|
2
|
+
|
|
3
|
+
export const revalidate = false;
|
|
4
|
+
|
|
5
|
+
export async function GET(): Promise<Response> {
|
|
6
|
+
const scan = getSortedPages().map(getLLMText);
|
|
7
|
+
const scanned = await Promise.all(scan);
|
|
8
|
+
|
|
9
|
+
return new Response(scanned.join("\n\n"));
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { basePath, getSortedPages } from "@/lib/source";
|
|
2
|
+
import { appName } from "@/lib/shared";
|
|
3
|
+
|
|
4
|
+
export const revalidate = false;
|
|
5
|
+
|
|
6
|
+
// The agent-facing index of the record: this instance's name, then every
|
|
7
|
+
// document in sidebar order, each link usable as-is on a sub-path host.
|
|
8
|
+
export function GET(): Response {
|
|
9
|
+
const lines = getSortedPages().map((page) => {
|
|
10
|
+
const link = `- [${page.data.title}](${basePath}${page.url})`;
|
|
11
|
+
return page.data.description ? `${link}: ${page.data.description}` : link;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return new Response(`# ${appName}\n\n${lines.join("\n")}\n`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The maker's mark. Attribution, not a dependency: the record and this site
|
|
5
|
+
* are yours (MIT-0) — delete this component and both keep working.
|
|
6
|
+
*/
|
|
7
|
+
export function BuiltWith(): ReactElement {
|
|
8
|
+
return (
|
|
9
|
+
<a
|
|
10
|
+
href="https://github.com/panaversity/ksor"
|
|
11
|
+
target="_blank"
|
|
12
|
+
rel="noreferrer"
|
|
13
|
+
className="text-fd-muted-foreground underline-offset-4 transition-colors hover:text-fd-foreground hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
14
|
+
>
|
|
15
|
+
Built with KSoR
|
|
16
|
+
</a>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { BuiltWith } from "@/components/built-with";
|
|
3
|
+
import { audienceNotice } from "@/lib/audience";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The foot of the site chrome: who built it, and — on any build below the
|
|
7
|
+
* public tier — which audience that build was for, so a screenshot of an
|
|
8
|
+
* internal site names itself and a page that escapes carries its own
|
|
9
|
+
* warning.
|
|
10
|
+
*
|
|
11
|
+
* The public build renders the attribution ALONE, exactly as a site with no
|
|
12
|
+
* audience model does: the one build with nothing to disclose must not even
|
|
13
|
+
* carry the shape of a disclosure.
|
|
14
|
+
*/
|
|
15
|
+
export function FooterMark(): ReactElement {
|
|
16
|
+
if (audienceNotice === null) return <BuiltWith />;
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<BuiltWith /> · <span className="text-fd-muted-foreground">{audienceNotice}</span>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import defaultMdxComponents from "fumadocs-ui/mdx";
|
|
2
|
+
import type { MDXComponents } from "mdx/types";
|
|
3
|
+
|
|
4
|
+
export function getMDXComponents(components?: MDXComponents) {
|
|
5
|
+
return {
|
|
6
|
+
...defaultMdxComponents,
|
|
7
|
+
...components,
|
|
8
|
+
} satisfies MDXComponents;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const useMDXComponents = getMDXComponents;
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { instanceFrontmatter } from "./shared";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The audience model, declared in instance.md — the record says who its
|
|
5
|
+
* readers are, and the build enforces it:
|
|
6
|
+
*
|
|
7
|
+
* audiences:
|
|
8
|
+
* - public
|
|
9
|
+
* - internal
|
|
10
|
+
* - restricted
|
|
11
|
+
* default_visibility: public
|
|
12
|
+
*
|
|
13
|
+
* Ordered least- to most-restricted, so "build the internal site" means
|
|
14
|
+
* "public and internal included" with no further configuration. A record
|
|
15
|
+
* that declares no audiences has no model and publishes every document —
|
|
16
|
+
* the behaviour of every instance written before this key existed.
|
|
17
|
+
*/
|
|
18
|
+
export interface AudienceModel {
|
|
19
|
+
/** Least- to most-restricted, `public` first. */
|
|
20
|
+
readonly audiences: readonly string[];
|
|
21
|
+
/** The tier of a document that declares no `visibility:`. */
|
|
22
|
+
readonly defaultVisibility: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unquote(raw: string): string {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
return /^(['"])(.*)\1$/.exec(trimmed)?.[2] ?? trimmed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every refusal this feature makes: a slug a pipeline can match, then the remedy. */
|
|
31
|
+
export function refuse(slug: string, what: string, why: string, fix: string): never {
|
|
32
|
+
// The slug leads, so a pipeline can match on it, and the three lines below
|
|
33
|
+
// it are the whole remedy — an operator never has to read this file.
|
|
34
|
+
throw new Error(`${slug}: ${what}\n why: ${why}\n fix: ${fix}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readAudienceModel(): AudienceModel | null {
|
|
38
|
+
const block = instanceFrontmatter();
|
|
39
|
+
// Top-level key only: `^` under /m cannot match an indented child.
|
|
40
|
+
if (!/^audiences:/m.test(block)) return null;
|
|
41
|
+
|
|
42
|
+
// The grammar mirrors the checker's exactly — CRLF-tolerant, list items at
|
|
43
|
+
// ANY indent (YAML allows unindented block sequences), and a ` #` comment
|
|
44
|
+
// ends an unquoted entry (all three found live 2026-08-18: records the
|
|
45
|
+
// checker blessed either failed this build or silently lost a tier).
|
|
46
|
+
const stripComment = (value: string): string =>
|
|
47
|
+
/^["']/.test(value.trim()) ? value : value.replace(/\s+#.*$/, "");
|
|
48
|
+
// A line scanner, not a block regex: a blank line among the items or a
|
|
49
|
+
// comment on the key line broke the block capture and refused every build
|
|
50
|
+
// of a checker-green record (review finding, 2026-08-19).
|
|
51
|
+
const flow = /^audiences:[ \t]*\[(.*)\][ \t]*(?:#.*)?$/m.exec(block)?.[1];
|
|
52
|
+
let items: string[] = [];
|
|
53
|
+
if (flow !== undefined) {
|
|
54
|
+
items = flow.split(",");
|
|
55
|
+
} else {
|
|
56
|
+
const lines = block.split("\n");
|
|
57
|
+
const start = lines.findIndex((line) => /^audiences:[ \t]*(?:#.*)?$/.test(line));
|
|
58
|
+
if (start !== -1) {
|
|
59
|
+
for (const line of lines.slice(start + 1)) {
|
|
60
|
+
if (line.trim() === "") continue;
|
|
61
|
+
const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
|
|
62
|
+
if (item === null) break;
|
|
63
|
+
items.push(item[1] ?? "");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const audiences = items
|
|
68
|
+
.map(stripComment)
|
|
69
|
+
.map(unquote)
|
|
70
|
+
.filter((value) => value !== "");
|
|
71
|
+
|
|
72
|
+
// A declared-but-unreadable model must never read as "no model": that is
|
|
73
|
+
// the one parse failure that publishes the whole record.
|
|
74
|
+
if (audiences.length === 0) {
|
|
75
|
+
refuse(
|
|
76
|
+
"ksor-audiences-unreadable",
|
|
77
|
+
"instance.md declares `audiences:` but no audience could be read from it",
|
|
78
|
+
"an unreadable model reads as no model, and no model publishes every document — the one parse failure that leaks",
|
|
79
|
+
"write the audiences as a list, least-restricted first:\n audiences:\n - public\n - internal",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The staging never depends on the checker having run: a
|
|
84
|
+
// most-restrictive-first model would make plain `pnpm build` publish
|
|
85
|
+
// every restricted document with no label (review finding, 2026-08-18).
|
|
86
|
+
if (audiences[0] !== "public") {
|
|
87
|
+
refuse(
|
|
88
|
+
"ksor-audiences-misordered",
|
|
89
|
+
`audiences: must start with public (it starts with "${audiences[0]}")`,
|
|
90
|
+
"the list is ordered least- to most-restricted, and an unset KSOR_AUDIENCE builds the FIRST entry — any other first entry makes the default build the leak",
|
|
91
|
+
"reorder audiences: with public first",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (new Set(audiences).size !== audiences.length) {
|
|
95
|
+
refuse(
|
|
96
|
+
"ksor-audiences-duplicate",
|
|
97
|
+
`audiences: declares a tier twice (${audiences.join(", ")})`,
|
|
98
|
+
"a duplicated tier has two positions in the ordering, and which one a build honours is undefined",
|
|
99
|
+
"remove the duplicate entry",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const defaultVisibility = unquote(
|
|
103
|
+
stripComment(/^default_visibility:[ \t]*(.*)$/m.exec(block)?.[1] ?? ""),
|
|
104
|
+
);
|
|
105
|
+
if (defaultVisibility === "") {
|
|
106
|
+
refuse(
|
|
107
|
+
"ksor-default-visibility-missing",
|
|
108
|
+
"instance.md declares `audiences:` without `default_visibility:`",
|
|
109
|
+
"there is no safe guess: assuming the widest tier leaks on the first document that forgets the key, assuming the narrowest hides the record",
|
|
110
|
+
`add the tier a document without a visibility: key belongs to, e.g. default_visibility: ${audiences[0]}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!audiences.includes(defaultVisibility)) {
|
|
114
|
+
refuse(
|
|
115
|
+
"ksor-default-visibility-undeclared",
|
|
116
|
+
`default_visibility: ${defaultVisibility} is not one of the declared audiences (${audiences.join(", ")})`,
|
|
117
|
+
"every document without a visibility: key belongs to this tier — a tier no build understands is a record no build can publish honestly",
|
|
118
|
+
`set default_visibility: to one of ${audiences.join(", ")}, or declare ${defaultVisibility} in audiences:`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { audiences, defaultVisibility };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The declared model, or null when this record declares none. */
|
|
126
|
+
export const audienceModel: AudienceModel | null = readAudienceModel();
|
|
127
|
+
|
|
128
|
+
function resolveBuildAudience(model: AudienceModel | null): string {
|
|
129
|
+
const requested = process.env.KSOR_AUDIENCE?.trim() ?? "";
|
|
130
|
+
if (model === null) {
|
|
131
|
+
if (requested !== "") {
|
|
132
|
+
refuse(
|
|
133
|
+
"ksor-audiences-not-declared",
|
|
134
|
+
`KSOR_AUDIENCE="${requested}" was requested, but instance.md declares no audiences`,
|
|
135
|
+
"this build would publish every document — a build that cannot filter must never look like one that did",
|
|
136
|
+
"declare the model in instance.md (audiences: + default_visibility:), or build without KSOR_AUDIENCE",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
// Unset means the least-restricted tier: the only default that cannot leak,
|
|
142
|
+
// so `pnpm build` keeps publishing the public site out of the box.
|
|
143
|
+
if (requested === "") return model.audiences[0] as string;
|
|
144
|
+
if (!model.audiences.includes(requested)) {
|
|
145
|
+
refuse(
|
|
146
|
+
"ksor-audience-undeclared",
|
|
147
|
+
`KSOR_AUDIENCE="${requested}" is not an audience this record declares (${model.audiences.join(", ")})`,
|
|
148
|
+
"an unrecognized audience could only be honoured by publishing more than the record names — so it refuses instead of widening",
|
|
149
|
+
`build with one of ${model.audiences.join(", ")}, or add "${requested}" to instance.md's audiences: list`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return requested;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The audience this build publishes for; "" when the record has no model. */
|
|
156
|
+
export const buildAudience: string = resolveBuildAudience(audienceModel);
|
|
157
|
+
|
|
158
|
+
/** Whether a document of this visibility belongs in THIS build. */
|
|
159
|
+
export function visibleInBuild(visibility: string | null): boolean {
|
|
160
|
+
if (audienceModel === null) return true;
|
|
161
|
+
const value =
|
|
162
|
+
visibility === null || visibility === "" ? audienceModel.defaultVisibility : visibility;
|
|
163
|
+
const rank = audienceModel.audiences.indexOf(value);
|
|
164
|
+
// An undeclared visibility is refused, never published: a value no build
|
|
165
|
+
// understands is a typo, and a typo reads as a restriction.
|
|
166
|
+
if (rank === -1) return false;
|
|
167
|
+
return rank <= audienceModel.audiences.indexOf(buildAudience);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a non-public build calls itself, in the site chrome — so a leaked
|
|
172
|
+
* screenshot of an internal site says which audience it was built for. The
|
|
173
|
+
* public build (the least-restricted tier) says nothing new.
|
|
174
|
+
*/
|
|
175
|
+
export const audienceNotice: string | null =
|
|
176
|
+
audienceModel === null || buildAudience === audienceModel.audiences[0]
|
|
177
|
+
? null
|
|
178
|
+
: `${buildAudience} build — not for publication`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
|
2
|
+
import { appTitle } from "./shared";
|
|
3
|
+
|
|
4
|
+
export function baseOptions(): BaseLayoutProps {
|
|
5
|
+
return {
|
|
6
|
+
nav: {
|
|
7
|
+
// The record's display title (instance.md's H1), not the machine slug.
|
|
8
|
+
// Truncated: a long title forced horizontal scroll on mobile without it
|
|
9
|
+
// (found live, 2026-08-18).
|
|
10
|
+
title: (
|
|
11
|
+
<span className="max-w-[60vw] truncate font-medium tracking-tight sm:max-w-none">
|
|
12
|
+
{appTitle}
|
|
13
|
+
</span>
|
|
14
|
+
),
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// The record's one identity source: instance.md's `name:` — the same file
|
|
5
|
+
// every other shell reads, so renaming the instance renames every surface
|
|
6
|
+
// at the next build, and no shell carries a baked-in copy (found live
|
|
7
|
+
// 2026-08-18: a stamped constant survived a restore-from-templates as the
|
|
8
|
+
// literal placeholder name, with every gate green).
|
|
9
|
+
function findInstance(start: string): string {
|
|
10
|
+
let dir = start;
|
|
11
|
+
for (let i = 0; i < 5; i += 1) {
|
|
12
|
+
const candidate = path.join(dir, "instance.md");
|
|
13
|
+
if (existsSync(candidate)) return candidate;
|
|
14
|
+
const parent = path.dirname(dir);
|
|
15
|
+
if (parent === dir) break;
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
throw new Error(
|
|
19
|
+
"instance.md not found — it is the project's identity; build from the project (pnpm dev / pnpm build at the repo root).",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* instance.md's frontmatter block — the configuration every surface reads
|
|
25
|
+
* (identity here, the audience model in lib/audience.ts). Only this block:
|
|
26
|
+
* body prose that looks like a key must never become configuration (review
|
|
27
|
+
* finding, 2026-08-18).
|
|
28
|
+
*/
|
|
29
|
+
export function instanceFrontmatter(): string {
|
|
30
|
+
const text = readFileSync(findInstance(process.cwd()), "utf8");
|
|
31
|
+
// The checker's boundary exactly: BOM stripped, CRLF normalized, lax close.
|
|
32
|
+
const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
|
|
33
|
+
return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readInstanceName(): string {
|
|
37
|
+
const raw = /^name:[ \t]*(.*)$/m.exec(instanceFrontmatter())?.[1]?.trim() ?? "";
|
|
38
|
+
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
39
|
+
const name = unquoted?.[2] ?? raw;
|
|
40
|
+
if (name === "") {
|
|
41
|
+
throw new Error("instance.md carries no name: — it is the project's identity; run pnpm check.");
|
|
42
|
+
}
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const appName: string = readInstanceName();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The record's DISPLAY TITLE: instance.md's first body heading. The slug in
|
|
50
|
+
* `name:` is the machine identity (llms.txt, future citations); the H1 is
|
|
51
|
+
* the human name every page leads with. A fresh scaffold reads "Knowledge
|
|
52
|
+
* System of Record" until the intake interview writes the real one.
|
|
53
|
+
*/
|
|
54
|
+
function readInstanceTitle(): string {
|
|
55
|
+
const text = readFileSync(findInstance(process.cwd()), "utf8");
|
|
56
|
+
const body = text.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, "");
|
|
57
|
+
return /^#[ \t]+(.+)$/m.exec(body)?.[1]?.trim() ?? appName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const appTitle: string = readInstanceTitle();
|