@ctcstack/ctcdocs-core 0.5.2 → 0.7.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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PROJECT_LAYOUT, RESERVED_SLUGS, } from './project-layout.js';
1
+ export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, 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';
5
5
  export { loadSiteConfiguration, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
6
- export type { BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, NavigationConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
6
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, NavigationConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PROJECT_LAYOUT, RESERVED_SLUGS, } from './project-layout.js';
1
+ export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, 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';
@@ -46,11 +46,24 @@ export declare const PROJECT_LAYOUT: {
46
46
  export declare const PLATFORM_ROUTES: {
47
47
  /** The whole corpus, grouped by folder. See docs/ADR/017-full-index-page.md. */
48
48
  readonly fullIndex: "documents";
49
+ /**
50
+ * Permanent links, `/d/<short ID>/`, one per document and section. See
51
+ * docs/ADR/022-permanent-short-ids.md.
52
+ */
53
+ readonly permanentLinks: "d";
49
54
  };
50
55
  /** Each platform route as the href a link uses. */
51
56
  export declare const PLATFORM_ROUTE_HREFS: {
52
57
  readonly fullIndex: "/documents/";
53
58
  };
54
59
  export declare const RESERVED_SLUGS: readonly string[];
60
+ /**
61
+ * A short ID: lowercase hexadecimal, six characters unless six collided with
62
+ * another item's, in which case it is longer. It is recorded once per item and
63
+ * never changes, so a permanent link keeps working through every rename.
64
+ */
65
+ export declare const SHORT_ID_PATTERN: RegExp;
66
+ /** The site-relative permanent link of an item with this short ID. */
67
+ export declare function permanentLinkPath(shortId: string): string;
55
68
  export declare const GENERATED_DIRECTORY_ALLOWLIST: readonly ["src/content/docs/_generated", "src/assets/generated", "src/generated"];
56
69
  export declare const GENERATED_FILE_ALLOWLIST: readonly ["data/sync-manifest.json", "data/docs-index.json", "data/latest-sync-report.json"];
@@ -46,12 +46,27 @@ export const PROJECT_LAYOUT = {
46
46
  export const PLATFORM_ROUTES = {
47
47
  /** The whole corpus, grouped by folder. See docs/ADR/017-full-index-page.md. */
48
48
  fullIndex: 'documents',
49
+ /**
50
+ * Permanent links, `/d/<short ID>/`, one per document and section. See
51
+ * docs/ADR/022-permanent-short-ids.md.
52
+ */
53
+ permanentLinks: 'd',
49
54
  };
50
55
  /** Each platform route as the href a link uses. */
51
56
  export const PLATFORM_ROUTE_HREFS = {
52
57
  fullIndex: `/${PLATFORM_ROUTES.fullIndex}/`,
53
58
  };
54
59
  export const RESERVED_SLUGS = Object.freeze(Object.values(PLATFORM_ROUTES));
60
+ /**
61
+ * A short ID: lowercase hexadecimal, six characters unless six collided with
62
+ * another item's, in which case it is longer. It is recorded once per item and
63
+ * never changes, so a permanent link keeps working through every rename.
64
+ */
65
+ export const SHORT_ID_PATTERN = /^[0-9a-f]{6,64}$/u;
66
+ /** The site-relative permanent link of an item with this short ID. */
67
+ export function permanentLinkPath(shortId) {
68
+ return `/${PLATFORM_ROUTES.permanentLinks}/${shortId}/`;
69
+ }
55
70
  export const GENERATED_DIRECTORY_ALLOWLIST = [
56
71
  PROJECT_LAYOUT.generatedDocumentsDirectory,
57
72
  PROJECT_LAYOUT.generatedAssetsDirectory,
@@ -100,7 +100,25 @@ export interface NavigationConfiguration {
100
100
  * slugs are reserved either way, so turning this on or off moves no address.
101
101
  */
102
102
  readonly sectionIndexPages: boolean;
103
+ /**
104
+ * The Unicode scripts a letter in a Drive name may belong to, such as
105
+ * `["Latin"]`, or `null` when any script is accepted. A name becomes a
106
+ * heading, a sidebar label and, once, a permanent address, so a deployment
107
+ * whose editors write in one alphabet can refuse a letter typed on the other
108
+ * keyboard layout before it reaches any of them. Whatever this allows, a
109
+ * single word never mixes Latin, Cyrillic and Greek letters (ADR-020).
110
+ */
111
+ readonly nameScripts: readonly string[] | null;
112
+ /**
113
+ * Whether an address stays where it was first given (`stable`, ADR-005) or
114
+ * follows its item's current Drive path on every sync over the whole corpus
115
+ * (`follow-names`, ADR-021). Either way a moved address leaves a redirect,
116
+ * so a project can switch while its structure settles and back once people
117
+ * share links. Defaults to `stable`.
118
+ */
119
+ readonly addresses: AddressPolicy;
103
120
  }
121
+ export type AddressPolicy = 'stable' | 'follow-names';
104
122
  export interface SiteConfiguration {
105
123
  readonly brand: BrandConfiguration;
106
124
  readonly deployment: DeploymentConfiguration;
@@ -89,6 +89,36 @@ function flag(source, key, path) {
89
89
  function optionalFlag(source, key, path, fallback) {
90
90
  return source[key] === undefined ? fallback : flag(source, key, path);
91
91
  }
92
+ /**
93
+ * A list of distinct Unicode script names, as a `\p{Script=…}` property
94
+ * escape spells them. Each name is proved by compiling that escape, so a typo
95
+ * fails here rather than letting every letter through, or none.
96
+ */
97
+ function optionalScriptList(source, key, path) {
98
+ const value = source[key];
99
+ if (value === undefined) {
100
+ return null;
101
+ }
102
+ if (!Array.isArray(value) || value.length === 0) {
103
+ fail(path, 'must be a non-empty array of Unicode script names');
104
+ }
105
+ const scripts = value.map((entry, index) => {
106
+ if (typeof entry !== 'string' || !/^[A-Za-z_]+$/u.test(entry)) {
107
+ fail(`${path}[${index}]`, 'must be a Unicode script name such as "Latin"');
108
+ }
109
+ try {
110
+ new RegExp(`\\p{Script=${entry}}`, 'u');
111
+ }
112
+ catch {
113
+ fail(`${path}[${index}]`, 'must be a Unicode script name such as "Latin"');
114
+ }
115
+ return entry;
116
+ });
117
+ if (new Set(scripts).size !== scripts.length) {
118
+ fail(path, 'must not repeat a script');
119
+ }
120
+ return Object.freeze(scripts);
121
+ }
92
122
  /**
93
123
  * A count of things shown on a page. Zero is rejected along with fractions and
94
124
  * negatives: a block configured to show nothing is hidden by the switch that
@@ -104,6 +134,16 @@ function optionalCount(source, key, path, fallback) {
104
134
  }
105
135
  return value;
106
136
  }
137
+ function optionalAddressPolicy(source, key, path) {
138
+ const value = source[key];
139
+ if (value === undefined) {
140
+ return 'stable';
141
+ }
142
+ if (value !== 'stable' && value !== 'follow-names') {
143
+ fail(path, 'must be "stable" or "follow-names"');
144
+ }
145
+ return value;
146
+ }
107
147
  function optionalVisibility(source, path) {
108
148
  const value = source.visibility;
109
149
  if (value === undefined) {
@@ -190,6 +230,8 @@ export function parseSiteConfiguration(input) {
190
230
  navigation: {
191
231
  landingDocumentTitles: titleList(navigationSource, 'landingDocumentTitles', 'navigation.landingDocumentTitles'),
192
232
  sectionIndexPages: flag(navigationSource, 'sectionIndexPages', 'navigation.sectionIndexPages'),
233
+ nameScripts: optionalScriptList(navigationSource, 'nameScripts', 'navigation.nameScripts'),
234
+ addresses: optionalAddressPolicy(navigationSource, 'addresses', 'navigation.addresses'),
193
235
  },
194
236
  sync: {
195
237
  commitBotName: text(syncSource, 'commitBotName', 'sync.commitBotName'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Configuration schema, project layout, and generated-path allowlist shared by the CTCDocs platform packages.",
5
5
  "keywords": [
6
6
  "ctcdocs",
package/src/index.ts CHANGED
@@ -3,8 +3,10 @@ export {
3
3
  GENERATED_FILE_ALLOWLIST,
4
4
  PLATFORM_ROUTE_HREFS,
5
5
  PLATFORM_ROUTES,
6
+ permanentLinkPath,
6
7
  PROJECT_LAYOUT,
7
8
  RESERVED_SLUGS,
9
+ SHORT_ID_PATTERN,
8
10
  } from './project-layout.js';
9
11
  export {
10
12
  assertGeneratedPathAllowed,
@@ -22,6 +24,7 @@ export {
22
24
  SiteConfigurationError,
23
25
  } from './site-configuration.js';
24
26
  export type {
27
+ AddressPolicy,
25
28
  BrandConfiguration,
26
29
  DeploymentConfiguration,
27
30
  DeploymentEnvironmentConfiguration,
@@ -47,6 +47,11 @@ export const PROJECT_LAYOUT = {
47
47
  export const PLATFORM_ROUTES = {
48
48
  /** The whole corpus, grouped by folder. See docs/ADR/017-full-index-page.md. */
49
49
  fullIndex: 'documents',
50
+ /**
51
+ * Permanent links, `/d/<short ID>/`, one per document and section. See
52
+ * docs/ADR/022-permanent-short-ids.md.
53
+ */
54
+ permanentLinks: 'd',
50
55
  } as const;
51
56
 
52
57
  /** Each platform route as the href a link uses. */
@@ -58,6 +63,18 @@ export const RESERVED_SLUGS: readonly string[] = Object.freeze(
58
63
  Object.values(PLATFORM_ROUTES),
59
64
  );
60
65
 
66
+ /**
67
+ * A short ID: lowercase hexadecimal, six characters unless six collided with
68
+ * another item's, in which case it is longer. It is recorded once per item and
69
+ * never changes, so a permanent link keeps working through every rename.
70
+ */
71
+ export const SHORT_ID_PATTERN = /^[0-9a-f]{6,64}$/u;
72
+
73
+ /** The site-relative permanent link of an item with this short ID. */
74
+ export function permanentLinkPath(shortId: string): string {
75
+ return `/${PLATFORM_ROUTES.permanentLinks}/${shortId}/`;
76
+ }
77
+
61
78
  export const GENERATED_DIRECTORY_ALLOWLIST = [
62
79
  PROJECT_LAYOUT.generatedDocumentsDirectory,
63
80
  PROJECT_LAYOUT.generatedAssetsDirectory,
@@ -125,8 +125,27 @@ export interface NavigationConfiguration {
125
125
  * slugs are reserved either way, so turning this on or off moves no address.
126
126
  */
127
127
  readonly sectionIndexPages: boolean;
128
+ /**
129
+ * The Unicode scripts a letter in a Drive name may belong to, such as
130
+ * `["Latin"]`, or `null` when any script is accepted. A name becomes a
131
+ * heading, a sidebar label and, once, a permanent address, so a deployment
132
+ * whose editors write in one alphabet can refuse a letter typed on the other
133
+ * keyboard layout before it reaches any of them. Whatever this allows, a
134
+ * single word never mixes Latin, Cyrillic and Greek letters (ADR-020).
135
+ */
136
+ readonly nameScripts: readonly string[] | null;
137
+ /**
138
+ * Whether an address stays where it was first given (`stable`, ADR-005) or
139
+ * follows its item's current Drive path on every sync over the whole corpus
140
+ * (`follow-names`, ADR-021). Either way a moved address leaves a redirect,
141
+ * so a project can switch while its structure settles and back once people
142
+ * share links. Defaults to `stable`.
143
+ */
144
+ readonly addresses: AddressPolicy;
128
145
  }
129
146
 
147
+ export type AddressPolicy = 'stable' | 'follow-names';
148
+
130
149
  export interface SiteConfiguration {
131
150
  readonly brand: BrandConfiguration;
132
151
  readonly deployment: DeploymentConfiguration;
@@ -242,6 +261,46 @@ function optionalFlag(
242
261
  return source[key] === undefined ? fallback : flag(source, key, path);
243
262
  }
244
263
 
264
+ /**
265
+ * A list of distinct Unicode script names, as a `\p{Script=…}` property
266
+ * escape spells them. Each name is proved by compiling that escape, so a typo
267
+ * fails here rather than letting every letter through, or none.
268
+ */
269
+ function optionalScriptList(
270
+ source: Record<string, unknown>,
271
+ key: string,
272
+ path: string,
273
+ ): readonly string[] | null {
274
+ const value = source[key];
275
+ if (value === undefined) {
276
+ return null;
277
+ }
278
+ if (!Array.isArray(value) || value.length === 0) {
279
+ fail(path, 'must be a non-empty array of Unicode script names');
280
+ }
281
+ const scripts = value.map((entry, index) => {
282
+ if (typeof entry !== 'string' || !/^[A-Za-z_]+$/u.test(entry)) {
283
+ fail(
284
+ `${path}[${index}]`,
285
+ 'must be a Unicode script name such as "Latin"',
286
+ );
287
+ }
288
+ try {
289
+ new RegExp(`\\p{Script=${entry}}`, 'u');
290
+ } catch {
291
+ fail(
292
+ `${path}[${index}]`,
293
+ 'must be a Unicode script name such as "Latin"',
294
+ );
295
+ }
296
+ return entry;
297
+ });
298
+ if (new Set(scripts).size !== scripts.length) {
299
+ fail(path, 'must not repeat a script');
300
+ }
301
+ return Object.freeze(scripts);
302
+ }
303
+
245
304
  /**
246
305
  * A count of things shown on a page. Zero is rejected along with fractions and
247
306
  * negatives: a block configured to show nothing is hidden by the switch that
@@ -263,6 +322,21 @@ function optionalCount(
263
322
  return value;
264
323
  }
265
324
 
325
+ function optionalAddressPolicy(
326
+ source: Record<string, unknown>,
327
+ key: string,
328
+ path: string,
329
+ ): AddressPolicy {
330
+ const value = source[key];
331
+ if (value === undefined) {
332
+ return 'stable';
333
+ }
334
+ if (value !== 'stable' && value !== 'follow-names') {
335
+ fail(path, 'must be "stable" or "follow-names"');
336
+ }
337
+ return value;
338
+ }
339
+
266
340
  function optionalVisibility(
267
341
  source: Record<string, unknown>,
268
342
  path: string,
@@ -396,6 +470,16 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
396
470
  'sectionIndexPages',
397
471
  'navigation.sectionIndexPages',
398
472
  ),
473
+ nameScripts: optionalScriptList(
474
+ navigationSource,
475
+ 'nameScripts',
476
+ 'navigation.nameScripts',
477
+ ),
478
+ addresses: optionalAddressPolicy(
479
+ navigationSource,
480
+ 'addresses',
481
+ 'navigation.addresses',
482
+ ),
399
483
  },
400
484
  sync: {
401
485
  commitBotName: text(syncSource, 'commitBotName', 'sync.commitBotName'),