@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.
- package/LICENSE +202 -0
- package/README.md +37 -0
- package/dist/generated-paths.d.ts +3 -0
- package/dist/generated-paths.js +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/ownership-markers.d.ts +13 -0
- package/dist/ownership-markers.js +16 -0
- package/dist/project-layout.d.ts +35 -0
- package/dist/project-layout.js +43 -0
- package/dist/project-root.d.ts +10 -0
- package/dist/project-root.js +34 -0
- package/dist/site-configuration.d.ts +100 -0
- package/dist/site-configuration.js +206 -0
- package/package.json +37 -0
- package/src/generated-paths.ts +48 -0
- package/src/index.ts +31 -0
- package/src/ownership-markers.ts +23 -0
- package/src/project-layout.ts +45 -0
- package/src/project-root.ts +40 -0
- package/src/site-configuration.ts +380 -0
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
import { PROJECT_LAYOUT } from './project-layout.js';
|
|
17
|
+
export class SiteConfigurationError extends Error {
|
|
18
|
+
name = 'SiteConfigurationError';
|
|
19
|
+
}
|
|
20
|
+
function fail(path, expectation) {
|
|
21
|
+
throw new SiteConfigurationError(`${PROJECT_LAYOUT.configurationFile}: ${path} ${expectation}.`);
|
|
22
|
+
}
|
|
23
|
+
function record(value, path) {
|
|
24
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
25
|
+
fail(path, 'must be an object');
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function text(source, key, path) {
|
|
30
|
+
const value = source[key];
|
|
31
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
32
|
+
fail(path, 'must be a non-empty string');
|
|
33
|
+
}
|
|
34
|
+
return value.trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Accepts only a bare HTTPS origin. A path, query, or embedded credential
|
|
38
|
+
* would silently produce wrong canonical URLs, wrong Wrangler routes, and a
|
|
39
|
+
* smoke test that probes the wrong address.
|
|
40
|
+
*/
|
|
41
|
+
function origin(source, key, path) {
|
|
42
|
+
const value = text(source, key, path);
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(value);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return fail(path, 'must be an absolute URL');
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'https:' ||
|
|
51
|
+
url.username ||
|
|
52
|
+
url.password ||
|
|
53
|
+
url.search ||
|
|
54
|
+
url.hash ||
|
|
55
|
+
!['', '/'].includes(url.pathname)) {
|
|
56
|
+
fail(path, 'must be an HTTPS origin without credentials, path, or query');
|
|
57
|
+
}
|
|
58
|
+
return url;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A list of distinct, non-empty titles. Two entries differing only in case
|
|
62
|
+
* would make the precedence between them depend on which one a folder happens
|
|
63
|
+
* to contain, so they are rejected rather than deduplicated.
|
|
64
|
+
*/
|
|
65
|
+
function titleList(source, key, path) {
|
|
66
|
+
const value = source[key];
|
|
67
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
68
|
+
fail(path, 'must be a non-empty array of titles');
|
|
69
|
+
}
|
|
70
|
+
const titles = value.map((entry, index) => {
|
|
71
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
72
|
+
fail(`${path}[${index}]`, 'must be a non-empty string');
|
|
73
|
+
}
|
|
74
|
+
return entry.trim();
|
|
75
|
+
});
|
|
76
|
+
const seen = new Set(titles.map((title) => title.toLocaleLowerCase('en')));
|
|
77
|
+
if (seen.size !== titles.length) {
|
|
78
|
+
fail(path, 'must not repeat a title');
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze(titles);
|
|
81
|
+
}
|
|
82
|
+
function flag(source, key, path) {
|
|
83
|
+
const value = source[key];
|
|
84
|
+
if (typeof value !== 'boolean') {
|
|
85
|
+
fail(path, 'must be true or false');
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function optionalVisibility(source, path) {
|
|
90
|
+
const value = source.visibility;
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
return 'private';
|
|
93
|
+
}
|
|
94
|
+
if (value !== 'private' && value !== 'public') {
|
|
95
|
+
fail(`${path}.visibility`, 'must be "private" or "public"');
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Environment names become Wrangler environment keys and appear in Worker
|
|
101
|
+
* names as `<worker>-<environment>`, so they are held to the same shape as the
|
|
102
|
+
* Worker name itself.
|
|
103
|
+
*/
|
|
104
|
+
function environments(source) {
|
|
105
|
+
const raw = record(source.environments, 'deployment.environments');
|
|
106
|
+
const names = Object.keys(raw);
|
|
107
|
+
if (!names.includes('production')) {
|
|
108
|
+
fail('deployment.environments', 'must define a production environment');
|
|
109
|
+
}
|
|
110
|
+
const parsed = {};
|
|
111
|
+
const hostnames = new Map();
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const path = `deployment.environments.${name}`;
|
|
114
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(name)) {
|
|
115
|
+
fail(path, 'must be named with lowercase letters, digits, and hyphens');
|
|
116
|
+
}
|
|
117
|
+
const environmentSource = record(raw[name], path);
|
|
118
|
+
const url = origin(environmentSource, 'url', `${path}.url`);
|
|
119
|
+
const visibility = optionalVisibility(environmentSource, path);
|
|
120
|
+
const previous = hostnames.get(url.host);
|
|
121
|
+
if (previous !== undefined) {
|
|
122
|
+
fail(path, `must not reuse the hostname already bound by the ${previous} environment`);
|
|
123
|
+
}
|
|
124
|
+
hostnames.set(url.host, name);
|
|
125
|
+
parsed[name] = { hostname: url.host, url: url.origin, visibility };
|
|
126
|
+
}
|
|
127
|
+
return Object.freeze(parsed);
|
|
128
|
+
}
|
|
129
|
+
export function parseSiteConfiguration(input) {
|
|
130
|
+
const root = record(input, 'the configuration');
|
|
131
|
+
const brandSource = record(root.brand, 'brand');
|
|
132
|
+
const faviconPath = text(brandSource, 'faviconPath', 'brand.faviconPath');
|
|
133
|
+
if (!faviconPath.startsWith('/')) {
|
|
134
|
+
fail('brand.faviconPath', 'must be a site-root-relative path');
|
|
135
|
+
}
|
|
136
|
+
const brand = {
|
|
137
|
+
faviconPath,
|
|
138
|
+
name: text(brandSource, 'name', 'brand.name'),
|
|
139
|
+
siteDescription: text(brandSource, 'siteDescription', 'brand.siteDescription'),
|
|
140
|
+
siteTitle: text(brandSource, 'siteTitle', 'brand.siteTitle'),
|
|
141
|
+
};
|
|
142
|
+
const deploymentSource = record(root.deployment, 'deployment');
|
|
143
|
+
const workerName = text(deploymentSource, 'workerName', 'deployment.workerName');
|
|
144
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(workerName)) {
|
|
145
|
+
fail('deployment.workerName', 'must be lowercase letters, digits, and hyphens');
|
|
146
|
+
}
|
|
147
|
+
const syncSource = record(root.sync, 'sync');
|
|
148
|
+
const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
|
|
149
|
+
// The marker is embedded in an HTML comment in every generated Markdown
|
|
150
|
+
// file. A comment delimiter inside it would terminate that comment early and
|
|
151
|
+
// publish the rest of the marker as document text.
|
|
152
|
+
if (generatedBy.includes('--') || generatedBy.includes('<')) {
|
|
153
|
+
fail('sync.generatedBy', 'must not contain "--" or "<"');
|
|
154
|
+
}
|
|
155
|
+
const defaultLocale = text(syncSource, 'defaultLocale', 'sync.defaultLocale');
|
|
156
|
+
if (defaultLocale.length < 2) {
|
|
157
|
+
fail('sync.defaultLocale', 'must be a language tag such as "en"');
|
|
158
|
+
}
|
|
159
|
+
const navigationSource = record(root.navigation, 'navigation');
|
|
160
|
+
const homeSource = record(root.home, 'home');
|
|
161
|
+
return {
|
|
162
|
+
brand,
|
|
163
|
+
deployment: {
|
|
164
|
+
environments: environments(deploymentSource),
|
|
165
|
+
workerName,
|
|
166
|
+
},
|
|
167
|
+
home: { lede: text(homeSource, 'lede', 'home.lede') },
|
|
168
|
+
navigation: {
|
|
169
|
+
landingDocumentTitles: titleList(navigationSource, 'landingDocumentTitles', 'navigation.landingDocumentTitles'),
|
|
170
|
+
sectionIndexPages: flag(navigationSource, 'sectionIndexPages', 'navigation.sectionIndexPages'),
|
|
171
|
+
},
|
|
172
|
+
sync: {
|
|
173
|
+
commitBotName: text(syncSource, 'commitBotName', 'sync.commitBotName'),
|
|
174
|
+
defaultLocale,
|
|
175
|
+
generatedBy,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Parsed configurations, keyed by absolute project root.
|
|
181
|
+
*
|
|
182
|
+
* The configuration is read by the Astro build, by every component that renders
|
|
183
|
+
* during it, and by the sync CLI. Reading and validating the same file dozens of
|
|
184
|
+
* times per build would be wasted work, and — more importantly — two components
|
|
185
|
+
* disagreeing about the configuration because one of them read a half-written
|
|
186
|
+
* file is a class of bug worth designing out.
|
|
187
|
+
*/
|
|
188
|
+
const loaded = new Map();
|
|
189
|
+
export function loadSiteConfiguration(projectRoot) {
|
|
190
|
+
const root = resolve(projectRoot);
|
|
191
|
+
const cached = loaded.get(root);
|
|
192
|
+
if (cached) {
|
|
193
|
+
return cached;
|
|
194
|
+
}
|
|
195
|
+
const configurationPath = resolve(root, PROJECT_LAYOUT.configurationFile);
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = JSON.parse(readFileSync(configurationPath, 'utf8'));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new SiteConfigurationError(`${configurationPath} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
const configuration = parseSiteConfiguration(raw);
|
|
204
|
+
loaded.set(root, configuration);
|
|
205
|
+
return configuration;
|
|
206
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ctcstack/ctcdocs-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configuration schema, project layout, and generated-path allowlist shared by the CTCDocs platform packages.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ctcstack/ctcdocs.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.12.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "22.20.1",
|
|
28
|
+
"fast-check": "4.9.0",
|
|
29
|
+
"typescript": "6.0.3",
|
|
30
|
+
"vitest": "4.1.10"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc --project tsconfig.build.json",
|
|
34
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
35
|
+
"test": "vitest run --root ../.."
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GENERATED_DIRECTORY_ALLOWLIST,
|
|
5
|
+
GENERATED_FILE_ALLOWLIST,
|
|
6
|
+
} from './project-layout.js';
|
|
7
|
+
|
|
8
|
+
export function normalizeRepositoryPath(input: string): string | undefined {
|
|
9
|
+
if (input.length === 0 || input.includes('\\') || input.startsWith('/')) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const normalized = posix.normalize(input);
|
|
14
|
+
if (
|
|
15
|
+
normalized === '.' ||
|
|
16
|
+
normalized === '..' ||
|
|
17
|
+
normalized.startsWith('../')
|
|
18
|
+
) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isGeneratedPathAllowed(input: string): boolean {
|
|
26
|
+
const normalized = normalizeRepositoryPath(input);
|
|
27
|
+
if (normalized === undefined) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
GENERATED_FILE_ALLOWLIST.some((allowedPath) => normalized === allowedPath)
|
|
33
|
+
) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return GENERATED_DIRECTORY_ALLOWLIST.some(
|
|
38
|
+
(allowedDirectory) =>
|
|
39
|
+
normalized === allowedDirectory ||
|
|
40
|
+
normalized.startsWith(`${allowedDirectory}/`),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertGeneratedPathAllowed(input: string): void {
|
|
45
|
+
if (!isGeneratedPathAllowed(input)) {
|
|
46
|
+
throw new Error(`Generated output path is not allowed: ${input}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export {
|
|
2
|
+
GENERATED_DIRECTORY_ALLOWLIST,
|
|
3
|
+
GENERATED_FILE_ALLOWLIST,
|
|
4
|
+
PROJECT_LAYOUT,
|
|
5
|
+
} from './project-layout.js';
|
|
6
|
+
export {
|
|
7
|
+
assertGeneratedPathAllowed,
|
|
8
|
+
isGeneratedPathAllowed,
|
|
9
|
+
normalizeRepositoryPath,
|
|
10
|
+
} from './generated-paths.js';
|
|
11
|
+
export {
|
|
12
|
+
generatedMarkdownHeader,
|
|
13
|
+
generatedSourceHeader,
|
|
14
|
+
} from './ownership-markers.js';
|
|
15
|
+
export { findProjectRoot, ProjectRootError } from './project-root.js';
|
|
16
|
+
export {
|
|
17
|
+
loadSiteConfiguration,
|
|
18
|
+
parseSiteConfiguration,
|
|
19
|
+
SiteConfigurationError,
|
|
20
|
+
} from './site-configuration.js';
|
|
21
|
+
export type {
|
|
22
|
+
BrandConfiguration,
|
|
23
|
+
DeploymentConfiguration,
|
|
24
|
+
DeploymentEnvironmentConfiguration,
|
|
25
|
+
DeploymentEnvironmentConfigurations,
|
|
26
|
+
DeploymentVisibility,
|
|
27
|
+
HomeConfiguration,
|
|
28
|
+
NavigationConfiguration,
|
|
29
|
+
SiteConfiguration,
|
|
30
|
+
SyncConfigurationDefaults,
|
|
31
|
+
} from './site-configuration.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SiteConfiguration } from './site-configuration.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ownership markers, derived from `sync.generatedBy` in the project
|
|
5
|
+
* configuration so one setting names the pipeline everywhere it signs its
|
|
6
|
+
* output. Markdown carries the HTML-comment form; generated TypeScript carries
|
|
7
|
+
* the line-comment form.
|
|
8
|
+
*
|
|
9
|
+
* They are functions of the configuration rather than constants because the
|
|
10
|
+
* platform does not own the value: it is read from the project that installed
|
|
11
|
+
* the platform, and two projects sharing this package must not share a marker.
|
|
12
|
+
*/
|
|
13
|
+
export function generatedMarkdownHeader(
|
|
14
|
+
configuration: SiteConfiguration,
|
|
15
|
+
): string {
|
|
16
|
+
return `<!-- AUTO-GENERATED BY ${configuration.sync.generatedBy}. DO NOT EDIT. -->`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generatedSourceHeader(
|
|
20
|
+
configuration: SiteConfiguration,
|
|
21
|
+
): string {
|
|
22
|
+
return `// AUTO-GENERATED BY ${configuration.sync.generatedBy}. DO NOT EDIT.`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a CTCDocs project keeps its files.
|
|
3
|
+
*
|
|
4
|
+
* The layout is a fixed convention rather than configuration, and deliberately
|
|
5
|
+
* so: the generated-path allowlist below is a security control. Sync automation
|
|
6
|
+
* fails when it writes outside it, and an allowlist a project can widen is an
|
|
7
|
+
* allowlist an attacker can widen. A project that wants a different shape forks
|
|
8
|
+
* the platform; it does not get a setting.
|
|
9
|
+
*
|
|
10
|
+
* Every path is repository-relative and POSIX-separated, because they are
|
|
11
|
+
* compared against Git paths and written into the manifest.
|
|
12
|
+
*/
|
|
13
|
+
export const PROJECT_LAYOUT = {
|
|
14
|
+
/** Identifies a project root; `findProjectRoot` walks up looking for it. */
|
|
15
|
+
configurationFile: 'site.config.json',
|
|
16
|
+
/** Deployment target. Wrangler reads it itself, so it cannot be generated. */
|
|
17
|
+
wranglerConfigurationFile: 'wrangler.jsonc',
|
|
18
|
+
/** Secret-scanning configuration, whose exemptions `validate` checks. */
|
|
19
|
+
gitleaksConfigurationFile: '.gitleaks.toml',
|
|
20
|
+
/** Served verbatim by the Worker. */
|
|
21
|
+
publicDirectory: 'public',
|
|
22
|
+
robotsFile: 'public/robots.txt',
|
|
23
|
+
headersFile: 'public/_headers',
|
|
24
|
+
/** Generated Markdown, one directory per Drive folder. */
|
|
25
|
+
generatedDocumentsDirectory: 'src/content/docs/_generated',
|
|
26
|
+
/** Original images, one directory per Google file identifier. */
|
|
27
|
+
generatedAssetsDirectory: 'src/assets/generated',
|
|
28
|
+
/** Generated TypeScript: the sidebar and the redirect map. */
|
|
29
|
+
generatedSourceDirectory: 'src/generated',
|
|
30
|
+
manifestFile: 'data/sync-manifest.json',
|
|
31
|
+
documentIndexFile: 'data/docs-index.json',
|
|
32
|
+
syncReportFile: 'data/latest-sync-report.json',
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export const GENERATED_DIRECTORY_ALLOWLIST = [
|
|
36
|
+
PROJECT_LAYOUT.generatedDocumentsDirectory,
|
|
37
|
+
PROJECT_LAYOUT.generatedAssetsDirectory,
|
|
38
|
+
PROJECT_LAYOUT.generatedSourceDirectory,
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export const GENERATED_FILE_ALLOWLIST = [
|
|
42
|
+
PROJECT_LAYOUT.manifestFile,
|
|
43
|
+
PROJECT_LAYOUT.documentIndexFile,
|
|
44
|
+
PROJECT_LAYOUT.syncReportFile,
|
|
45
|
+
] as const;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the project a platform package is running inside.
|
|
3
|
+
*
|
|
4
|
+
* The platform packages live in `node_modules` of a project they know nothing
|
|
5
|
+
* about. The CLI, the Astro components, and the browser suite all need the
|
|
6
|
+
* project's root — to read its configuration, to resolve generated output, to
|
|
7
|
+
* point Playwright at a corpus — and none of them can be told where it is by a
|
|
8
|
+
* relative path from their own file.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { PROJECT_LAYOUT } from './project-layout.js';
|
|
14
|
+
|
|
15
|
+
export class ProjectRootError extends Error {
|
|
16
|
+
override readonly name = 'ProjectRootError';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The nearest ancestor of `from` that holds `site.config.json`, `from`
|
|
21
|
+
* included. Defaults to the working directory, which is the project root for
|
|
22
|
+
* `astro build`, for `ctcdocs-sync`, and for every script a project's
|
|
23
|
+
* `package.json` runs.
|
|
24
|
+
*/
|
|
25
|
+
export function findProjectRoot(from: string = process.cwd()): string {
|
|
26
|
+
let directory = resolve(from);
|
|
27
|
+
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (existsSync(resolve(directory, PROJECT_LAYOUT.configurationFile))) {
|
|
30
|
+
return directory;
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(directory);
|
|
33
|
+
if (parent === directory) {
|
|
34
|
+
throw new ProjectRootError(
|
|
35
|
+
`No ${PROJECT_LAYOUT.configurationFile} found in ${resolve(from)} or any parent directory. Run this from inside a CTCDocs project.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
directory = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|