@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 +2 -0
- package/dist/corpus-structure.d.ts +6 -0
- package/dist/corpus-structure.js +13 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/project-layout.d.ts +30 -0
- package/dist/project-layout.js +46 -2
- package/dist/published-markdown.d.ts +44 -0
- package/dist/published-markdown.js +132 -0
- package/dist/site-configuration.d.ts +64 -0
- package/dist/site-configuration.js +129 -1
- package/package.json +14 -2
- package/src/corpus-structure.ts +20 -1
- package/src/index.ts +6 -0
- package/src/project-layout.ts +51 -2
- package/src/published-markdown.ts +199 -0
- package/src/site-configuration.ts +275 -3
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;
|
package/dist/corpus-structure.js
CHANGED
|
@@ -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
|
|
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';
|
package/dist/project-layout.d.ts
CHANGED
|
@@ -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"];
|
package/dist/project-layout.js
CHANGED
|
@@ -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,
|
|
97
|
-
* `/pagefind-<class>/` (ADR-039), and the original files
|
|
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.
|
|
4
|
-
"description": "Configuration schema, project layout,
|
|
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",
|
package/src/corpus-structure.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/project-layout.ts
CHANGED
|
@@ -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,
|
|
102
|
-
* `/pagefind-<class>/` (ADR-039), and the original files
|
|
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
|
}
|