@panaversity/ksor 0.0.30 → 0.0.32
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 +101 -0
- package/README.md +24 -0
- package/dist/cli.mjs +7 -3
- package/dist/{gateway-api-CbFkHZiU-HvlJRjRB.mjs → gateway-api-6nC9x54K-BWFTI_6U.mjs} +1 -1
- package/dist/gateway.mjs +1 -1
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +9 -1
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +9 -1
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +160 -0
- package/templates/scaffold/AGENTS.md +74 -5
- package/templates/scaffold/README.md +24 -1
- package/templates/scaffold/knowledge/what-is-a-ksor.slides.yaml +65 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -1
- package/templates/scaffold/system/site/app/global.css +136 -0
- package/templates/scaffold/system/site/components/deck-viewer.tsx +195 -0
- package/templates/scaffold/system/site/components/mdx.tsx +28 -0
- package/templates/scaffold/system/site/components/slides.tsx +128 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +7 -0
- package/templates/scaffold/system/site/lib/attachments.ts +49 -2
- package/templates/scaffold/system/site/lib/slides-embed.ts +93 -0
- package/templates/scaffold/system/site/lib/slides.ts +123 -0
- package/templates/scaffold/system/site/source.config.ts +33 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Presentation } from "lucide-react";
|
|
4
|
+
import { useState, type ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
import { DeckViewer } from "@/components/deck-viewer";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import type { SlidesEntry } from "@/lib/attachments";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The presentation that teaches this document.
|
|
12
|
+
*
|
|
13
|
+
* The predecessor embeds the deck directly — an always-on `<iframe>` to Google
|
|
14
|
+
* Slides, authored as raw JSX in the lesson's MDX. Two things stop that here,
|
|
15
|
+
* and the second one changed the design rather than just the authoring:
|
|
16
|
+
*
|
|
17
|
+
* 1. `knowledge/` is CommonMark (critical rule 2), so the frame cannot be
|
|
18
|
+
* authored in the document. It is an attachment instead.
|
|
19
|
+
*
|
|
20
|
+
* 2. The scaffold's browser test asserts **zero external requests** on a
|
|
21
|
+
* built page. An always-on frame breaks that on every page carrying a
|
|
22
|
+
* deck — and the guarantee is worth keeping, because it is what makes the
|
|
23
|
+
* site work offline, behind a firewall, and without telling a third party
|
|
24
|
+
* which of your policies someone is reading.
|
|
25
|
+
*
|
|
26
|
+
* So the frame is CLICK-TO-LOAD. Nothing reaches the provider until a reader
|
|
27
|
+
* asks for it: the page ships a placeholder, and the `<iframe>` is created on
|
|
28
|
+
* click. The link out is always available and costs nothing, because a plain
|
|
29
|
+
* `<a>` is not a request.
|
|
30
|
+
*
|
|
31
|
+
* That is a real divergence from the predecessor and it is an improvement
|
|
32
|
+
* rather than a compromise — the reader who only wanted the policy never
|
|
33
|
+
* announces themselves to a slide host.
|
|
34
|
+
*/
|
|
35
|
+
export function Slides({ slides }: { slides: SlidesEntry }): ReactElement {
|
|
36
|
+
const [loaded, setLoaded] = useState(false);
|
|
37
|
+
const provider = slides.provider ?? slides.derivedProvider;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<section aria-label="Teaching aid" className="not-prose mt-8 mb-12">
|
|
41
|
+
{/* A section heading, in the record's own language for one.
|
|
42
|
+
|
|
43
|
+
An earlier version dropped the accent bar and greyed the label, on
|
|
44
|
+
the theory that anything stronger would compete with the document
|
|
45
|
+
title directly above. That went too far: with no marker and no colour
|
|
46
|
+
the block read as loose text rather than as a section (owner, seen
|
|
47
|
+
live). The fix is the established marker at a smaller SIZE, not a
|
|
48
|
+
weaker one — the label carries the accent so it reads as a marker,
|
|
49
|
+
and the title sits one step below the document's. */}
|
|
50
|
+
<header className="mb-6">
|
|
51
|
+
<p className="font-mono text-xs font-medium tracking-[0.12em] text-fd-primary uppercase">
|
|
52
|
+
Teaching aid
|
|
53
|
+
</p>
|
|
54
|
+
<h2 className="mt-2 font-(family-name:--font-display) text-2xl font-semibold tracking-tight text-fd-foreground">
|
|
55
|
+
{slides.title}
|
|
56
|
+
</h2>
|
|
57
|
+
{/* The record's own marker for "a new region starts here": a short
|
|
58
|
+
accent bar riding a full-width hairline. Every study-aid header
|
|
59
|
+
uses it, so a reader has met it before. */}
|
|
60
|
+
<div className="mt-3 h-px w-full bg-fd-border">
|
|
61
|
+
<div className="h-[3px] w-24 -translate-y-px bg-fd-primary" />
|
|
62
|
+
</div>
|
|
63
|
+
{slides.description === undefined ? null : (
|
|
64
|
+
<p className="mt-4 text-sm text-fd-muted-foreground">{slides.description}</p>
|
|
65
|
+
)}
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
<div className="flex flex-col gap-4">
|
|
69
|
+
{/* A deck the record owns needs no link and no permission: it IS the
|
|
70
|
+
presentation. The linked mode below is for an adopter who already
|
|
71
|
+
has one somewhere else. */}
|
|
72
|
+
{slides.deck !== undefined && slides.deck.length > 0 ? (
|
|
73
|
+
<DeckViewer slides={slides.deck} title={slides.title} />
|
|
74
|
+
) : null}
|
|
75
|
+
|
|
76
|
+
{slides.url === undefined ? null : (
|
|
77
|
+
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
78
|
+
<a
|
|
79
|
+
href={slides.url}
|
|
80
|
+
target="_blank"
|
|
81
|
+
rel="noreferrer"
|
|
82
|
+
className="inline-flex items-center gap-1.5 text-fd-primary underline underline-offset-4 transition-colors hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
|
|
83
|
+
>
|
|
84
|
+
Open the full presentation
|
|
85
|
+
<ExternalLink aria-hidden className="size-3.5" />
|
|
86
|
+
</a>
|
|
87
|
+
{provider === undefined ? null : (
|
|
88
|
+
<span className="font-mono text-xs text-fd-muted-foreground">{provider}</span>
|
|
89
|
+
)}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{slides.embed === undefined ? null : (
|
|
94
|
+
<div
|
|
95
|
+
// 16:9, the aspect every deck host serves. A ratio box rather than
|
|
96
|
+
// a fixed height, so the frame scales with the measure instead of
|
|
97
|
+
// letterboxing on a narrow window.
|
|
98
|
+
className="relative w-full overflow-hidden rounded-lg border border-fd-border bg-fd-muted"
|
|
99
|
+
style={{ paddingBottom: "56.25%" }}
|
|
100
|
+
>
|
|
101
|
+
{loaded ? (
|
|
102
|
+
<iframe
|
|
103
|
+
src={slides.embed}
|
|
104
|
+
title={slides.title}
|
|
105
|
+
allowFullScreen
|
|
106
|
+
// No referrer: the provider learns that a deck was opened, not
|
|
107
|
+
// which document of this record it was opened from.
|
|
108
|
+
referrerPolicy="no-referrer"
|
|
109
|
+
loading="lazy"
|
|
110
|
+
className="absolute inset-0 size-full"
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
|
114
|
+
<Presentation aria-hidden className="size-8 text-fd-muted-foreground" />
|
|
115
|
+
<Button onClick={() => setLoaded(true)}>Load the slides</Button>
|
|
116
|
+
<p className="max-w-sm text-xs text-fd-muted-foreground">
|
|
117
|
+
{/* Said plainly, because it is the reason for the click. */}
|
|
118
|
+
The deck is hosted{provider === undefined ? " elsewhere" : ` on ${provider}`}.
|
|
119
|
+
Nothing is requested from there until you load it.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</section>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -22,6 +22,7 @@ export const ATTACHMENT_SUFFIXES = [
|
|
|
22
22
|
{ suffix: ".summary.mdx", kind: "summary" },
|
|
23
23
|
{ suffix: ".flashcards.yaml", kind: "deck" },
|
|
24
24
|
{ suffix: ".quiz.yaml", kind: "quiz" },
|
|
25
|
+
{ suffix: ".slides.yaml", kind: "slides" },
|
|
25
26
|
] as const;
|
|
26
27
|
|
|
27
28
|
export type AttachmentKind = (typeof ATTACHMENT_SUFFIXES)[number]["kind"];
|
|
@@ -40,6 +41,8 @@ export const ATTACHMENT_NEAR_MISSES = [
|
|
|
40
41
|
{ suffix: ".summary.markdown", want: ".summary.md" },
|
|
41
42
|
{ suffix: ".quiz.yml", want: ".quiz.yaml" },
|
|
42
43
|
{ suffix: ".quiz.json", want: ".quiz.yaml" },
|
|
44
|
+
{ suffix: ".slides.yml", want: ".slides.yaml" },
|
|
45
|
+
{ suffix: ".slides.json", want: ".slides.yaml" },
|
|
43
46
|
] as const;
|
|
44
47
|
|
|
45
48
|
/**
|
|
@@ -106,6 +109,7 @@ export const ATTACHMENT_CASES = [
|
|
|
106
109
|
{ name: "returns.summary.md", kind: "summary", parent: "returns.md" },
|
|
107
110
|
{ name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
|
|
108
111
|
{ name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
|
|
112
|
+
{ name: "returns.slides.yaml", kind: "slides", parent: "returns.md" },
|
|
109
113
|
{ name: "index.summary.md", kind: "summary", parent: "index.md" },
|
|
110
114
|
// A stem containing dots keeps every one of them: the parent is the same
|
|
111
115
|
// name with the attachment suffix removed, never "up to the first dot".
|
|
@@ -115,12 +119,14 @@ export const ATTACHMENT_CASES = [
|
|
|
115
119
|
{ name: "summary.md", kind: null, parent: null },
|
|
116
120
|
{ name: "flashcards.yaml", kind: null, parent: null },
|
|
117
121
|
{ name: "quiz.yaml", kind: null, parent: null },
|
|
122
|
+
{ name: "slides.yaml", kind: null, parent: null },
|
|
118
123
|
{ name: "my-summary.md", kind: null, parent: null },
|
|
119
124
|
// A dotfile with no stem attaches to nothing — refused as an attachment so
|
|
120
125
|
// it is refused as an unexpected file instead, which is the honest error.
|
|
121
126
|
{ name: ".summary.md", kind: null, parent: null },
|
|
122
127
|
{ name: ".flashcards.yaml", kind: null, parent: null },
|
|
123
128
|
{ name: ".quiz.yaml", kind: null, parent: null },
|
|
129
|
+
{ name: ".slides.yaml", kind: null, parent: null },
|
|
124
130
|
// Case matters: the record already refuses two names differing only in case,
|
|
125
131
|
// so an uppercase suffix is a different file, not the same rule.
|
|
126
132
|
{ name: "returns.SUMMARY.md", kind: null, parent: null },
|
|
@@ -128,4 +134,5 @@ export const ATTACHMENT_CASES = [
|
|
|
128
134
|
{ name: "returns.flashcards.yml", kind: null, parent: null },
|
|
129
135
|
{ name: "returns.flashcards.json", kind: null, parent: null },
|
|
130
136
|
{ name: "returns.quiz.yml", kind: null, parent: null },
|
|
137
|
+
{ name: "returns.slides.yml", kind: null, parent: null },
|
|
131
138
|
] as const;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { decks, quizzes, summaries } from "collections/server";
|
|
1
|
+
import { decks, quizzes, slides, summaries } from "collections/server";
|
|
2
2
|
|
|
3
3
|
import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
|
|
4
4
|
import { cardHash, type Card, type Deck } from "./deck";
|
|
5
5
|
import { newCard, type CardSchedule } from "./srs";
|
|
6
6
|
import { type Question, type Quiz } from "./quiz";
|
|
7
|
+
import { type Slide, type Slides } from "./slides";
|
|
8
|
+
import { embedUrlFor, providerOf } from "./slides-embed";
|
|
7
9
|
import { DEFAULT_QUESTIONS_PER_ROUND } from "./quiz-round";
|
|
8
10
|
import { questionHash } from "./identity";
|
|
9
11
|
|
|
@@ -117,12 +119,57 @@ export function quizFor(documentPath: string): QuizEntry | null {
|
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/** One slide the record carries. */
|
|
123
|
+
export type DeckSlide = Slide;
|
|
124
|
+
|
|
125
|
+
export interface SlidesEntry {
|
|
126
|
+
readonly title: string;
|
|
127
|
+
/** Absent when the record owns the deck — see `deck` below. */
|
|
128
|
+
readonly url?: string;
|
|
129
|
+
readonly description?: string;
|
|
130
|
+
/** Explicit `provider:`, when the author named one. */
|
|
131
|
+
readonly provider?: string;
|
|
132
|
+
/** Derived from the host when they did not — never guessed beyond the table. */
|
|
133
|
+
readonly derivedProvider?: string;
|
|
134
|
+
/** The framable url: the author's `embed:`, or one derived from `url`. */
|
|
135
|
+
readonly embed?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Slides the record owns. When present this is the presentation, and there
|
|
138
|
+
* is no url — the schema refuses both, because two decks have no answer to
|
|
139
|
+
* which one governs.
|
|
140
|
+
*/
|
|
141
|
+
readonly deck?: readonly DeckSlide[];
|
|
142
|
+
readonly path: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The presentation for a document, or null. */
|
|
146
|
+
export function slidesFor(documentPath: string): SlidesEntry | null {
|
|
147
|
+
const wanted = attachmentPath(documentPath, ".slides.yaml");
|
|
148
|
+
const hit = slides.find((entry) => entry.info.path === wanted);
|
|
149
|
+
if (hit === undefined) return null;
|
|
150
|
+
const parsed = hit as unknown as Slides & { readonly info: { readonly path: string } };
|
|
151
|
+
const d = parsed.slides;
|
|
152
|
+
return {
|
|
153
|
+
title: d.title,
|
|
154
|
+
url: d.url,
|
|
155
|
+
deck: parsed.deck,
|
|
156
|
+
description: d.description,
|
|
157
|
+
provider: d.provider,
|
|
158
|
+
derivedProvider: d.url === undefined ? undefined : (providerOf(d.url) ?? undefined),
|
|
159
|
+
// An author's explicit embed wins; otherwise derive one, and a provider we
|
|
160
|
+
// do not know simply renders as a link.
|
|
161
|
+
embed: d.url === undefined ? undefined : (d.embed ?? embedUrlFor(d.url) ?? undefined),
|
|
162
|
+
path: parsed.info.path,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
/** True when a document has ANY attachment — the presence gate for the UI. */
|
|
121
167
|
export function hasAttachments(documentPath: string): boolean {
|
|
122
168
|
return (
|
|
123
169
|
summaryFor(documentPath) !== null ||
|
|
124
170
|
deckFor(documentPath) !== null ||
|
|
125
|
-
quizFor(documentPath) !== null
|
|
171
|
+
quizFor(documentPath) !== null ||
|
|
172
|
+
slidesFor(documentPath) !== null
|
|
126
173
|
);
|
|
127
174
|
}
|
|
128
175
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a presentation's share link into the url that can be framed.
|
|
3
|
+
*
|
|
4
|
+
* Every deck host publishes two different urls — the one you get from "Share"
|
|
5
|
+
* and the one that works in an `<iframe>` — and an author will paste the first.
|
|
6
|
+
* Deriving the second is a small rule per provider, and getting it wrong is
|
|
7
|
+
* invisible until someone opens the page, so it is a table with tests rather
|
|
8
|
+
* than a regex written once.
|
|
9
|
+
*
|
|
10
|
+
* A LEAF: no imports. `slides.ts` carries zod and cannot enter the unit tier.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Only https. A browser blocks an http frame inside a secure page as mixed
|
|
15
|
+
* content, so an http embed publishes a panel that silently never loads —
|
|
16
|
+
* which is worse than refusing it, because nothing goes red.
|
|
17
|
+
*/
|
|
18
|
+
export function isHttpsUrl(value: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(value).protocol === "https:";
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One provider's rule: which hosts it owns, and how a share url becomes an
|
|
28
|
+
* embed url. Extend this rather than special-casing at a call site.
|
|
29
|
+
*/
|
|
30
|
+
interface Provider {
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly hosts: readonly string[];
|
|
33
|
+
readonly embed: (url: URL) => string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PROVIDERS: readonly Provider[] = [
|
|
37
|
+
{
|
|
38
|
+
label: "Google Slides",
|
|
39
|
+
hosts: ["docs.google.com"],
|
|
40
|
+
// /presentation/d/<id>/edit -> /presentation/d/<id>/embed
|
|
41
|
+
embed: (url) => {
|
|
42
|
+
const match = /^\/presentation\/d\/([^/]+)/.exec(url.pathname);
|
|
43
|
+
if (match === null) return null;
|
|
44
|
+
return `https://docs.google.com/presentation/d/${match[1]}/embed?start=false&loop=false&delayms=3000`;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: "Canva",
|
|
49
|
+
hosts: ["www.canva.com", "canva.com"],
|
|
50
|
+
// /design/<id>/<token>/view -> same, with ?embed
|
|
51
|
+
embed: (url) => {
|
|
52
|
+
const match = /^\/design\/([^/]+)\/([^/]+)/.exec(url.pathname);
|
|
53
|
+
if (match === null) return null;
|
|
54
|
+
return `https://www.canva.com/design/${match[1]}/${match[2]}/view?embed`;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: "SlideShare",
|
|
59
|
+
hosts: ["www.slideshare.net", "slideshare.net"],
|
|
60
|
+
// Only an explicit /slideshow/embed_code/ url is framable.
|
|
61
|
+
embed: (url) => (url.pathname.startsWith("/slideshow/embed_code/") ? url.toString() : null),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** The provider's own name for a url, or null when it is not one we know. */
|
|
66
|
+
export function providerOf(value: string): string | null {
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(value);
|
|
69
|
+
return PROVIDERS.find((p) => p.hosts.includes(url.hostname))?.label ?? null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The url to put in the frame, or null when this one cannot be derived.
|
|
77
|
+
*
|
|
78
|
+
* Null is an ordinary answer, not an error: the deck still renders as a link,
|
|
79
|
+
* and an author who wants a frame supplies `embed:` explicitly.
|
|
80
|
+
*/
|
|
81
|
+
export function embedUrlFor(value: string): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(value);
|
|
84
|
+
if (url.protocol !== "https:") return null;
|
|
85
|
+
const provider = PROVIDERS.find((p) => p.hosts.includes(url.hostname));
|
|
86
|
+
return provider?.embed(url) ?? null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every provider whose share url this understands, for docs and errors. */
|
|
93
|
+
export const KNOWN_PROVIDERS: readonly string[] = PROVIDERS.map((p) => p.label);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { embedUrlFor, isHttpsUrl } from "./slides-embed";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shape of a `<doc>.slides.yaml` — a presentation that teaches this
|
|
7
|
+
* document.
|
|
8
|
+
*
|
|
9
|
+
* The predecessor authors this INLINE in MDX: a `## 📚 Teaching Aid` heading,
|
|
10
|
+
* a `:::tip` with the link, and a raw `<div style={{…}}>` wrapping an
|
|
11
|
+
* `<iframe>` (`specs/crashcourses/connector-native-apps/…md`). None of that is
|
|
12
|
+
* available here and the reason is critical rule 2: `knowledge/` is CommonMark
|
|
13
|
+
* only, so a document cannot carry raw JSX and stay readable in a plain
|
|
14
|
+
* markdown viewer. The deck is an attachment instead, which also means it
|
|
15
|
+
* inherits the document's tier and takedown rather than being an embed nobody
|
|
16
|
+
* governs.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* One slide, as the record carries it.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately NOT freeform markdown or HTML. A slide is a heading and a few
|
|
22
|
+
* lines, and admitting arbitrary markup would put layout into the record —
|
|
23
|
+
* which is the same mistake as embedding a deck, one level down. What a slide
|
|
24
|
+
* says is knowledge; how it looks is the site's business.
|
|
25
|
+
*/
|
|
26
|
+
export const SlideSchema = z.object({
|
|
27
|
+
heading: z.string().min(1).max(120),
|
|
28
|
+
/**
|
|
29
|
+
* Three to five in practice. The cap is six because a slide someone reads
|
|
30
|
+
* aloud is a slide nobody listens to, and a limit is the only thing that
|
|
31
|
+
* reliably stops a generator from pasting a paragraph per slide.
|
|
32
|
+
*/
|
|
33
|
+
bullets: z.array(z.string().min(1).max(240)).max(6).optional(),
|
|
34
|
+
/** One line under the heading, for a slide that makes a single point. */
|
|
35
|
+
lead: z.string().max(300).optional(),
|
|
36
|
+
/** Spoken, not shown. Rendered for the presenter, never on the slide. */
|
|
37
|
+
note: z.string().max(600).optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const SlidesSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
slides: z.object({
|
|
43
|
+
title: z.string().min(1).max(120),
|
|
44
|
+
/**
|
|
45
|
+
* Where the deck lives. HTTPS only — an http:// embed is blocked as
|
|
46
|
+
* mixed content on any deployed site, so accepting one would publish a
|
|
47
|
+
* frame that silently never loads.
|
|
48
|
+
*/
|
|
49
|
+
url: z.string().url().max(2000).optional(),
|
|
50
|
+
/**
|
|
51
|
+
* The provider's EMBED url, when it differs from the share url. Optional
|
|
52
|
+
* because `embedUrlFor` derives it for providers whose rule is known;
|
|
53
|
+
* required in practice for any provider whose is not, and a deck with
|
|
54
|
+
* neither renders as a link rather than as a broken frame.
|
|
55
|
+
*/
|
|
56
|
+
embed: z.string().url().max(2000).optional(),
|
|
57
|
+
/** Shown beside the link — "Google Slides", "Canva". Never inferred. */
|
|
58
|
+
provider: z.string().max(60).optional(),
|
|
59
|
+
/** One line under the title, if the deck needs introducing. */
|
|
60
|
+
description: z.string().max(300).optional(),
|
|
61
|
+
}),
|
|
62
|
+
/**
|
|
63
|
+
* The slides themselves, when the record carries them.
|
|
64
|
+
*
|
|
65
|
+
* This is the mode that makes the workflow complete: an agent writes these
|
|
66
|
+
* from the document with no browser and no third party, and the site
|
|
67
|
+
* renders the presentation. The deck is then governed like everything else
|
|
68
|
+
* here — reviewed in a PR, versioned with its document, withdrawn with it —
|
|
69
|
+
* and it cannot rot into a dead link, because there is no link.
|
|
70
|
+
*/
|
|
71
|
+
deck: z.array(SlideSchema).min(1).max(60).optional(),
|
|
72
|
+
})
|
|
73
|
+
.superRefine((value, ctx) => {
|
|
74
|
+
// Exactly one source. A deck that is BOTH authored here and embedded from
|
|
75
|
+
// elsewhere has two versions and no answer to which one governs — which is
|
|
76
|
+
// the disagreement this whole product exists to settle.
|
|
77
|
+
const authored = (value.deck?.length ?? 0) > 0;
|
|
78
|
+
const linked = value.slides.url !== undefined;
|
|
79
|
+
if (!authored && !linked) {
|
|
80
|
+
ctx.addIssue({
|
|
81
|
+
code: "custom",
|
|
82
|
+
path: ["deck"],
|
|
83
|
+
message:
|
|
84
|
+
"ksor-slides-empty: a presentation needs either `deck:` (slides the record carries) or `slides.url:` (a deck hosted elsewhere) — with neither there is nothing to show",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (authored && linked) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: "custom",
|
|
90
|
+
path: ["deck"],
|
|
91
|
+
message:
|
|
92
|
+
"ksor-slides-two-sources: this declares both `deck:` and `slides.url:`, so there are two presentations and nothing says which one governs — keep the one the record owns, or drop `deck:` and keep the link",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const key of ["url", "embed"] as const) {
|
|
97
|
+
const candidate = value.slides[key];
|
|
98
|
+
if (candidate !== undefined && !isHttpsUrl(candidate)) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: "custom",
|
|
101
|
+
path: ["slides", key],
|
|
102
|
+
message: `ksor-slides-insecure: ${key} must be https:// — a browser blocks an http:// frame on a secure page as mixed content, so this would publish a deck that silently never loads`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A deck nobody can embed is still a legitimate deck — it renders as a
|
|
107
|
+
// link. But a deck we cannot embed AND whose provider we cannot name is
|
|
108
|
+
// worth telling the author about, because they probably expected a frame.
|
|
109
|
+
if (
|
|
110
|
+
linked &&
|
|
111
|
+
value.slides.embed === undefined &&
|
|
112
|
+
embedUrlFor(value.slides.url ?? "") === null
|
|
113
|
+
) {
|
|
114
|
+
ctx.addIssue({
|
|
115
|
+
code: "custom",
|
|
116
|
+
path: ["slides", "embed"],
|
|
117
|
+
message: `ksor-slides-no-embed: this url has no embed form ksor knows how to derive, so the deck would render as a link only — add an explicit \`embed:\` url if you want it shown inline`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export type Slides = z.infer<typeof SlidesSchema>;
|
|
123
|
+
export type Slide = z.infer<typeof SlideSchema>;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config";
|
|
2
|
+
import { remarkCodeTab } from "fumadocs-core/mdx-plugins/remark-code-tab";
|
|
2
3
|
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { DeckSchema } from "./lib/deck";
|
|
5
6
|
import { QuizSchema } from "./lib/quiz";
|
|
7
|
+
import { SlidesSchema } from "./lib/slides";
|
|
6
8
|
import { knowledgeSourceDir } from "./lib/stage-knowledge";
|
|
7
9
|
|
|
8
10
|
// The record lives at <repo>/knowledge — two levels up from this site.
|
|
@@ -103,8 +105,38 @@ export const quizzes = defineCollections({
|
|
|
103
105
|
schema: QuizSchema,
|
|
104
106
|
});
|
|
105
107
|
|
|
108
|
+
/** The presentation that teaches a document — see components/slides.tsx. */
|
|
109
|
+
export const slides = defineCollections({
|
|
110
|
+
type: "meta",
|
|
111
|
+
dir: knowledgeSourceDir(),
|
|
112
|
+
files: ["**/*.slides.yaml"],
|
|
113
|
+
schema: SlidesSchema,
|
|
114
|
+
});
|
|
115
|
+
|
|
106
116
|
export default defineConfig({
|
|
107
117
|
mdxOptions: {
|
|
108
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Alternative versions of the same instruction, as TABS.
|
|
120
|
+
*
|
|
121
|
+
* A record often has to say the same thing twice — one way for one tool,
|
|
122
|
+
* one for another — and stacking both is how a reader follows the wrong
|
|
123
|
+
* one. `remarkCodeTab` turns consecutive fenced blocks that declare a
|
|
124
|
+
* `tab="…"` into a tab group.
|
|
125
|
+
*
|
|
126
|
+
* The reason this works HERE, where a JSX `<Tabs>` cannot: a fence's info
|
|
127
|
+
* string is free text in CommonMark. `\`\`\`bash tab="Claude Code"` is a
|
|
128
|
+
* perfectly ordinary bash block to every other markdown reader, which sees
|
|
129
|
+
* both blocks one after another and is not misled — it just does not get
|
|
130
|
+
* to pick. So the record stays framework-free (critical rule 2) and the
|
|
131
|
+
* site still renders the affordance.
|
|
132
|
+
*
|
|
133
|
+
* `CodeBlockTabs` rather than `Tabs`, and the difference is not cosmetic:
|
|
134
|
+
* only that branch honours `tab-group`, which is what makes ONE choice
|
|
135
|
+
* apply to every group on the page and persist to the next visit. The
|
|
136
|
+
* `Tabs` branch drops the attribute silently, so a reader with a
|
|
137
|
+
* ten-section document would pick their tool ten times (verified against
|
|
138
|
+
* fumadocs-core 16.14.5, remark-code-tab.js).
|
|
139
|
+
*/
|
|
140
|
+
remarkPlugins: [[remarkCodeTab, { Tabs: "CodeBlockTabs" }]],
|
|
109
141
|
},
|
|
110
142
|
});
|