@ctcstack/ctcdocs-core 0.16.2 → 0.18.0

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/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  The configuration layer shared by the [CTCDocs](https://github.com/ctcstack/ctcdocs)
4
4
  packages: the schema of a project's `site.config.json`, the fixed project
5
5
  layout, and the allowlist of paths the synchronization pipeline may write.
6
+ Its `published-markdown` entry serializes a page's Markdown version, which
7
+ the site serves and the sync measures.
6
8
 
7
9
  A project does not install this package directly — `@ctcstack/ctcdocs` and
8
10
  `@ctcstack/ctcdocs-sync` depend on it. It is documented because what it
@@ -19,6 +19,12 @@ export interface CorpusDocument {
19
19
  * on its new chain to be confirmed.
20
20
  */
21
21
  readonly publishedReaders?: '*' | readonly string[];
22
+ /** The permanent short ID (ADR-022), the title and Drive's modified time. */
23
+ readonly shortId?: string;
24
+ readonly title?: string;
25
+ readonly modified?: string;
26
+ /** The Google Doc or PDF in Drive, as the page links it. */
27
+ readonly source?: string;
22
28
  }
23
29
  export interface CorpusStructure {
24
30
  readonly rootFolderId: string | null;
@@ -57,11 +57,23 @@ export function parseCorpusStructure(manifest) {
57
57
  const documents = new Map();
58
58
  for (const [id, raw] of entries(source.documents, 'documents')) {
59
59
  const path = `documents.${id}`;
60
- const published = raw.publishedReaders;
60
+ const record = raw;
61
+ const published = record.publishedReaders;
62
+ const optional = (key) => typeof record[key] === 'string' && record[key].length > 0
63
+ ? record[key]
64
+ : undefined;
65
+ const shortId = optional('shortId');
66
+ const title = optional('displayTitle');
67
+ const modified = optional('googleModifiedTime');
68
+ const source = optional('sourceUrl');
61
69
  documents.set(id, {
62
70
  id,
63
71
  parentId: field(raw, 'googleParentId', path, true),
64
72
  slug: field(raw, 'stableSlug', path) ?? '',
73
+ ...(shortId ? { shortId } : {}),
74
+ ...(title ? { title } : {}),
75
+ ...(modified ? { modified } : {}),
76
+ ...(source ? { source } : {}),
65
77
  ...(published === '*' ||
66
78
  (Array.isArray(published) &&
67
79
  published.every((group) => typeof group === 'string'))
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
1
+ export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, markdownProjectionPath, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, resolvePermanentLink, SHORT_ID_PATTERN, } from './project-layout.js';
2
2
  export { assertGeneratedPathAllowed, isGeneratedPathAllowed, normalizeRepositoryPath, } from './generated-paths.js';
3
3
  export { generatedMarkdownHeader, generatedSourceHeader, } from './ownership-markers.js';
4
4
  export { findProjectRoot, ProjectRootError } from './project-root.js';
@@ -10,5 +10,5 @@ export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccess
10
10
  export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers, } from './access-classes.js';
11
11
  export { accessFindings } from './access-findings.js';
12
12
  export type { AccessFinding } from './access-findings.js';
13
- export { loadSiteConfiguration, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
14
- export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
13
+ export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
14
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, McpConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
1
+ export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, markdownProjectionPath, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, resolvePermanentLink, SHORT_ID_PATTERN, } from './project-layout.js';
2
2
  export { assertGeneratedPathAllowed, isGeneratedPathAllowed, normalizeRepositoryPath, } from './generated-paths.js';
3
3
  export { generatedMarkdownHeader, generatedSourceHeader, } from './ownership-markers.js';
4
4
  export { findProjectRoot, ProjectRootError } from './project-root.js';
@@ -6,4 +6,4 @@ export { EVERY_MEMBER, parseAccessConfiguration, } from './access-configuration.
6
6
  export { CorpusStructureError, EMPTY_CORPUS, parseCorpusStructure, readCorpusStructure, } from './corpus-structure.js';
7
7
  export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccessModel, documentClass, folderChain, folderClass, intersectReaders, MEMBERS_CLASS, nextPublishedReaders, widensReaders, } from './access-classes.js';
8
8
  export { accessFindings } from './access-findings.js';
9
- export { loadSiteConfiguration, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
9
+ export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
@@ -80,6 +80,16 @@ export declare const PLATFORM_WORKERS: {
80
80
  readonly accessMapAlias: "ctcdocs-access-map";
81
81
  readonly stateBinding: "KB_STATE";
82
82
  readonly directorySchedule: "*/10 * * * *";
83
+ /** The MCP server's OAuth state; the library requires this name (ADR-041). */
84
+ readonly oauthBinding: "OAUTH_KV";
85
+ /** The R2 bucket the Worker publishes each document's Markdown to. */
86
+ readonly documentsBinding: "KB_DOCUMENTS";
87
+ /** The AI Search instance that indexes that bucket. */
88
+ readonly searchBinding: "KB_SEARCH";
89
+ /** How often the site's Worker checks that the bucket matches its build. */
90
+ readonly publishSchedule: "*/5 * * * *";
91
+ /** Lets the OAuth library fetch a client's metadata document safely. */
92
+ readonly oauthCompatibilityFlag: "global_fetch_strictly_public";
83
93
  };
84
94
  /**
85
95
  * Where the Worker tells a signed-in reader which search bundles, beyond the
@@ -100,5 +110,25 @@ export declare const RESERVED_SLUGS: readonly string[];
100
110
  export declare const SHORT_ID_PATTERN: RegExp;
101
111
  /** The site-relative permanent link of an item with this short ID. */
102
112
  export declare function permanentLinkPath(shortId: string): string;
113
+ /**
114
+ * Renders a permanent link as the address it currently leads to.
115
+ *
116
+ * The sync writes a link between two documents as the target's permanent
117
+ * link, `/d/<short ID>/`, so the document holding it never changes when the
118
+ * target is renamed or moved (ADR-022). The redirect map says where each one
119
+ * leads today. Resolving it while the site builds sends a reader straight to
120
+ * the page instead of through a redirect page on every click.
121
+ *
122
+ * A permanent link the map does not know is left as it is: it is either a
123
+ * hand-written link to nothing, which the sync already refuses for generated
124
+ * pages, or one a later build will know.
125
+ */
126
+ export declare function resolvePermanentLink(value: string, redirects: Readonly<Record<string, string>>): string | undefined;
127
+ /**
128
+ * The site path of a page's Markdown version, from its route ID (ADR-010):
129
+ * the one definition, shared by the route that serves it, the pages that link
130
+ * it and the sync that measures it.
131
+ */
132
+ export declare function markdownProjectionPath(slug: string): string;
103
133
  export declare const GENERATED_DIRECTORY_ALLOWLIST: readonly ["src/content/docs/_generated", "src/assets/generated", "src/generated"];
104
134
  export declare const GENERATED_FILE_ALLOWLIST: readonly ["data/sync-manifest.json", "data/docs-index.json", "data/latest-sync-report.json", "data/title-report.json"];
@@ -80,6 +80,16 @@ export const PLATFORM_WORKERS = {
80
80
  accessMapAlias: 'ctcdocs-access-map',
81
81
  stateBinding: 'KB_STATE',
82
82
  directorySchedule: '*/10 * * * *',
83
+ /** The MCP server's OAuth state; the library requires this name (ADR-041). */
84
+ oauthBinding: 'OAUTH_KV',
85
+ /** The R2 bucket the Worker publishes each document's Markdown to. */
86
+ documentsBinding: 'KB_DOCUMENTS',
87
+ /** The AI Search instance that indexes that bucket. */
88
+ searchBinding: 'KB_SEARCH',
89
+ /** How often the site's Worker checks that the bucket matches its build. */
90
+ publishSchedule: '*/5 * * * *',
91
+ /** Lets the OAuth library fetch a client's metadata document safely. */
92
+ oauthCompatibilityFlag: 'global_fetch_strictly_public',
83
93
  };
84
94
  /**
85
95
  * Where the Worker tells a signed-in reader which search bundles, beyond the
@@ -93,8 +103,10 @@ export const PLATFORM_ROUTE_HREFS = {
93
103
  };
94
104
  /**
95
105
  * Top-level addresses the platform serves beside the corpus: the Worker's
96
- * sign-in routes (ADR-038), the search bundles, `/pagefind/` and
97
- * `/pagefind-<class>/` (ADR-039), and the original files under `/assets/`.
106
+ * sign-in and OAuth routes (ADR-038, ADR-041), the search bundles,
107
+ * `/pagefind/` and `/pagefind-<class>/` (ADR-039), and the original files
108
+ * under `/assets/`. The MCP server needs none: it answers `/mcp` exactly
109
+ * (ADR-041), and a page is always served under its trailing slash.
98
110
  */
99
111
  const SERVED_ADDRESSES = ['auth', 'pagefind', 'assets'];
100
112
  export const RESERVED_SLUGS = Object.freeze([
@@ -111,6 +123,38 @@ export const SHORT_ID_PATTERN = /^[0-9a-f]{6,64}$/u;
111
123
  export function permanentLinkPath(shortId) {
112
124
  return `/${PLATFORM_ROUTES.permanentLinks}/${shortId}/`;
113
125
  }
126
+ /**
127
+ * Renders a permanent link as the address it currently leads to.
128
+ *
129
+ * The sync writes a link between two documents as the target's permanent
130
+ * link, `/d/<short ID>/`, so the document holding it never changes when the
131
+ * target is renamed or moved (ADR-022). The redirect map says where each one
132
+ * leads today. Resolving it while the site builds sends a reader straight to
133
+ * the page instead of through a redirect page on every click.
134
+ *
135
+ * A permanent link the map does not know is left as it is: it is either a
136
+ * hand-written link to nothing, which the sync already refuses for generated
137
+ * pages, or one a later build will know.
138
+ */
139
+ export function resolvePermanentLink(value, redirects) {
140
+ const prefix = `/${PLATFORM_ROUTES.permanentLinks}/`;
141
+ if (!value.startsWith(prefix)) {
142
+ return undefined;
143
+ }
144
+ const suffixStart = value.search(/[?#]/u);
145
+ const path = suffixStart < 0 ? value : value.slice(0, suffixStart);
146
+ const suffix = suffixStart < 0 ? '' : value.slice(suffixStart);
147
+ const target = redirects[path.endsWith('/') ? path : `${path}/`];
148
+ return target === undefined ? undefined : `${target}${suffix}`;
149
+ }
150
+ /**
151
+ * The site path of a page's Markdown version, from its route ID (ADR-010):
152
+ * the one definition, shared by the route that serves it, the pages that link
153
+ * it and the sync that measures it.
154
+ */
155
+ export function markdownProjectionPath(slug) {
156
+ return `/${slug}/index.md`;
157
+ }
114
158
  export const GENERATED_DIRECTORY_ALLOWLIST = [
115
159
  PROJECT_LAYOUT.generatedDocumentsDirectory,
116
160
  PROJECT_LAYOUT.generatedAssetsDirectory,
@@ -0,0 +1,44 @@
1
+ export interface PublishedMarkdownInput {
2
+ title: string;
3
+ /**
4
+ * The ownership marker the generated file opens with, which is stripped from
5
+ * the published form. It is passed in rather than read from the project so
6
+ * this stays a pure function of its input.
7
+ */
8
+ ownershipHeader: string;
9
+ sourceUrl: string;
10
+ /** The published PDF, when the page is one's and the site serves it. */
11
+ fileUrl?: string;
12
+ googleModifiedTime: string;
13
+ syncedAt: string;
14
+ contentHash: string;
15
+ body: string;
16
+ stableSlugs: ReadonlySet<string>;
17
+ /**
18
+ * Each permanent link, `/d/<short ID>/`, to the address it leads to. A link
19
+ * between documents is stored as one (ADR-022); the projection names the
20
+ * target's own Markdown instead, as it does for any other corpus link.
21
+ */
22
+ permanentLinks: Readonly<Record<string, string>>;
23
+ }
24
+ /** Where the site serves a published PDF, as its Markdown version names it. */
25
+ export declare function publishedFileUrl(googleFileId: string, file: string): string;
26
+ /**
27
+ * One line of inline Markdown that reads as the text it was given: whitespace,
28
+ * newlines included, collapses to single spaces, and every character that
29
+ * would open emphasis, code, a link, HTML or a heading is escaped. Used for
30
+ * titles, which come from Drive names and may contain any of them.
31
+ */
32
+ export declare function inlineMarkdown(value: string): string;
33
+ /**
34
+ * The page's Markdown without its front matter: its title as a heading and
35
+ * its body. It is what the MCP server stores, indexes and returns from
36
+ * `fetch` (ADR-042), so it is also what the sync measures (ADR-043).
37
+ */
38
+ export declare function publishedMarkdownBody(input: Pick<PublishedMarkdownInput, 'title' | 'ownershipHeader' | 'body' | 'stableSlugs' | 'permanentLinks'>): string;
39
+ /**
40
+ * The page's Markdown version (ADR-010): front matter, then
41
+ * `publishedMarkdownBody`. Each front matter value is one JSON string, so no
42
+ * value spans a line and the first `---` line after the opening one closes it.
43
+ */
44
+ export declare function serializePublishedMarkdown(input: PublishedMarkdownInput): string;
@@ -0,0 +1,132 @@
1
+ import * as cheerio from 'cheerio';
2
+ import remarkGfm from 'remark-gfm';
3
+ import remarkParse from 'remark-parse';
4
+ import remarkStringify from 'remark-stringify';
5
+ import { unified } from 'unified';
6
+ import { markdownProjectionPath, resolvePermanentLink, } from './project-layout.js';
7
+ const processor = unified()
8
+ .use(remarkParse)
9
+ .use(remarkGfm)
10
+ .use(remarkStringify, {
11
+ bullet: '-',
12
+ emphasis: '*',
13
+ fences: true,
14
+ listItemIndent: 'one',
15
+ strong: '*',
16
+ });
17
+ function walk(node, visit) {
18
+ if (node.type !== 'root') {
19
+ visit(node);
20
+ }
21
+ if ('children' in node) {
22
+ for (const child of node.children) {
23
+ walk(child, visit);
24
+ }
25
+ }
26
+ }
27
+ function markdownUrl(value, stableSlugs, permanentLinks) {
28
+ const resolved = resolvePermanentLink(value, permanentLinks);
29
+ const link = resolved ?? value;
30
+ if (!link.startsWith('/')) {
31
+ return resolved;
32
+ }
33
+ let url;
34
+ try {
35
+ url = new URL(link, 'https://wiki.invalid');
36
+ }
37
+ catch {
38
+ return resolved;
39
+ }
40
+ const slug = url.pathname.replace(/^\/+|\/+$/gu, '');
41
+ if (!stableSlugs.has(slug)) {
42
+ return resolved;
43
+ }
44
+ return `${markdownProjectionPath(slug)}${url.search}${url.hash}`;
45
+ }
46
+ function rewriteInternalLinks(body, stableSlugs, permanentLinks) {
47
+ const tree = processor.parse(body);
48
+ walk(tree, (node) => {
49
+ if (node.type === 'link') {
50
+ node.url = markdownUrl(node.url, stableSlugs, permanentLinks) ?? node.url;
51
+ return;
52
+ }
53
+ if (node.type !== 'html') {
54
+ return;
55
+ }
56
+ const $ = cheerio.load(node.value, null, false);
57
+ let changed = false;
58
+ $('a[href]').each((_, anchor) => {
59
+ const href = $(anchor).attr('href');
60
+ const rewritten = href
61
+ ? markdownUrl(href, stableSlugs, permanentLinks)
62
+ : undefined;
63
+ if (rewritten) {
64
+ $(anchor).attr('href', rewritten);
65
+ changed = true;
66
+ }
67
+ });
68
+ if (changed) {
69
+ node.value = $.root().html() ?? '';
70
+ }
71
+ });
72
+ return processor.stringify(tree).trimEnd();
73
+ }
74
+ function cleanBody(body, ownershipHeader) {
75
+ const normalized = body.replace(/\r\n?/gu, '\n');
76
+ if (normalized !== ownershipHeader &&
77
+ !normalized.startsWith(`${ownershipHeader}\n`)) {
78
+ throw new Error('Generated Google document is missing the expected ownership marker.');
79
+ }
80
+ return normalized.slice(ownershipHeader.length).trim();
81
+ }
82
+ /** Where the site serves a published PDF, as its Markdown version names it. */
83
+ export function publishedFileUrl(googleFileId, file) {
84
+ return `/assets/generated/${googleFileId}/${file}`;
85
+ }
86
+ function yamlString(value) {
87
+ return JSON.stringify(value);
88
+ }
89
+ /**
90
+ * One line of inline Markdown that reads as the text it was given: whitespace,
91
+ * newlines included, collapses to single spaces, and every character that
92
+ * would open emphasis, code, a link, HTML or a heading is escaped. Used for
93
+ * titles, which come from Drive names and may contain any of them.
94
+ */
95
+ export function inlineMarkdown(value) {
96
+ return value
97
+ .replace(/\s+/gu, ' ')
98
+ .trim()
99
+ .replace(/([\\`*_[\]<>#])/gu, '\\$1');
100
+ }
101
+ /**
102
+ * The page's Markdown without its front matter: its title as a heading and
103
+ * its body. It is what the MCP server stores, indexes and returns from
104
+ * `fetch` (ADR-042), so it is also what the sync measures (ADR-043).
105
+ */
106
+ export function publishedMarkdownBody(input) {
107
+ const body = rewriteInternalLinks(cleanBody(input.body, input.ownershipHeader), input.stableSlugs, input.permanentLinks);
108
+ return [
109
+ `# ${inlineMarkdown(input.title)}`,
110
+ '',
111
+ ...(body ? [body, ''] : []),
112
+ ].join('\n');
113
+ }
114
+ /**
115
+ * The page's Markdown version (ADR-010): front matter, then
116
+ * `publishedMarkdownBody`. Each front matter value is one JSON string, so no
117
+ * value spans a line and the first `---` line after the opening one closes it.
118
+ */
119
+ export function serializePublishedMarkdown(input) {
120
+ return [
121
+ '---',
122
+ `title: ${yamlString(input.title)}`,
123
+ `source_url: ${yamlString(input.sourceUrl)}`,
124
+ ...(input.fileUrl ? [`file_url: ${yamlString(input.fileUrl)}`] : []),
125
+ `modified_at: ${yamlString(input.googleModifiedTime)}`,
126
+ `synced_at: ${yamlString(input.syncedAt)}`,
127
+ `content_hash: ${yamlString(input.contentHash)}`,
128
+ '---',
129
+ '',
130
+ publishedMarkdownBody(input),
131
+ ].join('\n');
132
+ }
@@ -94,6 +94,13 @@ export interface SyncConfigurationDefaults {
94
94
  * project sets the line it wants numbers for. Defaults to 2.
95
95
  */
96
96
  readonly largeImageMegabytes: number;
97
+ /**
98
+ * The length, in characters of a document's Markdown body, above which the
99
+ * sync report notes the document as worth splitting (ADR-043). No greater
100
+ * than the characters an assistant reads of a document; defaults to 40,000,
101
+ * or to that limit where it is lower.
102
+ */
103
+ readonly largeDocumentCharacters: number;
97
104
  }
98
105
  export interface NavigationConfiguration {
99
106
  /**
@@ -135,9 +142,66 @@ export type AddressPolicy = 'stable' | 'follow-names';
135
142
  export interface SignInConfiguration {
136
143
  readonly workspaceDomains: readonly string[];
137
144
  }
145
+ /**
146
+ * How the MCP server searches and how much a search returns (ADR-042). Every
147
+ * value has a default, the starting point ADR-042 records, so that a project
148
+ * tunes it against its own corpus without a new version of the platform.
149
+ */
150
+ export interface McpSearchConfiguration {
151
+ /** Chunks asked of AI Search for each query: 1 to 50. */
152
+ readonly chunks: number;
153
+ /**
154
+ * The vector similarity below which AI Search drops a chunk, 0 to 1. It
155
+ * drops only chunks vector search alone found, and AI Search ignores it
156
+ * while reranking is on (ADR-042), so it acts only with reranking off.
157
+ */
158
+ readonly vectorThreshold: number;
159
+ /** Whether a keyword match needs every word of the query, or any. */
160
+ readonly keywordMatch: 'and' | 'or';
161
+ /**
162
+ * Neighbouring chunks AI Search joins to each side of a match: 0 to 3. A
163
+ * chunk of AI Search's default size is already longer than a passage.
164
+ */
165
+ readonly contextChunks: number;
166
+ readonly reranking: {
167
+ readonly enabled: boolean;
168
+ /** An AI Search reranking model. */
169
+ readonly model: string;
170
+ /** The reranking score below which a chunk is dropped, 0 to 1. */
171
+ readonly threshold: number;
172
+ };
173
+ /** Documents a search returns at most; no more than `chunks`. */
174
+ readonly results: number;
175
+ /** Passages each document shows at most. */
176
+ readonly passagesPerResult: number;
177
+ /** Characters of passage text one search returns at most, in all. */
178
+ readonly passageCharacters: number;
179
+ }
180
+ /**
181
+ * The MCP server through which AI assistants read the site as the person who
182
+ * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
183
+ * only on a deployment whose every environment is private.
184
+ */
185
+ export interface McpConfiguration {
186
+ readonly enabled: boolean;
187
+ readonly search: McpSearchConfiguration;
188
+ /** Characters `fetch` returns at most; a longer document is cut there. */
189
+ readonly fetchCharacters: number;
190
+ }
191
+ /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
192
+ export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
193
+ /**
194
+ * The characters an assistant reads of a document: the cut `fetch` makes
195
+ * (ADR-042), or its default while the MCP server is off. The build writes
196
+ * this value into the access map and the sync measures documents against it
197
+ * (ADR-043), so the two never derive it apart.
198
+ */
199
+ export declare function fetchCharacterLimit(mcp: McpConfiguration | undefined): number;
138
200
  export interface SiteConfiguration {
139
201
  /** Required to serve a private environment through the platform's Worker. */
140
202
  readonly signIn?: SignInConfiguration;
203
+ /** Absent or disabled, the Worker serves no MCP or OAuth route. */
204
+ readonly mcp?: McpConfiguration;
141
205
  /**
142
206
  * Who may read which folder (ADR-039). Absent, every document is open to
143
207
  * every reader the deployment admits, as before access rules existed.
@@ -15,6 +15,37 @@ import { readFileSync } from 'node:fs';
15
15
  import { resolve } from 'node:path';
16
16
  import { parseAccessConfiguration, } from './access-configuration.js';
17
17
  import { PROJECT_LAYOUT } from './project-layout.js';
18
+ /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
19
+ export const MCP_DEFAULTS = Object.freeze({
20
+ search: Object.freeze({
21
+ chunks: 50,
22
+ vectorThreshold: 0.2,
23
+ keywordMatch: 'or',
24
+ contextChunks: 0,
25
+ reranking: Object.freeze({
26
+ enabled: true,
27
+ model: '@cf/baai/bge-reranker-base',
28
+ threshold: 0,
29
+ }),
30
+ results: 10,
31
+ passagesPerResult: 3,
32
+ passageCharacters: 24_000,
33
+ }),
34
+ fetchCharacters: 100_000,
35
+ });
36
+ /**
37
+ * The characters an assistant reads of a document: the cut `fetch` makes
38
+ * (ADR-042), or its default while the MCP server is off. The build writes
39
+ * this value into the access map and the sync measures documents against it
40
+ * (ADR-043), so the two never derive it apart.
41
+ */
42
+ export function fetchCharacterLimit(mcp) {
43
+ return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
44
+ }
45
+ /** About 10,000 tokens of English: a starting value, not a measured one. */
46
+ const LARGE_DOCUMENT_CHARACTERS = 40_000;
47
+ /** A passage of fewer characters than this says too little to judge by. */
48
+ const SHORTEST_PASSAGE = 100;
18
49
  export class SiteConfigurationError extends Error {
19
50
  name = 'SiteConfigurationError';
20
51
  }
@@ -136,6 +167,73 @@ function optionalCount(source, key, path, fallback) {
136
167
  return value;
137
168
  }
138
169
  /** A size above zero, in megabytes; a fraction such as 1.5 is allowed. */
170
+ /** A number between `min` and `max`, whole when `whole`, or the fallback. */
171
+ function optionalNumber(source, key, path, fallback, { min, max, whole }) {
172
+ const value = source[key];
173
+ if (value === undefined) {
174
+ return fallback;
175
+ }
176
+ if (typeof value !== 'number' ||
177
+ !Number.isFinite(value) ||
178
+ (whole && !Number.isInteger(value)) ||
179
+ value < min ||
180
+ (max !== undefined && value > max)) {
181
+ const kind = whole ? 'a whole number' : 'a number';
182
+ fail(path, max === undefined
183
+ ? `must be ${kind} of at least ${min}`
184
+ : `must be ${kind} from ${min} to ${max}`);
185
+ }
186
+ return value;
187
+ }
188
+ /** Fails on a key the section does not define. */
189
+ function knownKeys(source, path, keys) {
190
+ for (const key of Object.keys(source)) {
191
+ if (!keys.includes(key)) {
192
+ fail(`${path}.${key}`, 'is not a known setting');
193
+ }
194
+ }
195
+ }
196
+ function mcpSearch(value) {
197
+ const defaults = MCP_DEFAULTS.search;
198
+ if (value === undefined) {
199
+ return defaults;
200
+ }
201
+ const source = record(value, 'mcp.search');
202
+ knownKeys(source, 'mcp.search', Object.keys(defaults));
203
+ const chunks = optionalNumber(source, 'chunks', 'mcp.search.chunks', defaults.chunks, {
204
+ min: 1,
205
+ max: 50,
206
+ whole: true,
207
+ });
208
+ const keywordMatch = source.keywordMatch ?? defaults.keywordMatch;
209
+ if (keywordMatch !== 'and' && keywordMatch !== 'or') {
210
+ fail('mcp.search.keywordMatch', 'must be "and" or "or"');
211
+ }
212
+ let reranking = defaults.reranking;
213
+ if (source.reranking !== undefined) {
214
+ const rerankingSource = record(source.reranking, 'mcp.search.reranking');
215
+ knownKeys(rerankingSource, 'mcp.search.reranking', Object.keys(reranking));
216
+ reranking = {
217
+ enabled: optionalFlag(rerankingSource, 'enabled', 'mcp.search.reranking.enabled', reranking.enabled),
218
+ model: rerankingSource.model === undefined
219
+ ? reranking.model
220
+ : text(rerankingSource, 'model', 'mcp.search.reranking.model'),
221
+ threshold: optionalNumber(rerankingSource, 'threshold', 'mcp.search.reranking.threshold', reranking.threshold, { min: 0, max: 1, whole: false }),
222
+ };
223
+ }
224
+ // The default, like a set value, is no more than the chunks asked for.
225
+ const results = optionalNumber(source, 'results', 'mcp.search.results', Math.min(defaults.results, chunks), { min: 1, max: chunks, whole: true });
226
+ return {
227
+ chunks,
228
+ vectorThreshold: optionalNumber(source, 'vectorThreshold', 'mcp.search.vectorThreshold', defaults.vectorThreshold, { min: 0, max: 1, whole: false }),
229
+ keywordMatch,
230
+ contextChunks: optionalNumber(source, 'contextChunks', 'mcp.search.contextChunks', defaults.contextChunks, { min: 0, max: 3, whole: true }),
231
+ reranking,
232
+ results,
233
+ passagesPerResult: optionalNumber(source, 'passagesPerResult', 'mcp.search.passagesPerResult', defaults.passagesPerResult, { min: 1, whole: true }),
234
+ passageCharacters: optionalNumber(source, 'passageCharacters', 'mcp.search.passageCharacters', defaults.passageCharacters, { min: results * SHORTEST_PASSAGE, whole: true }),
235
+ };
236
+ }
139
237
  function optionalMegabytes(source, key, path, fallback) {
140
238
  const value = source[key];
141
239
  if (value === undefined) {
@@ -215,6 +313,14 @@ export function parseSiteConfiguration(input) {
215
313
  fail('deployment.workerName', 'must be lowercase letters, digits, and hyphens');
216
314
  }
217
315
  const syncSource = record(root.sync, 'sync');
316
+ // A misspelled optional line would otherwise fall back to its default.
317
+ knownKeys(syncSource, 'sync', [
318
+ 'generatedBy',
319
+ 'commitBotName',
320
+ 'defaultLocale',
321
+ 'largeImageMegabytes',
322
+ 'largeDocumentCharacters',
323
+ ]);
218
324
  const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
219
325
  // The marker is embedded in an HTML comment in every generated Markdown
220
326
  // file. A comment delimiter inside it would terminate that comment early and
@@ -232,6 +338,7 @@ export function parseSiteConfiguration(input) {
232
338
  environments: environments(deploymentSource),
233
339
  workerName,
234
340
  };
341
+ const publicEnvironment = Object.entries(deployment.environments).find(([, environment]) => environment.visibility === 'public');
235
342
  /*
236
343
  * Rules close folders to some readers, which a public environment cannot
237
344
  * do: everyone may read it. Accepting rules there would promise a boundary
@@ -239,7 +346,6 @@ export function parseSiteConfiguration(input) {
239
346
  */
240
347
  let access;
241
348
  if (root.access !== undefined) {
242
- const publicEnvironment = Object.entries(deployment.environments).find(([, environment]) => environment.visibility === 'public');
243
349
  if (publicEnvironment) {
244
350
  fail('access', `must not be set while the ${publicEnvironment[0]} environment is public`);
245
351
  }
@@ -269,8 +375,29 @@ export function parseSiteConfiguration(input) {
269
375
  }
270
376
  signIn = { workspaceDomains: Object.freeze(parsed) };
271
377
  }
378
+ let mcp;
379
+ if (root.mcp !== undefined) {
380
+ const source = record(root.mcp, 'mcp');
381
+ knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
382
+ const enabled = flag(source, 'enabled', 'mcp.enabled');
383
+ if (enabled) {
384
+ if (!signIn) {
385
+ fail('mcp', 'needs signIn: assistants sign in as the site does');
386
+ }
387
+ if (publicEnvironment) {
388
+ fail('mcp', `must not be enabled while the ${publicEnvironment[0]} environment is public`);
389
+ }
390
+ }
391
+ mcp = {
392
+ enabled,
393
+ search: mcpSearch(source.search),
394
+ fetchCharacters: optionalNumber(source, 'fetchCharacters', 'mcp.fetchCharacters', MCP_DEFAULTS.fetchCharacters, { min: 1_000, whole: true }),
395
+ };
396
+ }
397
+ const fetchLimit = fetchCharacterLimit(mcp);
272
398
  return {
273
399
  ...(signIn ? { signIn } : {}),
400
+ ...(mcp ? { mcp } : {}),
274
401
  ...(access ? { access } : {}),
275
402
  brand,
276
403
  deployment,
@@ -290,6 +417,7 @@ export function parseSiteConfiguration(input) {
290
417
  defaultLocale,
291
418
  generatedBy,
292
419
  largeImageMegabytes: optionalMegabytes(syncSource, 'largeImageMegabytes', 'sync.largeImageMegabytes', 2),
420
+ largeDocumentCharacters: optionalNumber(syncSource, 'largeDocumentCharacters', 'sync.largeDocumentCharacters', Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit), { min: 1, max: fetchLimit, whole: true }),
293
421
  },
294
422
  };
295
423
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.16.2",
4
- "description": "Configuration schema, project layout, and generated-path allowlist shared by the CTCDocs platform packages.",
3
+ "version": "0.18.0",
4
+ "description": "Configuration schema, project layout, generated-path allowlist and the published Markdown form shared by the CTCDocs platform packages.",
5
5
  "keywords": [
6
6
  "ctcdocs",
7
7
  "configuration",
@@ -27,6 +27,10 @@
27
27
  ".": {
28
28
  "types": "./dist/index.d.ts",
29
29
  "default": "./dist/index.js"
30
+ },
31
+ "./published-markdown": {
32
+ "types": "./dist/published-markdown.d.ts",
33
+ "default": "./dist/published-markdown.js"
30
34
  }
31
35
  },
32
36
  "files": [
@@ -37,7 +41,15 @@
37
41
  "engines": {
38
42
  "node": ">=22.12.0"
39
43
  },
44
+ "dependencies": {
45
+ "cheerio": "1.2.0",
46
+ "remark-gfm": "4.0.1",
47
+ "remark-parse": "11.0.0",
48
+ "remark-stringify": "11.0.0",
49
+ "unified": "11.0.5"
50
+ },
40
51
  "devDependencies": {
52
+ "@types/mdast": "4.0.4",
41
53
  "@types/node": "22.20.1",
42
54
  "fast-check": "4.10.2",
43
55
  "typescript": "6.0.3",
@@ -33,6 +33,12 @@ export interface CorpusDocument {
33
33
  * on its new chain to be confirmed.
34
34
  */
35
35
  readonly publishedReaders?: '*' | readonly string[];
36
+ /** The permanent short ID (ADR-022), the title and Drive's modified time. */
37
+ readonly shortId?: string;
38
+ readonly title?: string;
39
+ readonly modified?: string;
40
+ /** The Google Doc or PDF in Drive, as the page links it. */
41
+ readonly source?: string;
36
42
  }
37
43
 
38
44
  export interface CorpusStructure {
@@ -101,11 +107,24 @@ export function parseCorpusStructure(manifest: unknown): CorpusStructure {
101
107
  const documents = new Map<string, CorpusDocument>();
102
108
  for (const [id, raw] of entries(source.documents, 'documents')) {
103
109
  const path = `documents.${id}`;
104
- const published = (raw as Record<string, unknown>).publishedReaders;
110
+ const record = raw as Record<string, unknown>;
111
+ const published = record.publishedReaders;
112
+ const optional = (key: string) =>
113
+ typeof record[key] === 'string' && record[key].length > 0
114
+ ? record[key]
115
+ : undefined;
116
+ const shortId = optional('shortId');
117
+ const title = optional('displayTitle');
118
+ const modified = optional('googleModifiedTime');
119
+ const source = optional('sourceUrl');
105
120
  documents.set(id, {
106
121
  id,
107
122
  parentId: field(raw, 'googleParentId', path, true),
108
123
  slug: field(raw, 'stableSlug', path) ?? '',
124
+ ...(shortId ? { shortId } : {}),
125
+ ...(title ? { title } : {}),
126
+ ...(modified ? { modified } : {}),
127
+ ...(source ? { source } : {}),
109
128
  ...(published === '*' ||
110
129
  (Array.isArray(published) &&
111
130
  published.every((group) => typeof group === 'string'))
package/src/index.ts CHANGED
@@ -2,12 +2,14 @@ export {
2
2
  ACCESS_CLASSES_ROUTE,
3
3
  GENERATED_DIRECTORY_ALLOWLIST,
4
4
  GENERATED_FILE_ALLOWLIST,
5
+ markdownProjectionPath,
5
6
  PLATFORM_ROUTE_HREFS,
6
7
  PLATFORM_ROUTES,
7
8
  PLATFORM_WORKERS,
8
9
  permanentLinkPath,
9
10
  PROJECT_LAYOUT,
10
11
  RESERVED_SLUGS,
12
+ resolvePermanentLink,
11
13
  SHORT_ID_PATTERN,
12
14
  } from './project-layout.js';
13
15
  export {
@@ -63,7 +65,9 @@ export type {
63
65
  export { accessFindings } from './access-findings.js';
64
66
  export type { AccessFinding } from './access-findings.js';
65
67
  export {
68
+ fetchCharacterLimit,
66
69
  loadSiteConfiguration,
70
+ MCP_DEFAULTS,
67
71
  parseSiteConfiguration,
68
72
  SiteConfigurationError,
69
73
  } from './site-configuration.js';
@@ -75,6 +79,8 @@ export type {
75
79
  DeploymentEnvironmentConfigurations,
76
80
  DeploymentVisibility,
77
81
  HomeConfiguration,
82
+ McpConfiguration,
83
+ McpSearchConfiguration,
78
84
  NavigationConfiguration,
79
85
  SignInConfiguration,
80
86
  SiteConfiguration,
@@ -82,6 +82,16 @@ export const PLATFORM_WORKERS = {
82
82
  accessMapAlias: 'ctcdocs-access-map',
83
83
  stateBinding: 'KB_STATE',
84
84
  directorySchedule: '*/10 * * * *',
85
+ /** The MCP server's OAuth state; the library requires this name (ADR-041). */
86
+ oauthBinding: 'OAUTH_KV',
87
+ /** The R2 bucket the Worker publishes each document's Markdown to. */
88
+ documentsBinding: 'KB_DOCUMENTS',
89
+ /** The AI Search instance that indexes that bucket. */
90
+ searchBinding: 'KB_SEARCH',
91
+ /** How often the site's Worker checks that the bucket matches its build. */
92
+ publishSchedule: '*/5 * * * *',
93
+ /** Lets the OAuth library fetch a client's metadata document safely. */
94
+ oauthCompatibilityFlag: 'global_fetch_strictly_public',
85
95
  } as const;
86
96
 
87
97
  /**
@@ -98,8 +108,10 @@ export const PLATFORM_ROUTE_HREFS = {
98
108
 
99
109
  /**
100
110
  * Top-level addresses the platform serves beside the corpus: the Worker's
101
- * sign-in routes (ADR-038), the search bundles, `/pagefind/` and
102
- * `/pagefind-<class>/` (ADR-039), and the original files under `/assets/`.
111
+ * sign-in and OAuth routes (ADR-038, ADR-041), the search bundles,
112
+ * `/pagefind/` and `/pagefind-<class>/` (ADR-039), and the original files
113
+ * under `/assets/`. The MCP server needs none: it answers `/mcp` exactly
114
+ * (ADR-041), and a page is always served under its trailing slash.
103
115
  */
104
116
  const SERVED_ADDRESSES = ['auth', 'pagefind', 'assets'] as const;
105
117
 
@@ -120,6 +132,43 @@ export function permanentLinkPath(shortId: string): string {
120
132
  return `/${PLATFORM_ROUTES.permanentLinks}/${shortId}/`;
121
133
  }
122
134
 
135
+ /**
136
+ * Renders a permanent link as the address it currently leads to.
137
+ *
138
+ * The sync writes a link between two documents as the target's permanent
139
+ * link, `/d/<short ID>/`, so the document holding it never changes when the
140
+ * target is renamed or moved (ADR-022). The redirect map says where each one
141
+ * leads today. Resolving it while the site builds sends a reader straight to
142
+ * the page instead of through a redirect page on every click.
143
+ *
144
+ * A permanent link the map does not know is left as it is: it is either a
145
+ * hand-written link to nothing, which the sync already refuses for generated
146
+ * pages, or one a later build will know.
147
+ */
148
+ export function resolvePermanentLink(
149
+ value: string,
150
+ redirects: Readonly<Record<string, string>>,
151
+ ): string | undefined {
152
+ const prefix = `/${PLATFORM_ROUTES.permanentLinks}/`;
153
+ if (!value.startsWith(prefix)) {
154
+ return undefined;
155
+ }
156
+ const suffixStart = value.search(/[?#]/u);
157
+ const path = suffixStart < 0 ? value : value.slice(0, suffixStart);
158
+ const suffix = suffixStart < 0 ? '' : value.slice(suffixStart);
159
+ const target = redirects[path.endsWith('/') ? path : `${path}/`];
160
+ return target === undefined ? undefined : `${target}${suffix}`;
161
+ }
162
+
163
+ /**
164
+ * The site path of a page's Markdown version, from its route ID (ADR-010):
165
+ * the one definition, shared by the route that serves it, the pages that link
166
+ * it and the sync that measures it.
167
+ */
168
+ export function markdownProjectionPath(slug: string): string {
169
+ return `/${slug}/index.md`;
170
+ }
171
+
123
172
  export const GENERATED_DIRECTORY_ALLOWLIST = [
124
173
  PROJECT_LAYOUT.generatedDocumentsDirectory,
125
174
  PROJECT_LAYOUT.generatedAssetsDirectory,
@@ -0,0 +1,199 @@
1
+ import type { Root, RootContent } from 'mdast';
2
+ import * as cheerio from 'cheerio';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkParse from 'remark-parse';
5
+ import remarkStringify from 'remark-stringify';
6
+ import { unified } from 'unified';
7
+
8
+ import {
9
+ markdownProjectionPath,
10
+ resolvePermanentLink,
11
+ } from './project-layout.js';
12
+
13
+ const processor = unified()
14
+ .use(remarkParse)
15
+ .use(remarkGfm)
16
+ .use(remarkStringify, {
17
+ bullet: '-',
18
+ emphasis: '*',
19
+ fences: true,
20
+ listItemIndent: 'one',
21
+ strong: '*',
22
+ });
23
+
24
+ export interface PublishedMarkdownInput {
25
+ title: string;
26
+ /**
27
+ * The ownership marker the generated file opens with, which is stripped from
28
+ * the published form. It is passed in rather than read from the project so
29
+ * this stays a pure function of its input.
30
+ */
31
+ ownershipHeader: string;
32
+ sourceUrl: string;
33
+ /** The published PDF, when the page is one's and the site serves it. */
34
+ fileUrl?: string;
35
+ googleModifiedTime: string;
36
+ syncedAt: string;
37
+ contentHash: string;
38
+ body: string;
39
+ stableSlugs: ReadonlySet<string>;
40
+ /**
41
+ * Each permanent link, `/d/<short ID>/`, to the address it leads to. A link
42
+ * between documents is stored as one (ADR-022); the projection names the
43
+ * target's own Markdown instead, as it does for any other corpus link.
44
+ */
45
+ permanentLinks: Readonly<Record<string, string>>;
46
+ }
47
+
48
+ function walk(
49
+ node: Root | RootContent,
50
+ visit: (node: RootContent) => void,
51
+ ): void {
52
+ if (node.type !== 'root') {
53
+ visit(node);
54
+ }
55
+ if ('children' in node) {
56
+ for (const child of node.children) {
57
+ walk(child, visit);
58
+ }
59
+ }
60
+ }
61
+
62
+ function markdownUrl(
63
+ value: string,
64
+ stableSlugs: ReadonlySet<string>,
65
+ permanentLinks: Readonly<Record<string, string>>,
66
+ ): string | undefined {
67
+ const resolved = resolvePermanentLink(value, permanentLinks);
68
+ const link = resolved ?? value;
69
+ if (!link.startsWith('/')) {
70
+ return resolved;
71
+ }
72
+
73
+ let url: URL;
74
+ try {
75
+ url = new URL(link, 'https://wiki.invalid');
76
+ } catch {
77
+ return resolved;
78
+ }
79
+ const slug = url.pathname.replace(/^\/+|\/+$/gu, '');
80
+ if (!stableSlugs.has(slug)) {
81
+ return resolved;
82
+ }
83
+ return `${markdownProjectionPath(slug)}${url.search}${url.hash}`;
84
+ }
85
+
86
+ function rewriteInternalLinks(
87
+ body: string,
88
+ stableSlugs: ReadonlySet<string>,
89
+ permanentLinks: Readonly<Record<string, string>>,
90
+ ): string {
91
+ const tree = processor.parse(body) as Root;
92
+ walk(tree, (node) => {
93
+ if (node.type === 'link') {
94
+ node.url = markdownUrl(node.url, stableSlugs, permanentLinks) ?? node.url;
95
+ return;
96
+ }
97
+ if (node.type !== 'html') {
98
+ return;
99
+ }
100
+
101
+ const $ = cheerio.load(node.value, null, false);
102
+ let changed = false;
103
+ $('a[href]').each((_, anchor) => {
104
+ const href = $(anchor).attr('href');
105
+ const rewritten = href
106
+ ? markdownUrl(href, stableSlugs, permanentLinks)
107
+ : undefined;
108
+ if (rewritten) {
109
+ $(anchor).attr('href', rewritten);
110
+ changed = true;
111
+ }
112
+ });
113
+ if (changed) {
114
+ node.value = $.root().html() ?? '';
115
+ }
116
+ });
117
+
118
+ return processor.stringify(tree).trimEnd();
119
+ }
120
+
121
+ function cleanBody(body: string, ownershipHeader: string): string {
122
+ const normalized = body.replace(/\r\n?/gu, '\n');
123
+ if (
124
+ normalized !== ownershipHeader &&
125
+ !normalized.startsWith(`${ownershipHeader}\n`)
126
+ ) {
127
+ throw new Error(
128
+ 'Generated Google document is missing the expected ownership marker.',
129
+ );
130
+ }
131
+ return normalized.slice(ownershipHeader.length).trim();
132
+ }
133
+
134
+ /** Where the site serves a published PDF, as its Markdown version names it. */
135
+ export function publishedFileUrl(googleFileId: string, file: string): string {
136
+ return `/assets/generated/${googleFileId}/${file}`;
137
+ }
138
+
139
+ function yamlString(value: string): string {
140
+ return JSON.stringify(value);
141
+ }
142
+
143
+ /**
144
+ * One line of inline Markdown that reads as the text it was given: whitespace,
145
+ * newlines included, collapses to single spaces, and every character that
146
+ * would open emphasis, code, a link, HTML or a heading is escaped. Used for
147
+ * titles, which come from Drive names and may contain any of them.
148
+ */
149
+ export function inlineMarkdown(value: string): string {
150
+ return value
151
+ .replace(/\s+/gu, ' ')
152
+ .trim()
153
+ .replace(/([\\`*_[\]<>#])/gu, '\\$1');
154
+ }
155
+
156
+ /**
157
+ * The page's Markdown without its front matter: its title as a heading and
158
+ * its body. It is what the MCP server stores, indexes and returns from
159
+ * `fetch` (ADR-042), so it is also what the sync measures (ADR-043).
160
+ */
161
+ export function publishedMarkdownBody(
162
+ input: Pick<
163
+ PublishedMarkdownInput,
164
+ 'title' | 'ownershipHeader' | 'body' | 'stableSlugs' | 'permanentLinks'
165
+ >,
166
+ ): string {
167
+ const body = rewriteInternalLinks(
168
+ cleanBody(input.body, input.ownershipHeader),
169
+ input.stableSlugs,
170
+ input.permanentLinks,
171
+ );
172
+ return [
173
+ `# ${inlineMarkdown(input.title)}`,
174
+ '',
175
+ ...(body ? [body, ''] : []),
176
+ ].join('\n');
177
+ }
178
+
179
+ /**
180
+ * The page's Markdown version (ADR-010): front matter, then
181
+ * `publishedMarkdownBody`. Each front matter value is one JSON string, so no
182
+ * value spans a line and the first `---` line after the opening one closes it.
183
+ */
184
+ export function serializePublishedMarkdown(
185
+ input: PublishedMarkdownInput,
186
+ ): string {
187
+ return [
188
+ '---',
189
+ `title: ${yamlString(input.title)}`,
190
+ `source_url: ${yamlString(input.sourceUrl)}`,
191
+ ...(input.fileUrl ? [`file_url: ${yamlString(input.fileUrl)}`] : []),
192
+ `modified_at: ${yamlString(input.googleModifiedTime)}`,
193
+ `synced_at: ${yamlString(input.syncedAt)}`,
194
+ `content_hash: ${yamlString(input.contentHash)}`,
195
+ '---',
196
+ '',
197
+ publishedMarkdownBody(input),
198
+ ].join('\n');
199
+ }
@@ -121,6 +121,13 @@ export interface SyncConfigurationDefaults {
121
121
  * project sets the line it wants numbers for. Defaults to 2.
122
122
  */
123
123
  readonly largeImageMegabytes: number;
124
+ /**
125
+ * The length, in characters of a document's Markdown body, above which the
126
+ * sync report notes the document as worth splitting (ADR-043). No greater
127
+ * than the characters an assistant reads of a document; defaults to 40,000,
128
+ * or to that limit where it is lower.
129
+ */
130
+ readonly largeDocumentCharacters: number;
124
131
  }
125
132
 
126
133
  export interface NavigationConfiguration {
@@ -166,9 +173,94 @@ export interface SignInConfiguration {
166
173
  readonly workspaceDomains: readonly string[];
167
174
  }
168
175
 
176
+ /**
177
+ * How the MCP server searches and how much a search returns (ADR-042). Every
178
+ * value has a default, the starting point ADR-042 records, so that a project
179
+ * tunes it against its own corpus without a new version of the platform.
180
+ */
181
+ export interface McpSearchConfiguration {
182
+ /** Chunks asked of AI Search for each query: 1 to 50. */
183
+ readonly chunks: number;
184
+ /**
185
+ * The vector similarity below which AI Search drops a chunk, 0 to 1. It
186
+ * drops only chunks vector search alone found, and AI Search ignores it
187
+ * while reranking is on (ADR-042), so it acts only with reranking off.
188
+ */
189
+ readonly vectorThreshold: number;
190
+ /** Whether a keyword match needs every word of the query, or any. */
191
+ readonly keywordMatch: 'and' | 'or';
192
+ /**
193
+ * Neighbouring chunks AI Search joins to each side of a match: 0 to 3. A
194
+ * chunk of AI Search's default size is already longer than a passage.
195
+ */
196
+ readonly contextChunks: number;
197
+ readonly reranking: {
198
+ readonly enabled: boolean;
199
+ /** An AI Search reranking model. */
200
+ readonly model: string;
201
+ /** The reranking score below which a chunk is dropped, 0 to 1. */
202
+ readonly threshold: number;
203
+ };
204
+ /** Documents a search returns at most; no more than `chunks`. */
205
+ readonly results: number;
206
+ /** Passages each document shows at most. */
207
+ readonly passagesPerResult: number;
208
+ /** Characters of passage text one search returns at most, in all. */
209
+ readonly passageCharacters: number;
210
+ }
211
+
212
+ /**
213
+ * The MCP server through which AI assistants read the site as the person who
214
+ * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
215
+ * only on a deployment whose every environment is private.
216
+ */
217
+ export interface McpConfiguration {
218
+ readonly enabled: boolean;
219
+ readonly search: McpSearchConfiguration;
220
+ /** Characters `fetch` returns at most; a longer document is cut there. */
221
+ readonly fetchCharacters: number;
222
+ }
223
+
224
+ /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
225
+ export const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'> = Object.freeze({
226
+ search: Object.freeze({
227
+ chunks: 50,
228
+ vectorThreshold: 0.2,
229
+ keywordMatch: 'or',
230
+ contextChunks: 0,
231
+ reranking: Object.freeze({
232
+ enabled: true,
233
+ model: '@cf/baai/bge-reranker-base',
234
+ threshold: 0,
235
+ }),
236
+ results: 10,
237
+ passagesPerResult: 3,
238
+ passageCharacters: 24_000,
239
+ }),
240
+ fetchCharacters: 100_000,
241
+ });
242
+
243
+ /**
244
+ * The characters an assistant reads of a document: the cut `fetch` makes
245
+ * (ADR-042), or its default while the MCP server is off. The build writes
246
+ * this value into the access map and the sync measures documents against it
247
+ * (ADR-043), so the two never derive it apart.
248
+ */
249
+ export function fetchCharacterLimit(mcp: McpConfiguration | undefined): number {
250
+ return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
251
+ }
252
+
253
+ /** About 10,000 tokens of English: a starting value, not a measured one. */
254
+ const LARGE_DOCUMENT_CHARACTERS = 40_000;
255
+
256
+ /** A passage of fewer characters than this says too little to judge by. */
257
+ const SHORTEST_PASSAGE = 100;
258
+
169
259
  export interface SiteConfiguration {
170
260
  /** Required to serve a private environment through the platform's Worker. */
171
261
  readonly signIn?: SignInConfiguration;
262
+ /** Absent or disabled, the Worker serves no MCP or OAuth route. */
263
+ readonly mcp?: McpConfiguration;
172
264
  /**
173
265
  * Who may read which folder (ADR-039). Absent, every document is open to
174
266
  * every reader the deployment admits, as before access rules existed.
@@ -350,6 +442,139 @@ function optionalCount(
350
442
  }
351
443
 
352
444
  /** A size above zero, in megabytes; a fraction such as 1.5 is allowed. */
445
+ /** A number between `min` and `max`, whole when `whole`, or the fallback. */
446
+ function optionalNumber(
447
+ source: Record<string, unknown>,
448
+ key: string,
449
+ path: string,
450
+ fallback: number,
451
+ { min, max, whole }: { min: number; max?: number; whole: boolean },
452
+ ): number {
453
+ const value = source[key];
454
+ if (value === undefined) {
455
+ return fallback;
456
+ }
457
+ if (
458
+ typeof value !== 'number' ||
459
+ !Number.isFinite(value) ||
460
+ (whole && !Number.isInteger(value)) ||
461
+ value < min ||
462
+ (max !== undefined && value > max)
463
+ ) {
464
+ const kind = whole ? 'a whole number' : 'a number';
465
+ fail(
466
+ path,
467
+ max === undefined
468
+ ? `must be ${kind} of at least ${min}`
469
+ : `must be ${kind} from ${min} to ${max}`,
470
+ );
471
+ }
472
+ return value;
473
+ }
474
+
475
+ /** Fails on a key the section does not define. */
476
+ function knownKeys(
477
+ source: Record<string, unknown>,
478
+ path: string,
479
+ keys: readonly string[],
480
+ ): void {
481
+ for (const key of Object.keys(source)) {
482
+ if (!keys.includes(key)) {
483
+ fail(`${path}.${key}`, 'is not a known setting');
484
+ }
485
+ }
486
+ }
487
+
488
+ function mcpSearch(value: unknown): McpSearchConfiguration {
489
+ const defaults = MCP_DEFAULTS.search;
490
+ if (value === undefined) {
491
+ return defaults;
492
+ }
493
+ const source = record(value, 'mcp.search');
494
+ knownKeys(source, 'mcp.search', Object.keys(defaults));
495
+ const chunks = optionalNumber(
496
+ source,
497
+ 'chunks',
498
+ 'mcp.search.chunks',
499
+ defaults.chunks,
500
+ {
501
+ min: 1,
502
+ max: 50,
503
+ whole: true,
504
+ },
505
+ );
506
+ const keywordMatch = source.keywordMatch ?? defaults.keywordMatch;
507
+ if (keywordMatch !== 'and' && keywordMatch !== 'or') {
508
+ fail('mcp.search.keywordMatch', 'must be "and" or "or"');
509
+ }
510
+ let reranking = defaults.reranking;
511
+ if (source.reranking !== undefined) {
512
+ const rerankingSource = record(source.reranking, 'mcp.search.reranking');
513
+ knownKeys(rerankingSource, 'mcp.search.reranking', Object.keys(reranking));
514
+ reranking = {
515
+ enabled: optionalFlag(
516
+ rerankingSource,
517
+ 'enabled',
518
+ 'mcp.search.reranking.enabled',
519
+ reranking.enabled,
520
+ ),
521
+ model:
522
+ rerankingSource.model === undefined
523
+ ? reranking.model
524
+ : text(rerankingSource, 'model', 'mcp.search.reranking.model'),
525
+ threshold: optionalNumber(
526
+ rerankingSource,
527
+ 'threshold',
528
+ 'mcp.search.reranking.threshold',
529
+ reranking.threshold,
530
+ { min: 0, max: 1, whole: false },
531
+ ),
532
+ };
533
+ }
534
+ // The default, like a set value, is no more than the chunks asked for.
535
+ const results = optionalNumber(
536
+ source,
537
+ 'results',
538
+ 'mcp.search.results',
539
+ Math.min(defaults.results, chunks),
540
+ { min: 1, max: chunks, whole: true },
541
+ );
542
+ return {
543
+ chunks,
544
+ vectorThreshold: optionalNumber(
545
+ source,
546
+ 'vectorThreshold',
547
+ 'mcp.search.vectorThreshold',
548
+ defaults.vectorThreshold,
549
+ { min: 0, max: 1, whole: false },
550
+ ),
551
+ keywordMatch,
552
+ contextChunks: optionalNumber(
553
+ source,
554
+ 'contextChunks',
555
+ 'mcp.search.contextChunks',
556
+ defaults.contextChunks,
557
+ { min: 0, max: 3, whole: true },
558
+ ),
559
+ reranking,
560
+ results,
561
+ passagesPerResult: optionalNumber(
562
+ source,
563
+ 'passagesPerResult',
564
+ 'mcp.search.passagesPerResult',
565
+ defaults.passagesPerResult,
566
+ { min: 1, whole: true },
567
+ ),
568
+ passageCharacters: optionalNumber(
569
+ source,
570
+ 'passageCharacters',
571
+ 'mcp.search.passageCharacters',
572
+ defaults.passageCharacters,
573
+ { min: results * SHORTEST_PASSAGE, whole: true },
574
+ ),
575
+ };
576
+ }
577
+
353
578
  function optionalMegabytes(
354
579
  source: Record<string, unknown>,
355
580
  key: string,
@@ -467,6 +692,14 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
467
692
  }
468
693
 
469
694
  const syncSource = record(root.sync, 'sync');
695
+ // A misspelled optional line would otherwise fall back to its default.
696
+ knownKeys(syncSource, 'sync', [
697
+ 'generatedBy',
698
+ 'commitBotName',
699
+ 'defaultLocale',
700
+ 'largeImageMegabytes',
701
+ 'largeDocumentCharacters',
702
+ ]);
470
703
  const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
471
704
  // The marker is embedded in an HTML comment in every generated Markdown
472
705
  // file. A comment delimiter inside it would terminate that comment early and
@@ -486,6 +719,10 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
486
719
  workerName,
487
720
  };
488
721
 
722
+ const publicEnvironment = Object.entries(deployment.environments).find(
723
+ ([, environment]) => environment.visibility === 'public',
724
+ );
725
+
489
726
  /*
490
727
  * Rules close folders to some readers, which a public environment cannot
491
728
  * do: everyone may read it. Accepting rules there would promise a boundary
@@ -493,9 +730,6 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
493
730
  */
494
731
  let access: AccessConfiguration | undefined;
495
732
  if (root.access !== undefined) {
496
- const publicEnvironment = Object.entries(deployment.environments).find(
497
- ([, environment]) => environment.visibility === 'public',
498
- );
499
733
  if (publicEnvironment) {
500
734
  fail(
501
735
  'access',
@@ -538,8 +772,39 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
538
772
  signIn = { workspaceDomains: Object.freeze(parsed) };
539
773
  }
540
774
 
775
+ let mcp: McpConfiguration | undefined;
776
+ if (root.mcp !== undefined) {
777
+ const source = record(root.mcp, 'mcp');
778
+ knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
779
+ const enabled = flag(source, 'enabled', 'mcp.enabled');
780
+ if (enabled) {
781
+ if (!signIn) {
782
+ fail('mcp', 'needs signIn: assistants sign in as the site does');
783
+ }
784
+ if (publicEnvironment) {
785
+ fail(
786
+ 'mcp',
787
+ `must not be enabled while the ${publicEnvironment[0]} environment is public`,
788
+ );
789
+ }
790
+ }
791
+ mcp = {
792
+ enabled,
793
+ search: mcpSearch(source.search),
794
+ fetchCharacters: optionalNumber(
795
+ source,
796
+ 'fetchCharacters',
797
+ 'mcp.fetchCharacters',
798
+ MCP_DEFAULTS.fetchCharacters,
799
+ { min: 1_000, whole: true },
800
+ ),
801
+ };
802
+ }
803
+ const fetchLimit = fetchCharacterLimit(mcp);
804
+
541
805
  return {
542
806
  ...(signIn ? { signIn } : {}),
807
+ ...(mcp ? { mcp } : {}),
543
808
  ...(access ? { access } : {}),
544
809
  brand,
545
810
  deployment,
@@ -590,6 +855,13 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
590
855
  'sync.largeImageMegabytes',
591
856
  2,
592
857
  ),
858
+ largeDocumentCharacters: optionalNumber(
859
+ syncSource,
860
+ 'largeDocumentCharacters',
861
+ 'sync.largeDocumentCharacters',
862
+ Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit),
863
+ { min: 1, max: fetchLimit, whole: true },
864
+ ),
593
865
  },
594
866
  };
595
867
  }