@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelstone/server",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- async pendingChanges() {
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/${branch}...${draftBranch}`);
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/${branch}`);
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/${branch}`, { sha: newCommit.sha });
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 ${branch} (${shortSha})`,
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
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.29.0";
8
+ export const SERVER_VERSION = "0.30.0";