@ctcstack/ctcdocs-core 0.1.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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The project configuration layer.
3
+ *
4
+ * Everything that identifies one deployment of this platform — the product
5
+ * name, the hostnames it is served from, the Worker it deploys to, the marker
6
+ * stamped into generated files — lives in the project's `site.config.json` and
7
+ * is read from here. Adapting the platform to another project is editing that
8
+ * one file plus the environment variables in `.env.example`; no source file,
9
+ * test, or workflow in the platform carries a project name of its own.
10
+ *
11
+ * The values are validated rather than trusted, because a typo in a hostname is
12
+ * the difference between a protected deployment and a public one.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+
17
+ import { PROJECT_LAYOUT } from './project-layout.js';
18
+
19
+ export interface BrandConfiguration {
20
+ /** The organization or product the documentation belongs to. */
21
+ readonly name: string;
22
+ /** The site's own name: browser tab, header wordmark, home page heading. */
23
+ readonly siteTitle: string;
24
+ /** One sentence, used as the site-wide meta description. */
25
+ readonly siteDescription: string;
26
+ /** Site-root-relative path of the favicon served from `public/`. */
27
+ readonly faviconPath: string;
28
+ }
29
+
30
+ export interface HomeConfiguration {
31
+ /**
32
+ * The paragraph under the home page heading: where the documents come from
33
+ * and what a reader may do with them. It is whole prose rather than a name
34
+ * the platform assembles a sentence around, because the sentence itself is
35
+ * an editorial choice — how the Drive is named, whether read-only is worth
36
+ * saying, what a newcomer needs first.
37
+ */
38
+ readonly lede: string;
39
+ }
40
+
41
+ /**
42
+ * Who a deployment is for.
43
+ *
44
+ * `private` is a documentation site behind an identity boundary: crawlers are
45
+ * refused, pages ask not to be indexed, responses are cached privately, and the
46
+ * smoke test proves anonymous traffic is denied before anything is published.
47
+ * `public` is a documentation portal anyone may read, and the same checks run
48
+ * with their assertions inverted — a public site that has quietly become
49
+ * unreachable is as much a defect as a private one that has quietly become
50
+ * readable.
51
+ *
52
+ * It defaults to `private` because a wrong guess in that direction is
53
+ * recoverable and a wrong guess in the other one is a disclosure.
54
+ */
55
+ export type DeploymentVisibility = 'private' | 'public';
56
+
57
+ export interface DeploymentEnvironmentConfiguration {
58
+ /** Origin the environment is served from, without a trailing path. */
59
+ readonly url: string;
60
+ /** Host of `url`, which is what a Wrangler custom-domain route binds. */
61
+ readonly hostname: string;
62
+ /** Who may read this environment. Defaults to `private`. */
63
+ readonly visibility: DeploymentVisibility;
64
+ }
65
+
66
+ /**
67
+ * Deployment environments, keyed by Wrangler environment name.
68
+ *
69
+ * A project decides how many it wants: one deployment may promote through a
70
+ * development hostname, another may publish production only. `production` is
71
+ * the one name the platform depends on — it is the canonical site address, the
72
+ * origin the sync pipeline writes into generated links, and the target the
73
+ * smoke tests default to — so it is required and every other name is the
74
+ * project's own.
75
+ */
76
+ export type DeploymentEnvironmentConfigurations = {
77
+ readonly production: DeploymentEnvironmentConfiguration;
78
+ } & {
79
+ readonly [environmentName: string]: DeploymentEnvironmentConfiguration;
80
+ };
81
+
82
+ export interface DeploymentConfiguration {
83
+ /** Cloudflare Worker name; environments deploy as `<name>-<environment>`. */
84
+ readonly workerName: string;
85
+ readonly environments: DeploymentEnvironmentConfigurations;
86
+ }
87
+
88
+ export interface SyncConfigurationDefaults {
89
+ /**
90
+ * Named in the ownership marker of every generated file. Changing it
91
+ * rewrites the marker in the whole generated corpus, so it is a deliberate
92
+ * one-time decision for a new project rather than a cosmetic setting.
93
+ */
94
+ readonly generatedBy: string;
95
+ /** Git author the sync workflow commits generated output as. */
96
+ readonly commitBotName: string;
97
+ /** Locale assumed for documents whose language cannot be determined. */
98
+ readonly defaultLocale: string;
99
+ }
100
+
101
+ export interface NavigationConfiguration {
102
+ /**
103
+ * Document titles that open the folder they sit in, most preferred first.
104
+ * Matched case-insensitively against the title a reader sees, so the order
105
+ * prefix is not part of it.
106
+ */
107
+ readonly landingDocumentTitles: readonly string[];
108
+ /**
109
+ * Whether each folder gets a generated page listing its contents. Folder
110
+ * slugs are reserved either way, so turning this on or off moves no address.
111
+ */
112
+ readonly sectionIndexPages: boolean;
113
+ }
114
+
115
+ export interface SiteConfiguration {
116
+ readonly brand: BrandConfiguration;
117
+ readonly deployment: DeploymentConfiguration;
118
+ readonly home: HomeConfiguration;
119
+ readonly navigation: NavigationConfiguration;
120
+ readonly sync: SyncConfigurationDefaults;
121
+ }
122
+
123
+ export class SiteConfigurationError extends Error {
124
+ override readonly name = 'SiteConfigurationError';
125
+ }
126
+
127
+ function fail(path: string, expectation: string): never {
128
+ throw new SiteConfigurationError(
129
+ `${PROJECT_LAYOUT.configurationFile}: ${path} ${expectation}.`,
130
+ );
131
+ }
132
+
133
+ function record(value: unknown, path: string): Record<string, unknown> {
134
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
135
+ fail(path, 'must be an object');
136
+ }
137
+ return value as Record<string, unknown>;
138
+ }
139
+
140
+ function text(
141
+ source: Record<string, unknown>,
142
+ key: string,
143
+ path: string,
144
+ ): string {
145
+ const value = source[key];
146
+ if (typeof value !== 'string' || value.trim().length === 0) {
147
+ fail(path, 'must be a non-empty string');
148
+ }
149
+ return value.trim();
150
+ }
151
+
152
+ /**
153
+ * Accepts only a bare HTTPS origin. A path, query, or embedded credential
154
+ * would silently produce wrong canonical URLs, wrong Wrangler routes, and a
155
+ * smoke test that probes the wrong address.
156
+ */
157
+ function origin(
158
+ source: Record<string, unknown>,
159
+ key: string,
160
+ path: string,
161
+ ): URL {
162
+ const value = text(source, key, path);
163
+ let url: URL;
164
+ try {
165
+ url = new URL(value);
166
+ } catch {
167
+ return fail(path, 'must be an absolute URL');
168
+ }
169
+ if (
170
+ url.protocol !== 'https:' ||
171
+ url.username ||
172
+ url.password ||
173
+ url.search ||
174
+ url.hash ||
175
+ !['', '/'].includes(url.pathname)
176
+ ) {
177
+ fail(path, 'must be an HTTPS origin without credentials, path, or query');
178
+ }
179
+ return url;
180
+ }
181
+
182
+ /**
183
+ * A list of distinct, non-empty titles. Two entries differing only in case
184
+ * would make the precedence between them depend on which one a folder happens
185
+ * to contain, so they are rejected rather than deduplicated.
186
+ */
187
+ function titleList(
188
+ source: Record<string, unknown>,
189
+ key: string,
190
+ path: string,
191
+ ): readonly string[] {
192
+ const value = source[key];
193
+ if (!Array.isArray(value) || value.length === 0) {
194
+ fail(path, 'must be a non-empty array of titles');
195
+ }
196
+ const titles = value.map((entry, index) => {
197
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
198
+ fail(`${path}[${index}]`, 'must be a non-empty string');
199
+ }
200
+ return entry.trim();
201
+ });
202
+ const seen = new Set(titles.map((title) => title.toLocaleLowerCase('en')));
203
+ if (seen.size !== titles.length) {
204
+ fail(path, 'must not repeat a title');
205
+ }
206
+ return Object.freeze(titles);
207
+ }
208
+
209
+ function flag(
210
+ source: Record<string, unknown>,
211
+ key: string,
212
+ path: string,
213
+ ): boolean {
214
+ const value = source[key];
215
+ if (typeof value !== 'boolean') {
216
+ fail(path, 'must be true or false');
217
+ }
218
+ return value;
219
+ }
220
+
221
+ function optionalVisibility(
222
+ source: Record<string, unknown>,
223
+ path: string,
224
+ ): DeploymentVisibility {
225
+ const value = source.visibility;
226
+ if (value === undefined) {
227
+ return 'private';
228
+ }
229
+ if (value !== 'private' && value !== 'public') {
230
+ fail(`${path}.visibility`, 'must be "private" or "public"');
231
+ }
232
+ return value;
233
+ }
234
+
235
+ /**
236
+ * Environment names become Wrangler environment keys and appear in Worker
237
+ * names as `<worker>-<environment>`, so they are held to the same shape as the
238
+ * Worker name itself.
239
+ */
240
+ function environments(
241
+ source: Record<string, unknown>,
242
+ ): DeploymentEnvironmentConfigurations {
243
+ const raw = record(source.environments, 'deployment.environments');
244
+ const names = Object.keys(raw);
245
+ if (!names.includes('production')) {
246
+ fail('deployment.environments', 'must define a production environment');
247
+ }
248
+
249
+ const parsed: Record<string, DeploymentEnvironmentConfiguration> = {};
250
+ const hostnames = new Map<string, string>();
251
+
252
+ for (const name of names) {
253
+ const path = `deployment.environments.${name}`;
254
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(name)) {
255
+ fail(path, 'must be named with lowercase letters, digits, and hyphens');
256
+ }
257
+ const environmentSource = record(raw[name], path);
258
+ const url = origin(environmentSource, 'url', `${path}.url`);
259
+ const visibility = optionalVisibility(environmentSource, path);
260
+ const previous = hostnames.get(url.host);
261
+ if (previous !== undefined) {
262
+ fail(
263
+ path,
264
+ `must not reuse the hostname already bound by the ${previous} environment`,
265
+ );
266
+ }
267
+ hostnames.set(url.host, name);
268
+ parsed[name] = { hostname: url.host, url: url.origin, visibility };
269
+ }
270
+
271
+ return Object.freeze(parsed) as DeploymentEnvironmentConfigurations;
272
+ }
273
+
274
+ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
275
+ const root = record(input, 'the configuration');
276
+
277
+ const brandSource = record(root.brand, 'brand');
278
+ const faviconPath = text(brandSource, 'faviconPath', 'brand.faviconPath');
279
+ if (!faviconPath.startsWith('/')) {
280
+ fail('brand.faviconPath', 'must be a site-root-relative path');
281
+ }
282
+ const brand: BrandConfiguration = {
283
+ faviconPath,
284
+ name: text(brandSource, 'name', 'brand.name'),
285
+ siteDescription: text(
286
+ brandSource,
287
+ 'siteDescription',
288
+ 'brand.siteDescription',
289
+ ),
290
+ siteTitle: text(brandSource, 'siteTitle', 'brand.siteTitle'),
291
+ };
292
+
293
+ const deploymentSource = record(root.deployment, 'deployment');
294
+ const workerName = text(
295
+ deploymentSource,
296
+ 'workerName',
297
+ 'deployment.workerName',
298
+ );
299
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(workerName)) {
300
+ fail(
301
+ 'deployment.workerName',
302
+ 'must be lowercase letters, digits, and hyphens',
303
+ );
304
+ }
305
+
306
+ const syncSource = record(root.sync, 'sync');
307
+ const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
308
+ // The marker is embedded in an HTML comment in every generated Markdown
309
+ // file. A comment delimiter inside it would terminate that comment early and
310
+ // publish the rest of the marker as document text.
311
+ if (generatedBy.includes('--') || generatedBy.includes('<')) {
312
+ fail('sync.generatedBy', 'must not contain "--" or "<"');
313
+ }
314
+ const defaultLocale = text(syncSource, 'defaultLocale', 'sync.defaultLocale');
315
+ if (defaultLocale.length < 2) {
316
+ fail('sync.defaultLocale', 'must be a language tag such as "en"');
317
+ }
318
+
319
+ const navigationSource = record(root.navigation, 'navigation');
320
+ const homeSource = record(root.home, 'home');
321
+
322
+ return {
323
+ brand,
324
+ deployment: {
325
+ environments: environments(deploymentSource),
326
+ workerName,
327
+ },
328
+ home: { lede: text(homeSource, 'lede', 'home.lede') },
329
+ navigation: {
330
+ landingDocumentTitles: titleList(
331
+ navigationSource,
332
+ 'landingDocumentTitles',
333
+ 'navigation.landingDocumentTitles',
334
+ ),
335
+ sectionIndexPages: flag(
336
+ navigationSource,
337
+ 'sectionIndexPages',
338
+ 'navigation.sectionIndexPages',
339
+ ),
340
+ },
341
+ sync: {
342
+ commitBotName: text(syncSource, 'commitBotName', 'sync.commitBotName'),
343
+ defaultLocale,
344
+ generatedBy,
345
+ },
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Parsed configurations, keyed by absolute project root.
351
+ *
352
+ * The configuration is read by the Astro build, by every component that renders
353
+ * during it, and by the sync CLI. Reading and validating the same file dozens of
354
+ * times per build would be wasted work, and — more importantly — two components
355
+ * disagreeing about the configuration because one of them read a half-written
356
+ * file is a class of bug worth designing out.
357
+ */
358
+ const loaded = new Map<string, SiteConfiguration>();
359
+
360
+ export function loadSiteConfiguration(projectRoot: string): SiteConfiguration {
361
+ const root = resolve(projectRoot);
362
+ const cached = loaded.get(root);
363
+ if (cached) {
364
+ return cached;
365
+ }
366
+
367
+ const configurationPath = resolve(root, PROJECT_LAYOUT.configurationFile);
368
+ let raw: unknown;
369
+ try {
370
+ raw = JSON.parse(readFileSync(configurationPath, 'utf8'));
371
+ } catch (error: unknown) {
372
+ throw new SiteConfigurationError(
373
+ `${configurationPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
374
+ );
375
+ }
376
+
377
+ const configuration = parseSiteConfiguration(raw);
378
+ loaded.set(root, configuration);
379
+ return configuration;
380
+ }