@stelstone/server 0.29.0 → 0.30.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/package.json +1 -1
- package/src/adapters/github-content.mjs +16 -8
- package/src/core/config-schema.mjs +34 -0
- package/src/default-public-config.mjs +5 -0
- package/src/routes.mjs +33 -5
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -408,13 +408,15 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
408
408
|
? { deferredPublish: true, perEntryPublish: true }
|
|
409
409
|
: { deferredPublish: false, perEntryPublish: false },
|
|
410
410
|
|
|
411
|
-
|
|
411
|
+
/** @param {string} [target] branch to compare against; defaults to the deploy branch */
|
|
412
|
+
async pendingChanges(target) {
|
|
412
413
|
if (!draftMode) return { hasChanges: false, changedFiles: 0, files: [] };
|
|
413
414
|
await ensureDraftBranch();
|
|
415
|
+
const against = target || branch;
|
|
414
416
|
// One compare call: which files differ between published and draft?
|
|
415
417
|
// (GitHub caps the file list at 300 — orders of magnitude above any
|
|
416
418
|
// real collection here; still, say so rather than rely on it silently.)
|
|
417
|
-
const cmp = await apiGet(`/compare/${
|
|
419
|
+
const cmp = await apiGet(`/compare/${against}...${draftBranch}`);
|
|
418
420
|
const files = (cmp?.files ?? [])
|
|
419
421
|
.filter((f) => f.filename.startsWith(`${pagesDir}/`))
|
|
420
422
|
// Index manifests live on the draft branch only — the site build
|
|
@@ -437,16 +439,22 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
437
439
|
* references them by SHA — nothing is re-uploaded. A `removed` status
|
|
438
440
|
* becomes a tree entry with `sha: null`, which is how git-data deletes.
|
|
439
441
|
*
|
|
442
|
+
* `target` names the branch to land on, so one panel can push the same
|
|
443
|
+
* drafts to a preview site first and to the live site after. The caller
|
|
444
|
+
* resolves it from the configured list — an arbitrary branch name must
|
|
445
|
+
* never reach here from a request.
|
|
446
|
+
*
|
|
440
447
|
* @param {string} [message]
|
|
441
|
-
* @param {{ entries?: {collection: string, file: string}[] }} [opts]
|
|
448
|
+
* @param {{ entries?: {collection: string, file: string}[], target?: string }} [opts]
|
|
442
449
|
*/
|
|
443
|
-
async publish(message, { entries } = {}) {
|
|
450
|
+
async publish(message, { entries, target } = {}) {
|
|
451
|
+
const toBranch = target || branch;
|
|
444
452
|
if (!draftMode) {
|
|
445
453
|
// Writes are committed instantly; trigger is external (Netlify webhook on push)
|
|
446
454
|
return { ok: true, message: "All changes are already committed to GitHub" };
|
|
447
455
|
}
|
|
448
456
|
await ensureDraftBranch();
|
|
449
|
-
const pending = await this.pendingChanges();
|
|
457
|
+
const pending = await this.pendingChanges(toBranch);
|
|
450
458
|
if (!pending.hasChanges) return { ok: false, message: "No changes to publish" };
|
|
451
459
|
|
|
452
460
|
const scoped = Array.isArray(entries) && entries.length > 0;
|
|
@@ -472,7 +480,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
472
480
|
let lastErr;
|
|
473
481
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
474
482
|
try {
|
|
475
|
-
const refData = await apiGet(`/git/ref/heads/${
|
|
483
|
+
const refData = await apiGet(`/git/ref/heads/${toBranch}`);
|
|
476
484
|
const baseCommitSha = refData.object.sha;
|
|
477
485
|
const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
|
|
478
486
|
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
@@ -483,11 +491,11 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
|
|
|
483
491
|
tree: newTree.sha,
|
|
484
492
|
parents: [baseCommitSha],
|
|
485
493
|
});
|
|
486
|
-
await apiPatch(`/git/refs/heads/${
|
|
494
|
+
await apiPatch(`/git/refs/heads/${toBranch}`, { sha: newCommit.sha });
|
|
487
495
|
const shortSha = newCommit.sha.slice(0, 7);
|
|
488
496
|
return {
|
|
489
497
|
ok: true,
|
|
490
|
-
message: `Published ${targets.length} file(s) to ${
|
|
498
|
+
message: `Published ${targets.length} file(s) to ${toBranch} (${shortSha})`,
|
|
491
499
|
sha: newCommit.sha,
|
|
492
500
|
shortSha,
|
|
493
501
|
branch,
|
|
@@ -86,6 +86,39 @@ function checkContent(config, report, { runtime }) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `content.publishTargets` gives one panel more than one place to land the
|
|
91
|
+
* drafts — a preview site first, the live site after. Each id is what a
|
|
92
|
+
* request may ask for; the branch is never taken from the request.
|
|
93
|
+
*/
|
|
94
|
+
function checkPublishTargets(config, report) {
|
|
95
|
+
const targets = config.content?.publishTargets;
|
|
96
|
+
if (targets === undefined) return;
|
|
97
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
98
|
+
report.error("content.publishTargets", "must be a non-empty array");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!config.content?.draftBranch) {
|
|
102
|
+
report.error(
|
|
103
|
+
"content.publishTargets",
|
|
104
|
+
"needs content.draftBranch — without a draft branch saving already publishes, so there is nothing to send anywhere",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
targets.forEach((t, i) => {
|
|
109
|
+
const at = `content.publishTargets[${i}]`;
|
|
110
|
+
if (!t || typeof t !== "object") return report.error(at, "must be an object");
|
|
111
|
+
if (!t.id) report.error(`${at}.id`, "is required — this is what the panel sends");
|
|
112
|
+
if (!t.branch) report.error(`${at}.branch`, "is required — the branch this target lands on");
|
|
113
|
+
if (!t.label) report.warn(`${at}.label`, "is missing — the button will show the id");
|
|
114
|
+
if (t.id && seen.has(t.id)) report.error(`${at}.id`, `duplicate target id "${t.id}"`);
|
|
115
|
+
if (t.id) seen.add(t.id);
|
|
116
|
+
if (t.branch && t.branch === config.content?.draftBranch) {
|
|
117
|
+
report.error(`${at}.branch`, "is the draft branch — publishing onto it would be a no-op");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
function checkAuth(config, report, { getSecret }) {
|
|
90
123
|
const auth = config.auth;
|
|
91
124
|
if (!isPlainObject(auth)) {
|
|
@@ -435,6 +468,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
|
435
468
|
checkCollections(config, report);
|
|
436
469
|
checkForms(config, report, { getSecret: lookup });
|
|
437
470
|
checkBuild(config, report, { getSecret: lookup });
|
|
471
|
+
checkPublishTargets(config, report);
|
|
438
472
|
checkMisc(config, report);
|
|
439
473
|
|
|
440
474
|
return { errors: report.errors, warnings: report.warnings };
|
|
@@ -22,6 +22,11 @@ export function defaultPublicConfig(config, overrides = {}) {
|
|
|
22
22
|
blocks: config.blocks,
|
|
23
23
|
content: {
|
|
24
24
|
provider: config.content?.provider || "fs",
|
|
25
|
+
// Ids and labels only — the branch each maps to stays server-side.
|
|
26
|
+
publishTargets: (config.content?.publishTargets ?? []).map((t) => ({
|
|
27
|
+
id: t.id,
|
|
28
|
+
label: t.label || t.id,
|
|
29
|
+
})),
|
|
25
30
|
// Whether saving and publishing are separate steps. False only on the
|
|
26
31
|
// github backend without a draft branch — the admin hides Publish there,
|
|
27
32
|
// because saving already published.
|
package/src/routes.mjs
CHANGED
|
@@ -580,9 +580,11 @@ export const apiRoutes = [
|
|
|
580
580
|
method: "POST",
|
|
581
581
|
path: "/api/publish",
|
|
582
582
|
auth: "admin",
|
|
583
|
-
handler: async ({ adapters }) => {
|
|
583
|
+
handler: async ({ adapters, config, body }) => {
|
|
584
|
+
const target = resolveTarget(config, body?.target);
|
|
585
|
+
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
584
586
|
try {
|
|
585
|
-
return ok(await adapters.content.publish());
|
|
587
|
+
return ok(await adapters.content.publish(null, { target: target.branch }));
|
|
586
588
|
} catch (err) {
|
|
587
589
|
return { status: 500, json: { ok: false, message: err.message } };
|
|
588
590
|
}
|
|
@@ -596,7 +598,9 @@ export const apiRoutes = [
|
|
|
596
598
|
method: "POST",
|
|
597
599
|
path: "/api/collections/:collection/:file/publish",
|
|
598
600
|
auth: "admin",
|
|
599
|
-
handler: async ({ adapters, params }) => {
|
|
601
|
+
handler: async ({ adapters, params, config, body }) => {
|
|
602
|
+
const target = resolveTarget(config, body?.target);
|
|
603
|
+
if (target.error) return { status: 400, json: { ok: false, message: target.error } };
|
|
600
604
|
if (!adapters.content.capabilities?.perEntryPublish) {
|
|
601
605
|
return {
|
|
602
606
|
status: 501,
|
|
@@ -607,6 +611,7 @@ export const apiRoutes = [
|
|
|
607
611
|
return ok(
|
|
608
612
|
await adapters.content.publish(null, {
|
|
609
613
|
entries: [{ collection: params.collection, file: params.file }],
|
|
614
|
+
target: target.branch,
|
|
610
615
|
}),
|
|
611
616
|
);
|
|
612
617
|
} catch (err) {
|
|
@@ -619,10 +624,12 @@ export const apiRoutes = [
|
|
|
619
624
|
method: "GET",
|
|
620
625
|
path: "/api/publish/status",
|
|
621
626
|
auth: "any",
|
|
622
|
-
handler: async ({ adapters }) => {
|
|
627
|
+
handler: async ({ adapters, config, query }) => {
|
|
628
|
+
const target = resolveTarget(config, query?.target);
|
|
629
|
+
if (target.error) return { status: 400, json: { error: target.error } };
|
|
623
630
|
try {
|
|
624
631
|
return ok({
|
|
625
|
-
...(await adapters.content.pendingChanges()),
|
|
632
|
+
...(await adapters.content.pendingChanges(target.branch)),
|
|
626
633
|
perEntryPublish: !!adapters.content.capabilities?.perEntryPublish,
|
|
627
634
|
});
|
|
628
635
|
} catch (err) {
|
|
@@ -652,6 +659,27 @@ export const apiRoutes = [
|
|
|
652
659
|
},
|
|
653
660
|
];
|
|
654
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Turns a target id from a request into a branch name.
|
|
664
|
+
*
|
|
665
|
+
* The id is matched against `content.publishTargets`; a branch name is never
|
|
666
|
+
* taken from the request itself, or an admin could push the drafts onto any
|
|
667
|
+
* ref in the repo. No id, or no targets configured, means the deploy branch —
|
|
668
|
+
* which is what every existing config does.
|
|
669
|
+
*/
|
|
670
|
+
function resolveTarget(config, id) {
|
|
671
|
+
const targets = config?.content?.publishTargets;
|
|
672
|
+
if (!id) return { branch: undefined };
|
|
673
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
674
|
+
return { error: `Unknown publish target "${id}" — content.publishTargets is not configured` };
|
|
675
|
+
}
|
|
676
|
+
const hit = targets.find((t) => t.id === id);
|
|
677
|
+
if (!hit) {
|
|
678
|
+
return { error: `Unknown publish target "${id}" — expected one of: ${targets.map((t) => t.id).join(", ")}` };
|
|
679
|
+
}
|
|
680
|
+
return { branch: hit.branch };
|
|
681
|
+
}
|
|
682
|
+
|
|
655
683
|
/**
|
|
656
684
|
* Media endpoints need the auth adapter to mint a CDN token. Ask the port
|
|
657
685
|
* whether it can, instead of calling and handling the exception as a 500.
|
package/src/version.mjs
CHANGED