canopycms 0.0.67 → 0.0.68-int.93
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/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +8 -8
- package/dist/api/branch-status.js +6 -2
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +35 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +38 -39
- package/dist/api/entries.js +6 -8
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/request-url.d.ts +8 -0
- package/dist/api/request-url.js +15 -0
- package/dist/api/resolve-references.js +2 -2
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +2 -2
- package/dist/cli/cli.js +285 -168
- package/dist/cli/generate-ai-content.js +190 -109
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +15 -10
- package/dist/cli/sync.js +15 -13
- package/dist/config/schemas/config.d.ts +6 -3
- package/dist/config/schemas/config.js +3 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/types.d.ts +11 -2
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +2 -2
- package/dist/content-listing.js +3 -3
- package/dist/content-reader.js +4 -4
- package/dist/content-store.d.ts +2 -0
- package/dist/content-store.js +2 -1
- package/dist/content-tree.js +1 -1
- package/dist/context.d.ts +16 -7
- package/dist/context.js +116 -67
- package/dist/editor/Editor.js +4 -4
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +37 -6
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/entry-schema-registry.js +2 -1
- package/dist/git-manager.d.ts +20 -0
- package/dist/git-manager.js +50 -4
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +24 -9
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +3 -3
- package/dist/reference-resolver.js +3 -2
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/services.d.ts +12 -5
- package/dist/services.js +56 -56
- package/dist/settings-workspace.js +36 -0
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.js +7 -8
- package/dist/types.d.ts +23 -2
- package/dist/utils/content-write-lock.d.ts +3 -7
- package/dist/utils/content-write-lock.js +6 -4
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +15 -0
- package/dist/utils/url-prefix.js +26 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/git-sync.d.ts +4 -2
- package/dist/worker/git-sync.js +111 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.js +75 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/services.js
CHANGED
|
@@ -9,8 +9,12 @@ import { operatingStrategy } from './operating-mode/index.js';
|
|
|
9
9
|
import { BranchSchemaCache } from './branch-schema-cache.js';
|
|
10
10
|
import { enqueueTask } from './task-queue/cms-task-queue.js';
|
|
11
11
|
import { getTaskQueueDir } from './task-queue/task-queue-config.js';
|
|
12
|
-
import { detectHeadBranch } from './utils/git.js';
|
|
12
|
+
import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
|
|
13
13
|
import { readsFromCheckout } from './build-mode.js';
|
|
14
|
+
import { timeRequestPhase } from './utils/request-timing.js';
|
|
15
|
+
import { BRANCH_META_DIR } from './branch-metadata-file.js';
|
|
16
|
+
import { appendTrailers, buildEditorTrailers, } from './submission-attribution.js';
|
|
17
|
+
import { getErrorMessage, redactCredentials } from './utils/error.js';
|
|
14
18
|
/**
|
|
15
19
|
* A per-instance active-branch detector with its own 5s TTL cache, in priority
|
|
16
20
|
* order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
|
|
@@ -97,7 +101,7 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
97
101
|
const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
|
|
98
102
|
const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
|
|
99
103
|
// Content access loads permissions dynamically from the settings branch (orphan git branch)
|
|
100
|
-
const
|
|
104
|
+
const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
|
|
101
105
|
(async () => {
|
|
102
106
|
const strategy = operatingStrategy(config.mode);
|
|
103
107
|
const settingsRoot = strategy.getSettingsRoot();
|
|
@@ -111,9 +115,10 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
111
115
|
});
|
|
112
116
|
return settingsRoot;
|
|
113
117
|
});
|
|
118
|
+
const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
|
|
114
119
|
const contentAccessDeps = {
|
|
115
120
|
checkBranchAccess,
|
|
116
|
-
loadPathPermissions,
|
|
121
|
+
loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
|
|
117
122
|
defaultPathAccess: config.defaultPathAccess ?? 'deny',
|
|
118
123
|
mode: config.mode,
|
|
119
124
|
getSettingsBranchRoot,
|
|
@@ -164,15 +169,36 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
164
169
|
// and success is reported though the commit never reached the remote. Push
|
|
165
170
|
// whenever there is something new to send — we just committed, or the local
|
|
166
171
|
// branch already had unpushed commits from an earlier attempt.
|
|
172
|
+
// canopycms's own state under .canopy-meta/ is never content, so it neither
|
|
173
|
+
// makes a commit worth creating nor gets staged into one.
|
|
167
174
|
let committed = false;
|
|
168
|
-
if (status.files.
|
|
169
|
-
await git.
|
|
170
|
-
|
|
175
|
+
if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
|
|
176
|
+
await git.addAllExceptCanopyState();
|
|
177
|
+
const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
|
|
178
|
+
editedBy: config.gitEditedByTrailers ?? true,
|
|
179
|
+
coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
|
|
180
|
+
});
|
|
181
|
+
await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
|
|
171
182
|
committed = true;
|
|
172
183
|
}
|
|
173
184
|
if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
|
|
174
185
|
await git.push(options.context.branch.name);
|
|
175
186
|
}
|
|
187
|
+
// The list only feeds the PR body, so a failure to compute it falls back to
|
|
188
|
+
// this submit's own working-tree changes rather than failing a submit that
|
|
189
|
+
// has already been pushed.
|
|
190
|
+
let changedPaths;
|
|
191
|
+
try {
|
|
192
|
+
changedPaths = await git.listChangedPathsSinceBase();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
|
|
196
|
+
"the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
|
|
197
|
+
changedPaths = status.files.map((f) => f.path);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
|
|
201
|
+
};
|
|
176
202
|
};
|
|
177
203
|
// Must be initialized before closures that reference it (commitToSettingsBranch)
|
|
178
204
|
let githubService;
|
|
@@ -233,57 +259,31 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
233
259
|
error: error instanceof Error ? error.message : 'Push failed',
|
|
234
260
|
};
|
|
235
261
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
console.warn('Failed to create/update PR:', err);
|
|
263
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
264
|
-
}
|
|
265
|
-
return { committed: true, pushed: true, prUrl, syncStatus: 'synced' };
|
|
266
|
-
}
|
|
267
|
-
// Async path: queue task for worker (prod Lambda has no internet)
|
|
268
|
-
const taskDir = getTaskQueueDir(config);
|
|
269
|
-
try {
|
|
270
|
-
await enqueueTask(taskDir, {
|
|
271
|
-
action: 'push-and-create-or-update-pr',
|
|
272
|
-
payload: {
|
|
273
|
-
branch: settingsBranch,
|
|
274
|
-
baseBranch: config.defaultBaseBranch ?? 'main',
|
|
275
|
-
title: 'Update permissions and groups',
|
|
276
|
-
body: settingsPRBody,
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
console.warn('Failed to enqueue settings PR task:', err);
|
|
283
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
284
|
-
}
|
|
262
|
+
// The settings branch is an orphan: it shares no history with the base
|
|
263
|
+
// branch, so GitHub rejects a PR for it. The change took effect when it
|
|
264
|
+
// was committed and pushed above (permissions and groups are read live
|
|
265
|
+
// from the settings workspace); the only GitHub step is mirroring the
|
|
266
|
+
// branch, so there is never a PR.
|
|
267
|
+
if (!operatingStrategy(mode).supportsPullRequests()) {
|
|
268
|
+
return { committed: true, pushed: true };
|
|
269
|
+
}
|
|
270
|
+
// With a githubService the push above already reached GitHub: both it and
|
|
271
|
+
// the workspace remote come from `defaultRemoteUrl`. Without one (prod
|
|
272
|
+
// Lambda has no internet) the worker pushes it.
|
|
273
|
+
if (githubService) {
|
|
274
|
+
return { committed: true, pushed: true, syncStatus: 'synced' };
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
await enqueueTask(getTaskQueueDir(config), {
|
|
278
|
+
action: 'push-branch',
|
|
279
|
+
payload: { branch: settingsBranch },
|
|
280
|
+
});
|
|
281
|
+
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
console.warn('Failed to enqueue settings push task:', err);
|
|
285
|
+
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
285
286
|
}
|
|
286
|
-
return { committed: true, pushed: true };
|
|
287
287
|
}
|
|
288
288
|
catch (error) {
|
|
289
289
|
return {
|
|
@@ -9,6 +9,35 @@ const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
|
|
|
9
9
|
// In-memory lock against concurrent init within one process. One lock suffices,
|
|
10
10
|
// unlike content branches, which need one per branch.
|
|
11
11
|
let settingsInitLock = null;
|
|
12
|
+
/**
|
|
13
|
+
* Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
|
|
14
|
+
* skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
|
|
15
|
+
* otherwise ran on every API request. It is sound because that pass never fetched, pulled or
|
|
16
|
+
* reset the settings branch, so settings freshness never came from it: every process reads the
|
|
17
|
+
* one shared workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
|
|
18
|
+
* from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
|
|
19
|
+
* workspace out onto another branch. groups.json and permissions.json are still read from
|
|
20
|
+
* disk on every request; only the provisioning is memoized. Failures are never recorded. In
|
|
21
|
+
* dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
|
|
22
|
+
* re-seeds it.
|
|
23
|
+
*
|
|
24
|
+
* A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
|
|
25
|
+
* caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
|
|
26
|
+
* path, guard and lock included.
|
|
27
|
+
*/
|
|
28
|
+
const ensuredSettingsWorkspaces = new Set();
|
|
29
|
+
function ensuredKey(options) {
|
|
30
|
+
return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
|
|
31
|
+
}
|
|
32
|
+
async function checkedOutOn(settingsRoot, branchName) {
|
|
33
|
+
try {
|
|
34
|
+
const head = await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8');
|
|
35
|
+
return head.trim() === `ref: refs/heads/${branchName}`;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
12
41
|
const SETTINGS_INIT_LOCK_DIR = '.settings-init';
|
|
13
42
|
const SETTINGS_INIT_LOCK_NAME = 'lock';
|
|
14
43
|
/**
|
|
@@ -122,6 +151,12 @@ export class SettingsWorkspaceManager {
|
|
|
122
151
|
this.config = config;
|
|
123
152
|
}
|
|
124
153
|
async ensureGitWorkspace(options) {
|
|
154
|
+
const key = ensuredKey(options);
|
|
155
|
+
if (ensuredSettingsWorkspaces.has(key)) {
|
|
156
|
+
if (await checkedOutOn(options.settingsRoot, options.branchName))
|
|
157
|
+
return;
|
|
158
|
+
ensuredSettingsWorkspaces.delete(key);
|
|
159
|
+
}
|
|
125
160
|
return log.timed('workspace', 'ensureGitWorkspace', async () => {
|
|
126
161
|
// Layer 1: In-memory lock (prevents redundant async calls within same process)
|
|
127
162
|
if (settingsInitLock) {
|
|
@@ -170,6 +205,7 @@ export class SettingsWorkspaceManager {
|
|
|
170
205
|
gitBotAuthorName: this.config.gitBotAuthorName,
|
|
171
206
|
gitBotAuthorEmail: this.config.gitBotAuthorEmail,
|
|
172
207
|
});
|
|
208
|
+
ensuredSettingsWorkspaces.add(key);
|
|
173
209
|
}
|
|
174
210
|
finally {
|
|
175
211
|
try {
|
package/dist/static/seo.d.ts
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* entry-schema.ts, which builds a schema group from these same names so the schema side and
|
|
10
10
|
* the read side cannot drift.
|
|
11
11
|
*/
|
|
12
|
-
import { isAbsoluteUrl, stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
12
|
+
import { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash } from '../utils/url-prefix.js';
|
|
13
13
|
/**
|
|
14
14
|
* Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
|
|
15
15
|
* is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
|
|
16
16
|
* `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
|
|
17
17
|
* import anything under `static/`.
|
|
18
18
|
*/
|
|
19
|
-
export { isAbsoluteUrl, stripTrailingSlashes };
|
|
19
|
+
export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
|
|
20
20
|
/** og:type values covered by the recommended group. */
|
|
21
21
|
export type SeoOgType = 'website' | 'article' | 'profile';
|
|
22
22
|
/** twitter:card values covered by the recommended group. */
|
|
@@ -87,18 +87,6 @@ export declare function extractSeoFields(entryData: unknown, opts?: ExtractSeoFi
|
|
|
87
87
|
* resolves for anyone holding the link.
|
|
88
88
|
*/
|
|
89
89
|
export declare function isNoindexEntry(entryData: unknown, opts?: SeoFieldLocation): boolean;
|
|
90
|
-
/**
|
|
91
|
-
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
92
|
-
*
|
|
93
|
-
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
94
|
-
* `/blog/rss.xml`) alone, and never doubles an existing slash.
|
|
95
|
-
*
|
|
96
|
-
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
97
|
-
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
98
|
-
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
99
|
-
* with a trailing slash" means and breaks the URL).
|
|
100
|
-
*/
|
|
101
|
-
export declare function withTrailingSlash(path: string): string;
|
|
102
90
|
export interface ResolveSeoUrlOptions {
|
|
103
91
|
/** Site origin (e.g. `https://example.com`). Trailing slashes are stripped. */
|
|
104
92
|
siteUrl?: string;
|
package/dist/static/seo.js
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* entry-schema.ts, which builds a schema group from these same names so the schema side and
|
|
10
10
|
* the read side cannot drift.
|
|
11
11
|
*/
|
|
12
|
-
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, } from '../utils/url-prefix.js';
|
|
12
|
+
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, withTrailingSlash, } from '../utils/url-prefix.js';
|
|
13
13
|
/**
|
|
14
14
|
* Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
|
|
15
15
|
* is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
|
|
16
16
|
* `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
|
|
17
17
|
* import anything under `static/`.
|
|
18
18
|
*/
|
|
19
|
-
export { isAbsoluteUrl, stripTrailingSlashes };
|
|
19
|
+
export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
|
|
20
20
|
/**
|
|
21
21
|
* The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
|
|
22
22
|
* these names, so an adopter who uses both ends needs no configuration at all.
|
|
@@ -106,29 +106,6 @@ export function extractSeoFields(entryData, opts = {}) {
|
|
|
106
106
|
export function isNoindexEntry(entryData, opts = {}) {
|
|
107
107
|
return extractSeoFields(entryData, opts).noindex === true;
|
|
108
108
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
111
|
-
*
|
|
112
|
-
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
113
|
-
* `/blog/rss.xml`) alone, and never doubles an existing slash.
|
|
114
|
-
*
|
|
115
|
-
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
116
|
-
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
117
|
-
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
118
|
-
* with a trailing slash" means and breaks the URL).
|
|
119
|
-
*/
|
|
120
|
-
export function withTrailingSlash(path) {
|
|
121
|
-
const splitIndex = path.search(/[?#]/);
|
|
122
|
-
const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
|
|
123
|
-
const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
|
|
124
|
-
const withLeading = base.startsWith('/') ? base : `/${base}`;
|
|
125
|
-
if (withLeading === '/' || withLeading.endsWith('/'))
|
|
126
|
-
return withLeading + suffix;
|
|
127
|
-
const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
|
|
128
|
-
if (lastSegment.includes('.'))
|
|
129
|
-
return withLeading + suffix;
|
|
130
|
-
return `${withLeading}/${suffix}`;
|
|
131
|
-
}
|
|
132
109
|
/**
|
|
133
110
|
* Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
|
|
134
111
|
*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CanopyUser } from './user.js';
|
|
2
|
+
/**
|
|
3
|
+
* Who submitted (and edited) a branch, recorded in the submit commit's trailers
|
|
4
|
+
* and the pull request body. The bot stays the commit author; these records are
|
|
5
|
+
* the only place the editing user appears.
|
|
6
|
+
*
|
|
7
|
+
* Display names, ids and emails come from the auth provider and end up in git
|
|
8
|
+
* history and in GitHub-rendered Markdown, so each passes through a sanitizer
|
|
9
|
+
* below before it is written anywhere.
|
|
10
|
+
*/
|
|
11
|
+
export interface SubmissionEditor {
|
|
12
|
+
userId: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
email?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The PR body region canopycms owns. On update only the text between these
|
|
18
|
+
* markers is replaced, so anything a human wrote around it survives.
|
|
19
|
+
* @internal Exported for tests.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PR_SECTION_START = "<!-- canopycms:submission:start -->";
|
|
22
|
+
/** @internal Exported for tests. */
|
|
23
|
+
export declare const PR_SECTION_END = "<!-- canopycms:submission:end -->";
|
|
24
|
+
/**
|
|
25
|
+
* A display name safe for a single line of git trailer or Markdown, or undefined.
|
|
26
|
+
* @internal Exported for tests.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sanitizeDisplayName(raw: string | undefined): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The auth user id unchanged, or undefined if it holds characters unsafe to record.
|
|
31
|
+
* @internal Exported for tests.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sanitizeUserId(raw: string | undefined): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The email as given if it is a plain single-address email, otherwise undefined.
|
|
36
|
+
* @internal Exported for tests.
|
|
37
|
+
*/
|
|
38
|
+
export declare function sanitizeEmail(raw: string | undefined): string | undefined;
|
|
39
|
+
/** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
|
|
40
|
+
export declare function submissionEditorFromUser(user: CanopyUser): SubmissionEditor | undefined;
|
|
41
|
+
export interface CommitTrailerOptions {
|
|
42
|
+
/** One `Edited-by: Name (id)` per editor. */
|
|
43
|
+
editedBy: boolean;
|
|
44
|
+
/** One `Co-authored-by: Name <email>` per editor whose email is valid. */
|
|
45
|
+
coAuthoredBy: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Git trailer lines for the given editors, deduplicated by user id. */
|
|
48
|
+
export declare function buildEditorTrailers(editors: readonly SubmissionEditor[], options: CommitTrailerOptions): string[];
|
|
49
|
+
/**
|
|
50
|
+
* `subject`, then a blank line and the trailer block when there are trailers.
|
|
51
|
+
* Git reads trailers only from the last paragraph, so the blank line is what
|
|
52
|
+
* makes them trailers rather than part of the subject.
|
|
53
|
+
*/
|
|
54
|
+
export declare function appendTrailers(subject: string, trailers: readonly string[]): string;
|
|
55
|
+
export interface PrSectionInput {
|
|
56
|
+
/** The branch description the editor wrote; free Markdown. */
|
|
57
|
+
description?: string;
|
|
58
|
+
submitter?: SubmissionEditor;
|
|
59
|
+
/** Editors other than the submitter, when known. */
|
|
60
|
+
editors?: readonly SubmissionEditor[];
|
|
61
|
+
/** Repo-relative paths changed on the branch. */
|
|
62
|
+
changedPaths: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/** The canopycms-owned PR body section, including its start and end markers. */
|
|
65
|
+
export declare function buildPrSection(input: PrSectionInput): string;
|
|
66
|
+
/**
|
|
67
|
+
* `existing` with its canopycms section replaced by `section`, keeping every
|
|
68
|
+
* character outside the markers. The section is the first end marker together
|
|
69
|
+
* with the nearest start marker before it, so a lone start marker a human quoted
|
|
70
|
+
* earlier in the body is skipped. With no such pair, stray markers are
|
|
71
|
+
* removed and `section` is appended after the human text.
|
|
72
|
+
*/
|
|
73
|
+
export declare function mergePrSection(existing: string | null | undefined, section: string): string;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { canopyLogWarn } from './utils/logger.js';
|
|
2
|
+
const MAX_NAME_LENGTH = 80;
|
|
3
|
+
const MAX_ID_LENGTH = 128;
|
|
4
|
+
const MAX_EMAIL_LENGTH = 254;
|
|
5
|
+
const MAX_DESCRIPTION_LENGTH = 10_000;
|
|
6
|
+
const MAX_LISTED_ENTRIES = 100;
|
|
7
|
+
/**
|
|
8
|
+
* The PR body region canopycms owns. On update only the text between these
|
|
9
|
+
* markers is replaced, so anything a human wrote around it survives.
|
|
10
|
+
* @internal Exported for tests.
|
|
11
|
+
*/
|
|
12
|
+
export const PR_SECTION_START = '<!-- canopycms:submission:start -->';
|
|
13
|
+
/** @internal Exported for tests. */
|
|
14
|
+
export const PR_SECTION_END = '<!-- canopycms:submission:end -->';
|
|
15
|
+
// Cc (C0/C1 controls incl. newlines), Cf (zero-width and bidi overrides), Zl/Zp
|
|
16
|
+
// (Unicode line/paragraph separators) can forge a trailer line or hide text; Cs
|
|
17
|
+
// (a lone surrogate) reaches git as U+FFFD, since Node encodes it so.
|
|
18
|
+
const INVISIBLE_OR_CONTROL = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu;
|
|
19
|
+
// `<>` would end a Co-authored-by email or open HTML / a section marker, `()`
|
|
20
|
+
// would let a name forge the id that follows it, a backtick would close the
|
|
21
|
+
// code span names render in, and a backslash is Markdown's escape character.
|
|
22
|
+
const STRUCTURAL = /[<>()`\\]/g;
|
|
23
|
+
function truncate(value, max) {
|
|
24
|
+
const chars = Array.from(value);
|
|
25
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : value;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A changed path for a code span: only what could break out of the span or out
|
|
29
|
+
* of the section is removed, so `app/(group)/page.mdx` stays distinct from
|
|
30
|
+
* `app/page.mdx`. No NFKC, for the same reason.
|
|
31
|
+
*/
|
|
32
|
+
function cleanPath(raw) {
|
|
33
|
+
const cleaned = raw.replace(INVISIBLE_OR_CONTROL, ' ').replace(/[<>`]/g, '').trim();
|
|
34
|
+
return cleaned ? truncate(cleaned, 300) : undefined;
|
|
35
|
+
}
|
|
36
|
+
function cleanText(raw, max) {
|
|
37
|
+
if (raw === undefined)
|
|
38
|
+
return undefined;
|
|
39
|
+
const cleaned = raw
|
|
40
|
+
.normalize('NFKC')
|
|
41
|
+
.replace(INVISIBLE_OR_CONTROL, ' ')
|
|
42
|
+
.replace(STRUCTURAL, '')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim();
|
|
45
|
+
return cleaned ? truncate(cleaned, max) : undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A display name safe for a single line of git trailer or Markdown, or undefined.
|
|
49
|
+
* @internal Exported for tests.
|
|
50
|
+
*/
|
|
51
|
+
export function sanitizeDisplayName(raw) {
|
|
52
|
+
return cleanText(raw, MAX_NAME_LENGTH);
|
|
53
|
+
}
|
|
54
|
+
// Ids and emails are accepted as the provider issued them or rejected, never
|
|
55
|
+
// cleaned: cleaning could merge two users or attribute an edit to someone else.
|
|
56
|
+
// A trailer still swaps in look-alikes for display (trailerText).
|
|
57
|
+
const UNSAFE_VERBATIM_CHAR = /[\s\p{Cc}\p{Cf}\p{Cs}<>()`\\]/u;
|
|
58
|
+
/**
|
|
59
|
+
* The auth user id unchanged, or undefined if it holds characters unsafe to record.
|
|
60
|
+
* @internal Exported for tests.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeUserId(raw) {
|
|
63
|
+
if (!raw || Array.from(raw).length > MAX_ID_LENGTH || UNSAFE_VERBATIM_CHAR.test(raw))
|
|
64
|
+
return undefined;
|
|
65
|
+
return raw;
|
|
66
|
+
}
|
|
67
|
+
const EMAIL_SHAPE = /^[^\s@<>()"',;:\\[\]]+@[^\s@<>()"',;:\\[\]]+\.[^\s@<>()"',;:\\[\]]+$/;
|
|
68
|
+
/**
|
|
69
|
+
* The email as given if it is a plain single-address email, otherwise undefined.
|
|
70
|
+
* @internal Exported for tests.
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeEmail(raw) {
|
|
73
|
+
if (raw === undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
const trimmed = raw.trim();
|
|
76
|
+
// `\s` misses most C0/C1 controls, and a NUL makes git's spawn throw. `#` is
|
|
77
|
+
// legal in an address but GitHub links `#12` in a commit message.
|
|
78
|
+
if (trimmed.length > MAX_EMAIL_LENGTH ||
|
|
79
|
+
UNSAFE_VERBATIM_CHAR.test(trimmed) ||
|
|
80
|
+
trimmed.includes('#'))
|
|
81
|
+
return undefined;
|
|
82
|
+
return EMAIL_SHAPE.test(trimmed) ? trimmed : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
|
|
85
|
+
export function submissionEditorFromUser(user) {
|
|
86
|
+
if (user.type !== 'authenticated')
|
|
87
|
+
return undefined;
|
|
88
|
+
return { userId: user.userId, name: user.name, email: user.email };
|
|
89
|
+
}
|
|
90
|
+
function cleanEditors(editors) {
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const editor of editors) {
|
|
94
|
+
const userId = sanitizeUserId(editor.userId);
|
|
95
|
+
if (!userId) {
|
|
96
|
+
// The id itself stays out of the log: it is the unsafe value.
|
|
97
|
+
if (editor.userId) {
|
|
98
|
+
canopyLogWarn('CanopyCMS: Not recording an editor whose user id is unsafe to write to git');
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(userId))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(userId);
|
|
105
|
+
result.push({
|
|
106
|
+
userId,
|
|
107
|
+
name: sanitizeDisplayName(editor.name),
|
|
108
|
+
email: sanitizeEmail(editor.email),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* GitHub autolinks commit messages: `@user` mentions, `scheme://` URLs, and `#12` /
|
|
115
|
+
* `GH-12` issue references, which a closing keyword ("Closes #12") turns into an
|
|
116
|
+
* issue close once the commit reaches the default branch. A trailer value gets
|
|
117
|
+
* `@` and `#` swapped for look-alikes and the other two patterns broken. Its
|
|
118
|
+
* input is already single-line and structure-free.
|
|
119
|
+
*/
|
|
120
|
+
function trailerText(value) {
|
|
121
|
+
return value
|
|
122
|
+
.replace(/@/g, '@')
|
|
123
|
+
.replace(/#/g, '#')
|
|
124
|
+
.replace(/:\/\//g, ': //')
|
|
125
|
+
.replace(/\bGH-(?=\d)/gi, (m) => `${m.slice(0, 2)}‐`);
|
|
126
|
+
}
|
|
127
|
+
/** Git trailer lines for the given editors, deduplicated by user id. */
|
|
128
|
+
export function buildEditorTrailers(editors, options) {
|
|
129
|
+
const trailers = [];
|
|
130
|
+
const clean = cleanEditors(editors);
|
|
131
|
+
if (options.editedBy) {
|
|
132
|
+
for (const editor of clean) {
|
|
133
|
+
trailers.push(editor.name
|
|
134
|
+
? `Edited-by: ${trailerText(editor.name)} (${trailerText(editor.userId)})`
|
|
135
|
+
: `Edited-by: ${trailerText(editor.userId)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (options.coAuthoredBy) {
|
|
139
|
+
for (const editor of clean) {
|
|
140
|
+
if (!editor.email)
|
|
141
|
+
continue;
|
|
142
|
+
trailers.push(`Co-authored-by: ${trailerText(editor.name ?? editor.userId)} <${editor.email}>`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return trailers;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `subject`, then a blank line and the trailer block when there are trailers.
|
|
149
|
+
* Git reads trailers only from the last paragraph, so the blank line is what
|
|
150
|
+
* makes them trailers rather than part of the subject.
|
|
151
|
+
*/
|
|
152
|
+
export function appendTrailers(subject, trailers) {
|
|
153
|
+
return trailers.length > 0 ? `${subject.trimEnd()}\n\n${trailers.join('\n')}` : subject;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Renders as inline code: GitHub neither autolinks, mentions nor interprets HTML
|
|
157
|
+
* inside a code span. The input is already free of backticks.
|
|
158
|
+
*/
|
|
159
|
+
function codeSpan(value) {
|
|
160
|
+
return `\`${value}\``;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Free Markdown with every HTML comment opener and closer escaped, so the text
|
|
164
|
+
* can neither close the canopycms section early nor open a comment that hides
|
|
165
|
+
* the rest of the body. Escaped comments render as their literal text.
|
|
166
|
+
*/
|
|
167
|
+
function neutralizeComments(text) {
|
|
168
|
+
return text.replace(/<!--/g, '<!--').replace(/--!?>/g, '-->');
|
|
169
|
+
}
|
|
170
|
+
function editorLabel(editor) {
|
|
171
|
+
return editor.name
|
|
172
|
+
? `${codeSpan(editor.name)} (${codeSpan(editor.userId)})`
|
|
173
|
+
: codeSpan(editor.userId);
|
|
174
|
+
}
|
|
175
|
+
/** The canopycms-owned PR body section, including its start and end markers. */
|
|
176
|
+
export function buildPrSection(input) {
|
|
177
|
+
const parts = [];
|
|
178
|
+
const [submitter] = input.submitter ? cleanEditors([input.submitter]) : [];
|
|
179
|
+
parts.push(submitter
|
|
180
|
+
? `Submitted by ${editorLabel(submitter)} via CanopyCMS.`
|
|
181
|
+
: 'Submitted via CanopyCMS.');
|
|
182
|
+
const others = cleanEditors(input.editors ?? []).filter((e) => e.userId !== submitter?.userId);
|
|
183
|
+
if (others.length > 0) {
|
|
184
|
+
parts.push(`Also edited by: ${others.map(editorLabel).join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
const paths = [...new Set(input.changedPaths)]
|
|
187
|
+
.map(cleanPath)
|
|
188
|
+
.filter((p) => p !== undefined);
|
|
189
|
+
if (paths.length > 0) {
|
|
190
|
+
const listed = paths.slice(0, MAX_LISTED_ENTRIES).map((p) => `- ${codeSpan(p)}`);
|
|
191
|
+
if (paths.length > MAX_LISTED_ENTRIES) {
|
|
192
|
+
listed.push(`- …and ${paths.length - MAX_LISTED_ENTRIES} more`);
|
|
193
|
+
}
|
|
194
|
+
parts.push(`**Changed entries (${paths.length})**\n\n${listed.join('\n')}`);
|
|
195
|
+
}
|
|
196
|
+
// Last, so an unclosed fence or `<details>` in free Markdown cannot swallow
|
|
197
|
+
// the attribution above it.
|
|
198
|
+
const description = input.description?.trim();
|
|
199
|
+
if (description)
|
|
200
|
+
parts.push(neutralizeComments(truncate(description, MAX_DESCRIPTION_LENGTH)));
|
|
201
|
+
return `${PR_SECTION_START}\n${parts.join('\n\n')}\n${PR_SECTION_END}`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* `existing` with its canopycms section replaced by `section`, keeping every
|
|
205
|
+
* character outside the markers. The section is the first end marker together
|
|
206
|
+
* with the nearest start marker before it, so a lone start marker a human quoted
|
|
207
|
+
* earlier in the body is skipped. With no such pair, stray markers are
|
|
208
|
+
* removed and `section` is appended after the human text.
|
|
209
|
+
*/
|
|
210
|
+
export function mergePrSection(existing, section) {
|
|
211
|
+
const body = existing ?? '';
|
|
212
|
+
for (let end = body.indexOf(PR_SECTION_END); end >= 0;) {
|
|
213
|
+
const start = body.lastIndexOf(PR_SECTION_START, end - PR_SECTION_START.length);
|
|
214
|
+
if (start >= 0) {
|
|
215
|
+
return body.slice(0, start) + section + body.slice(end + PR_SECTION_END.length);
|
|
216
|
+
}
|
|
217
|
+
end = body.indexOf(PR_SECTION_END, end + PR_SECTION_END.length);
|
|
218
|
+
}
|
|
219
|
+
const human = body.split(PR_SECTION_START).join('').split(PR_SECTION_END).join('').trimEnd();
|
|
220
|
+
return human ? `${human}\n\n${section}` : section;
|
|
221
|
+
}
|
package/dist/sync-core.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { simpleGit } from 'simple-git';
|
|
9
9
|
import { invalidateBranchContentCaches } from './content-index-generation.js';
|
|
10
10
|
import { filePathExists } from './utils/fs.js';
|
|
11
|
+
import { isCanopyInternalPath, stageAllExceptCanopyState } from './utils/git.js';
|
|
11
12
|
/** Git tag marking the last known sync point, used as the merge base for `sync both` 3-way merges. */
|
|
12
13
|
export const SYNC_BASE_TAG = 'canopycms-sync-base';
|
|
13
14
|
/** Validate that a resolved path stays within the expected parent directory. */
|
|
@@ -154,15 +155,13 @@ export async function pushContentToWorkspace(options) {
|
|
|
154
155
|
// The content dir was replaced wholesale, so ContentStore ID indexes rooted
|
|
155
156
|
// at this workspace must be marked stale — in this process and (via the
|
|
156
157
|
// on-disk generation marker) in every other process sharing the filesystem.
|
|
157
|
-
// Done in the finally AFTER the git add/commit below
|
|
158
|
-
// .canopy-meta
|
|
159
|
-
// the sync commit via `add -A` (production workspaces git-exclude
|
|
160
|
-
// .canopy-meta/, but this function shouldn't depend on that).
|
|
158
|
+
// Done in the finally AFTER the git add/commit below, and staging skips
|
|
159
|
+
// .canopy-meta/, where the marker lives.
|
|
161
160
|
try {
|
|
162
161
|
const wsGit = simpleGit({ baseDir: branchPath });
|
|
163
|
-
await wsGit
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
162
|
+
await stageAllExceptCanopyState(wsGit);
|
|
163
|
+
const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
|
|
164
|
+
if (staged.length === 0) {
|
|
166
165
|
if (baseTag)
|
|
167
166
|
await wsGit.tag(['-f', baseTag]);
|
|
168
167
|
return { fileCount: 0 };
|
|
@@ -170,7 +169,7 @@ export async function pushContentToWorkspace(options) {
|
|
|
170
169
|
await wsGit.commit(commitMessage ?? 'sync: update content from working tree');
|
|
171
170
|
if (baseTag)
|
|
172
171
|
await wsGit.tag(['-f', baseTag]);
|
|
173
|
-
return { fileCount:
|
|
172
|
+
return { fileCount: staged.length };
|
|
174
173
|
}
|
|
175
174
|
finally {
|
|
176
175
|
await invalidateBranchContentCaches(branchPath);
|