@panaversity/ksor 0.0.26 → 0.0.28
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 +65 -0
- package/dist/cli.mjs +67 -4
- package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
- package/templates/scaffold/AGENTS.md +49 -0
- package/templates/scaffold/Dockerfile +10 -6
- package/templates/scaffold/README.md +3 -2
- package/templates/scaffold/dockerignore +5 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
- package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
- package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
- package/templates/scaffold/system/site/components/governance.tsx +17 -25
- package/templates/scaffold/system/site/components/record-views.tsx +241 -0
- package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
- package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
- package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
- package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
- package/templates/scaffold/system/site/lib/attachments.ts +105 -0
- package/templates/scaffold/system/site/lib/deck.ts +59 -0
- package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
- package/templates/scaffold/system/site/lib/srs.ts +219 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
- package/templates/scaffold/system/site/source.config.ts +54 -1
- package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
|
+
import { ATTACHMENT_SUFFIXES, isAttachment, parentDocumentOf } from "./attachment-rule";
|
|
14
15
|
import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
|
|
15
16
|
import { isDenied, recordPathFrom, stableIdFrom, type DenylistManifest } from "./denial-rule";
|
|
16
17
|
import { appName, instanceFrontmatter } from "./shared";
|
|
@@ -316,11 +317,22 @@ function stableIdOf(recordDir: string, file: string, text: string): string {
|
|
|
316
317
|
);
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The suffixes staging probes for — DERIVED from the shared rule, so a new
|
|
322
|
+
* attachment kind cannot be added there and forgotten here.
|
|
323
|
+
*/
|
|
324
|
+
const ATTACHMENT_SUFFIXES_FOR_STAGE: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
|
|
325
|
+
|
|
319
326
|
function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
320
327
|
const documents: string[] = [];
|
|
321
328
|
const assets = new Set<string>();
|
|
322
329
|
let total = 0;
|
|
323
330
|
for (const file of walkFiles(recordDir)) {
|
|
331
|
+
// An attachment is not a document: it is neither counted nor filtered on
|
|
332
|
+
// its own terms. It rides in below, with the parent that survived — which
|
|
333
|
+
// is the whole of governance inheritance, obtained by POSITION rather than
|
|
334
|
+
// by a second rule that could disagree with this one.
|
|
335
|
+
if (isAttachment(path.basename(file))) continue;
|
|
324
336
|
if (!file.toLowerCase().endsWith(".md")) continue;
|
|
325
337
|
total += 1;
|
|
326
338
|
const text = readFileSync(file, "utf8");
|
|
@@ -332,6 +344,13 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
|
332
344
|
if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
|
|
333
345
|
continue;
|
|
334
346
|
documents.push(file);
|
|
347
|
+
// The parent survived BOTH filters, so its attachments may be published.
|
|
348
|
+
// Reached only here: a filtered or denied parent never gets this far, so
|
|
349
|
+
// there is no path on which an attachment is staged without its parent.
|
|
350
|
+
for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
|
|
351
|
+
const attachment = file.replace(/\.mdx?$/i, "") + suffix;
|
|
352
|
+
if (existsSync(attachment)) assets.add(attachment);
|
|
353
|
+
}
|
|
335
354
|
// Body only: frontmatter carries no links in the record grammar, and
|
|
336
355
|
// scanning it here while the other shell strips it staged different
|
|
337
356
|
// asset sets from one record (review finding, 2026-08-18).
|
|
@@ -539,6 +558,53 @@ function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest
|
|
|
539
558
|
* deleted or mistyped `audiences:` block would otherwise publish every
|
|
540
559
|
* restricted document on a green build (vis-docusaurus, 2026-08-18).
|
|
541
560
|
*/
|
|
561
|
+
/**
|
|
562
|
+
* Attachments the record cannot publish, refused at the BUILD.
|
|
563
|
+
*
|
|
564
|
+
* Staging never depends on the checker having run, so both rules need a home
|
|
565
|
+
* here as well as in `pnpm check` — the checker is where they get a good
|
|
566
|
+
* message, this is where they are guaranteed.
|
|
567
|
+
*
|
|
568
|
+
* Runs on every path, including the level-0 fast path that stages nothing:
|
|
569
|
+
* an orphan is a governance hole whether or not this record declares
|
|
570
|
+
* audiences.
|
|
571
|
+
*/
|
|
572
|
+
function assertAttachmentsWellFormed(recordDir: string): void {
|
|
573
|
+
for (const file of walkFiles(recordDir)) {
|
|
574
|
+
const base = path.basename(file);
|
|
575
|
+
if (!isAttachment(base)) continue;
|
|
576
|
+
const rel = path.relative(recordDir, file);
|
|
577
|
+
|
|
578
|
+
const parent = parentDocumentOf(base);
|
|
579
|
+
if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
|
|
580
|
+
refuse(
|
|
581
|
+
"ksor-attachment-orphan",
|
|
582
|
+
`${rel} is an attachment of ${parent}, which is not in the record`,
|
|
583
|
+
"an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
|
|
584
|
+
`add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// No frontmatter, at all. One rule kills the whole widening class:
|
|
589
|
+
// no `visibility:` claiming a tier the parent does not have, no `sor_id:`
|
|
590
|
+
// escaping the parent's takedown, no `status:`/`owner:` claiming
|
|
591
|
+
// governance a thing with no id cannot carry.
|
|
592
|
+
if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
|
|
593
|
+
const text = readFileSync(file, "utf8")
|
|
594
|
+
.replace(/^\uFEFF/, "")
|
|
595
|
+
.replaceAll("\r\n", "\n");
|
|
596
|
+
if (text.startsWith("---\n")) {
|
|
597
|
+
refuse(
|
|
598
|
+
"ksor-attachment-frontmatter",
|
|
599
|
+
`${rel} declares frontmatter`,
|
|
600
|
+
"an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing",
|
|
601
|
+
`remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
542
608
|
function refuseVisibilityWithoutAudiences(recordDir: string): void {
|
|
543
609
|
for (const file of walkFiles(recordDir)) {
|
|
544
610
|
if (!file.toLowerCase().endsWith(".md")) continue;
|
|
@@ -640,9 +706,11 @@ export function knowledgeSourceDir(): string {
|
|
|
640
706
|
// taking a lock on every build.
|
|
641
707
|
if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
|
|
642
708
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
709
|
+
assertAttachmentsWellFormed(recordDir);
|
|
643
710
|
return RECORD_DIR;
|
|
644
711
|
}
|
|
645
712
|
if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
|
|
713
|
+
assertAttachmentsWellFormed(recordDir);
|
|
646
714
|
fillStage(recordDir, stageDir, denied);
|
|
647
715
|
watchRecord(recordDir, stageDir);
|
|
648
716
|
return STAGE_DIR;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
|
|
1
|
+
import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config";
|
|
2
2
|
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { DeckSchema } from "./lib/deck";
|
|
4
5
|
import { knowledgeSourceDir } from "./lib/stage-knowledge";
|
|
5
6
|
|
|
6
7
|
// The record lives at <repo>/knowledge — two levels up from this site.
|
|
@@ -15,6 +16,15 @@ import { knowledgeSourceDir } from "./lib/stage-knowledge";
|
|
|
15
16
|
export const docs = defineDocs({
|
|
16
17
|
dir: knowledgeSourceDir(),
|
|
17
18
|
docs: {
|
|
19
|
+
// ONE exclusion, and it is the whole of "an attachment is not a document".
|
|
20
|
+
// The route table, the sidebar, llms.txt, llms-full.txt, /md/, the search
|
|
21
|
+
// index and the caveat map ALL read `source`, and `source` reads exactly
|
|
22
|
+
// this collection — so subtracting attachments here subtracts them from
|
|
23
|
+
// every surface at once. Doing it per-surface instead is the failure mode
|
|
24
|
+
// research/visibility.md §4-§5 is cited for; pruning the page tree is not
|
|
25
|
+
// even sufficient, because getSortedPages() deliberately re-adds what the
|
|
26
|
+
// tree dropped and the search index never consults the tree.
|
|
27
|
+
files: ["**/*.md", "**/*.mdx", "!**/*.summary.md", "!**/*.summary.mdx"],
|
|
18
28
|
schema: pageSchema
|
|
19
29
|
.extend({
|
|
20
30
|
status: z.string().optional(),
|
|
@@ -30,10 +40,53 @@ export const docs = defineDocs({
|
|
|
30
40
|
},
|
|
31
41
|
},
|
|
32
42
|
meta: {
|
|
43
|
+
// PINNED, and not optional. The default meta glob is `**/*.{yaml,json}`,
|
|
44
|
+
// which swallows every `<doc>.flashcards.yaml` in the record and fails the
|
|
45
|
+
// build with a zod error naming neither the file's purpose nor the rule
|
|
46
|
+
// (verified against the real record: the default glob returned the deck).
|
|
47
|
+
files: ["**/meta.{json,yaml}"],
|
|
33
48
|
schema: metaSchema,
|
|
34
49
|
},
|
|
35
50
|
});
|
|
36
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A document's summary, rendered on the document's own page.
|
|
54
|
+
*
|
|
55
|
+
* Its own collection rather than a page: it goes through the SAME MDX pipeline
|
|
56
|
+
* as the record — the same prose voice, code handling and heading anchors — but
|
|
57
|
+
* is never handed to `loader()`, so it has no route and appears on no agent
|
|
58
|
+
* surface. The parent's own bytes are untouched by its presence.
|
|
59
|
+
*/
|
|
60
|
+
export const summaries = defineCollections({
|
|
61
|
+
type: "doc",
|
|
62
|
+
dir: knowledgeSourceDir(),
|
|
63
|
+
files: ["**/*.summary.md"],
|
|
64
|
+
postprocess: {
|
|
65
|
+
// So the page can count the summary's words for its reading time without
|
|
66
|
+
// reading the file again. `getText("raw")` would go back to disk, and it
|
|
67
|
+
// resolves against the working directory rather than the collection's dir
|
|
68
|
+
// — which fails the export outright (found live: ENOENT on
|
|
69
|
+
// knowledge/<doc>.md during prerender).
|
|
70
|
+
includeProcessedMarkdown: true,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A document's recall deck.
|
|
76
|
+
*
|
|
77
|
+
* `type: "meta"` because fumadocs parses YAML for meta collections itself
|
|
78
|
+
* (dist/meta-BR_rkCyY.js: `.yaml` -> yaml.parse, `.json` -> JSON.parse, and
|
|
79
|
+
* anything else throws). That is why this needs no YAML parser and no new
|
|
80
|
+
* dependency — `yaml` is already fumadocs-mdx's own — and why the extension is
|
|
81
|
+
* exactly `.yaml`: `.yml` reaches that `throw` and names only the path.
|
|
82
|
+
*/
|
|
83
|
+
export const decks = defineCollections({
|
|
84
|
+
type: "meta",
|
|
85
|
+
dir: knowledgeSourceDir(),
|
|
86
|
+
files: ["**/*.flashcards.yaml"],
|
|
87
|
+
schema: DeckSchema,
|
|
88
|
+
});
|
|
89
|
+
|
|
37
90
|
export default defineConfig({
|
|
38
91
|
mdxOptions: {
|
|
39
92
|
// MDX options
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useCopyButton } from "fumadocs-ui/utils/use-copy-button";
|
|
4
|
-
import { Check, Copy } from "lucide-react";
|
|
5
|
-
import { useState, type ReactElement } from "react";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Hand this document to an agent: copy the governed markdown, verbatim.
|
|
9
|
-
*
|
|
10
|
-
* The record's second audience reads bytes, and until now a reader who wanted
|
|
11
|
-
* to give an agent a document had to open its markdown twin and select the
|
|
12
|
-
* page. This fetches that same twin — the one `/md/<path>.md` already serves,
|
|
13
|
-
* so there is no second rendering of the document to drift — and puts it on the
|
|
14
|
-
* clipboard.
|
|
15
|
-
*
|
|
16
|
-
* Fumadocs ships an `ai/page-actions` component that does this alongside "Open
|
|
17
|
-
* in ChatGPT" and "Open in Claude". Those two are deliberately NOT taken: this
|
|
18
|
-
* product's claim is that one corpus answers in ANY assistant because the
|
|
19
|
-
* surface is an open standard, and hardcoding two vendors into every adopter's
|
|
20
|
-
* page argues the opposite. What is taken is the shell's own `useCopyButton`,
|
|
21
|
-
* which owns the copied-state timing — the only part worth not rewriting.
|
|
22
|
-
*
|
|
23
|
-
* It rests in the muted grey the rest of the row's metadata wears and takes the
|
|
24
|
-
* accent only when it has actually copied. An accent at REST said "link" on a
|
|
25
|
-
* row of facts and added to a page that was already too blue; an accent on the
|
|
26
|
-
* state CHANGE is the one thing the accent is for.
|
|
27
|
-
*/
|
|
28
|
-
export function CopyMarkdown({ href }: { href: string }): ReactElement {
|
|
29
|
-
const [failed, setFailed] = useState(false);
|
|
30
|
-
const [copied, onClick] = useCopyButton(async () => {
|
|
31
|
-
setFailed(false);
|
|
32
|
-
try {
|
|
33
|
-
// `navigator.clipboard` exists only in a secure context, so a site served
|
|
34
|
-
// over plain http on a LAN address has no clipboard at all. Saying so
|
|
35
|
-
// beats a button that reports success it did not have.
|
|
36
|
-
if (navigator.clipboard === undefined) throw new Error("no clipboard");
|
|
37
|
-
const response = await fetch(href);
|
|
38
|
-
if (!response.ok) throw new Error(`markdown twin returned ${response.status}`);
|
|
39
|
-
await navigator.clipboard.writeText(await response.text());
|
|
40
|
-
} catch {
|
|
41
|
-
setFailed(true);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<button
|
|
47
|
-
type="button"
|
|
48
|
-
onClick={onClick}
|
|
49
|
-
// aria-live, because the label is the only feedback: a screen reader that
|
|
50
|
-
// does not hear "Copied" is told nothing happened at all.
|
|
51
|
-
aria-live="polite"
|
|
52
|
-
// The hover tone is dropped while copied, not overridden: a `hover:`
|
|
53
|
-
// variant is a class AND a pseudo-class, so it outranks a plain colour
|
|
54
|
-
// class — and the pointer is by definition still over the button at the
|
|
55
|
-
// moment it has just been clicked, so the success colour never showed
|
|
56
|
-
// (measured 2026-08-22: lab(4.41), the foreground, where the accent
|
|
57
|
-
// belonged).
|
|
58
|
-
className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] uppercase transition-colors hover:bg-fd-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring ${
|
|
59
|
-
copied ? "text-fd-primary" : "text-fd-muted-foreground hover:text-fd-foreground"
|
|
60
|
-
}`}
|
|
61
|
-
>
|
|
62
|
-
{copied ? (
|
|
63
|
-
<Check aria-hidden className="size-3.5" />
|
|
64
|
-
) : (
|
|
65
|
-
<Copy aria-hidden className="size-3.5" />
|
|
66
|
-
)}
|
|
67
|
-
{failed ? "Copy failed" : copied ? "Copied" : "Copy"}
|
|
68
|
-
</button>
|
|
69
|
-
);
|
|
70
|
-
}
|