pandorga 1.4.3 → 1.4.5

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.
data/studio/index.html CHANGED
@@ -18,8 +18,8 @@
18
18
  rel="stylesheet"
19
19
  href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css"
20
20
  />
21
- <script type="module" crossorigin src="/studio/assets/index-fGeDVQzi.js"></script>
22
- <link rel="stylesheet" crossorigin href="/studio/assets/index-B36OvZMr.css">
21
+ <script type="module" crossorigin src="/studio/assets/index-OAmrbW7i.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/studio/assets/index-Cd1mlY_H.css">
23
23
  </head>
24
24
  <body>
25
25
  <div id="app"></div>
@@ -73,4 +73,12 @@ export const studioApi = {
73
73
  method: "POST",
74
74
  body: JSON.stringify(payload),
75
75
  }),
76
+ /** Content-pipeline run for a commit SHA, or latest on the branch when omitted. */
77
+ pipelineStatus: (sha = null, workflow = null) => {
78
+ const params = new URLSearchParams();
79
+ if (sha) params.set("sha", String(sha));
80
+ if (workflow) params.set("workflow", String(workflow));
81
+ const q = params.toString();
82
+ return api(q ? `pipeline-status?${q}` : "pipeline-status");
83
+ },
76
84
  };
@@ -202,6 +202,19 @@ const state = {
202
202
  navMenuOpen: false,
203
203
  /** Theme menu (light / dark / system) in the icon toolbar. */
204
204
  themeMenuOpen: false,
205
+ /**
206
+ * Content publish indicator (local Export animation or production Actions).
207
+ * state: idle | syncing | awaiting_run | running | ok | error | stalled | unconfigured
208
+ */
209
+ pipeline: {
210
+ state: "idle",
211
+ sha: "",
212
+ runUrl: "",
213
+ message: "",
214
+ pollGen: 0,
215
+ },
216
+ /** Local Export button in flight. */
217
+ reexporting: false,
205
218
  /** Per-collection list query (session memory). */
206
219
  listQueryByCollection: Object.create(null),
207
220
  /** Per-collection sort id (session + localStorage). */
@@ -432,6 +445,7 @@ function mediaOpts() {
432
445
  captureError,
433
446
  clearError,
434
447
  goHome,
448
+ onCommitted: (commitSha) => armPipelineWatch(commitSha),
435
449
  };
436
450
  }
437
451
 
@@ -825,6 +839,212 @@ function isLocalDevHost() {
825
839
  return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
826
840
  }
827
841
 
842
+ function sleep(ms) {
843
+ return new Promise((resolve) => setTimeout(resolve, ms));
844
+ }
845
+
846
+ function pipelineChipMeta(pipelineState) {
847
+ switch (pipelineState) {
848
+ case "syncing":
849
+ case "running":
850
+ return {
851
+ label: "Exporting…",
852
+ icon: "progress_activity",
853
+ className: "pipeline-chip pipeline-running",
854
+ spinning: true,
855
+ title: "Content export is running",
856
+ };
857
+ case "awaiting_run":
858
+ return {
859
+ label: "Waiting…",
860
+ icon: "schedule",
861
+ className: "pipeline-chip pipeline-awaiting",
862
+ spinning: false,
863
+ title: "Waiting for the content pipeline run",
864
+ };
865
+ case "ok":
866
+ return {
867
+ label: "Live",
868
+ icon: "check_circle",
869
+ className: "pipeline-chip pipeline-ok",
870
+ spinning: false,
871
+ title: "Content pipeline succeeded for this commit",
872
+ };
873
+ case "error":
874
+ return {
875
+ label: "Failed",
876
+ icon: "error",
877
+ className: "pipeline-chip pipeline-error",
878
+ spinning: false,
879
+ title: state.pipeline.message || "Content pipeline failed",
880
+ };
881
+ case "stalled":
882
+ return {
883
+ label: "No run",
884
+ icon: "hourglass_disabled",
885
+ className: "pipeline-chip pipeline-stalled",
886
+ spinning: false,
887
+ title: "No pipeline run appeared for this commit yet",
888
+ };
889
+ case "unconfigured":
890
+ return {
891
+ label: "Pipeline",
892
+ icon: "cloud_off",
893
+ className: "pipeline-chip pipeline-idle",
894
+ spinning: false,
895
+ title:
896
+ state.pipeline.message ||
897
+ "Set pandorga.content.workflow in _config.yml (e.g. content-pipeline.yml)",
898
+ };
899
+ default:
900
+ return {
901
+ label: "Pipeline",
902
+ icon: "cloud",
903
+ className: "pipeline-chip pipeline-idle",
904
+ spinning: false,
905
+ title: "Content pipeline status",
906
+ };
907
+ }
908
+ }
909
+
910
+ function renderPipelineChip() {
911
+ const meta = pipelineChipMeta(state.pipeline.state);
912
+ const runUrl = state.pipeline.runUrl;
913
+ const kids = [
914
+ el("span", {
915
+ className:
916
+ "material-symbols-outlined" + (meta.spinning ? " studio-icon-spin" : ""),
917
+ text: meta.icon,
918
+ "aria-hidden": "true",
919
+ }),
920
+ el("span", { className: "btn-label", text: meta.label }),
921
+ ];
922
+ return el(
923
+ "button",
924
+ {
925
+ className: `btn btn-tool ${meta.className}`,
926
+ type: "button",
927
+ title: meta.title,
928
+ "aria-label": meta.label,
929
+ role: "status",
930
+ "data-studio-action": "pipeline-status",
931
+ disabled: !runUrl,
932
+ onClick: () => {
933
+ if (!runUrl) return;
934
+ try {
935
+ window.open(runUrl, "_blank", "noopener,noreferrer");
936
+ } catch {
937
+ /* ignore */
938
+ }
939
+ },
940
+ },
941
+ kids
942
+ );
943
+ }
944
+
945
+ function applyPipelinePayload(data) {
946
+ state.pipeline.runUrl = data.run?.html_url || state.pipeline.runUrl || "";
947
+ if (data.sha) state.pipeline.sha = String(data.sha).toLowerCase();
948
+ state.pipeline.message = "";
949
+ if (data.state === "unconfigured") {
950
+ state.pipeline.state = "unconfigured";
951
+ state.pipeline.message =
952
+ "Set pandorga.content.workflow in _config.yml (e.g. content-pipeline.yml)";
953
+ return;
954
+ }
955
+ state.pipeline.state = data.state || "idle";
956
+ if (data.state === "error") {
957
+ state.pipeline.message = "Content pipeline failed for this commit.";
958
+ }
959
+ }
960
+
961
+ /** Hydrate the always-visible chip from the latest workflow run (or a SHA). */
962
+ async function refreshPipelineStatus(sha = null) {
963
+ if (isLocalDevHost()) return;
964
+ try {
965
+ const data = await studioApi.pipelineStatus(sha || null);
966
+ applyPipelinePayload(data);
967
+ renderShell();
968
+ } catch (err) {
969
+ const code = err?.code || "";
970
+ if (code === "actions_forbidden" || code === "github_unconfigured") {
971
+ state.pipeline.state = "error";
972
+ state.pipeline.message = String(err.message || err);
973
+ renderShell();
974
+ }
975
+ }
976
+ }
977
+
978
+ /**
979
+ * After a production Save, poll Actions for the content-pipeline run on this SHA.
980
+ */
981
+ function armPipelineWatch(commitSha) {
982
+ const sha = String(commitSha || "").trim().toLowerCase();
983
+ if (!sha || isLocalDevHost()) return;
984
+ const gen = ++state.pipeline.pollGen;
985
+ state.pipeline.state = "awaiting_run";
986
+ state.pipeline.sha = sha;
987
+ state.pipeline.runUrl = "";
988
+ state.pipeline.message = "";
989
+ renderShell();
990
+ void pollPipelineStatus(gen, sha);
991
+ }
992
+
993
+ async function pollPipelineStatus(gen, sha) {
994
+ const started = Date.now();
995
+ const STALL_MS = 10 * 60 * 1000;
996
+ const MAX_MS = 30 * 60 * 1000;
997
+ let softFails = 0;
998
+
999
+ while (gen === state.pipeline.pollGen) {
1000
+ const elapsed = Date.now() - started;
1001
+ if (elapsed > MAX_MS) {
1002
+ if (state.pipeline.state === "running" || state.pipeline.state === "awaiting_run") {
1003
+ state.pipeline.state = "stalled";
1004
+ state.pipeline.message = "Timed out waiting for the content pipeline.";
1005
+ renderShell();
1006
+ }
1007
+ return;
1008
+ }
1009
+
1010
+ try {
1011
+ const data = await studioApi.pipelineStatus(sha);
1012
+ if (gen !== state.pipeline.pollGen) return;
1013
+ softFails = 0;
1014
+ applyPipelinePayload(data);
1015
+
1016
+ if (data.state === "unconfigured" || data.state === "ok" || data.state === "error") {
1017
+ renderShell();
1018
+ return;
1019
+ }
1020
+ if (data.state === "awaiting_run" && elapsed > STALL_MS) {
1021
+ state.pipeline.state = "stalled";
1022
+ renderShell();
1023
+ return;
1024
+ }
1025
+ renderShell();
1026
+ } catch (err) {
1027
+ if (gen !== state.pipeline.pollGen) return;
1028
+ softFails += 1;
1029
+ const code = err?.code || "";
1030
+ if (code === "actions_forbidden" || code === "github_unconfigured") {
1031
+ state.pipeline.state = "error";
1032
+ state.pipeline.message = String(err.message || err);
1033
+ renderShell();
1034
+ return;
1035
+ }
1036
+ if (softFails >= 5) {
1037
+ state.pipeline.state = "error";
1038
+ state.pipeline.message = String(err.message || err);
1039
+ renderShell();
1040
+ return;
1041
+ }
1042
+ }
1043
+
1044
+ await sleep(elapsed < 60_000 ? 2500 : 7000);
1045
+ }
1046
+ }
1047
+
828
1048
  function collectionAllowsCreate(col = state.collection) {
829
1049
  if (!col || col.type !== "collection") return false;
830
1050
  return col.operations?.create !== false;
@@ -914,8 +1134,35 @@ function renderStudioToolbar({ brand = null, menuToggle = null } = {}) {
914
1134
  : null,
915
1135
  ]);
916
1136
 
917
- const actions = [
918
- renderThemeMenu(),
1137
+ const actions = [renderThemeMenu()];
1138
+
1139
+ // Theme → pipeline/Export → Revert → Save. Pipeline chip is always present.
1140
+ if (isLocalDevHost()) {
1141
+ const exportBusy = state.reexporting || state.pipeline.state === "syncing";
1142
+ actions.push(
1143
+ toolbarActionButton({
1144
+ className:
1145
+ "btn btn-tool btn-reexport" +
1146
+ (exportBusy ? " pipeline-running" : "") +
1147
+ (state.pipeline.state === "ok" && !exportBusy ? " pipeline-ok" : "") +
1148
+ (state.pipeline.state === "error" && !exportBusy ? " pipeline-error" : ""),
1149
+ title: exportBusy
1150
+ ? "Exporting content JSON locally"
1151
+ : "Export content JSON locally and reload",
1152
+ ariaLabel: exportBusy ? "Exporting locally" : "Export locally",
1153
+ action: "reexport",
1154
+ disabled: exportBusy,
1155
+ onClick: () => reexportLocalContent(),
1156
+ icon: exportBusy ? "progress_activity" : "sync",
1157
+ label: exportBusy ? "Exporting…" : "Export",
1158
+ spinning: exportBusy,
1159
+ })
1160
+ );
1161
+ } else {
1162
+ actions.push(renderPipelineChip());
1163
+ }
1164
+
1165
+ actions.push(
919
1166
  toolbarActionButton({
920
1167
  className: "btn btn-tool",
921
1168
  title: "Revert all edits to the last loaded or saved version",
@@ -940,34 +1187,8 @@ function renderStudioToolbar({ brand = null, menuToggle = null } = {}) {
940
1187
  icon: saveIcon,
941
1188
  label: saveLabel,
942
1189
  spinning: state.saving,
943
- }),
944
- ];
945
- if (state.file && collectionAllowsDelete()) {
946
- actions.push(
947
- toolbarActionButton({
948
- className: "btn btn-tool btn-danger",
949
- title: "Delete this entry from the repository",
950
- ariaLabel: "Delete entry",
951
- action: "delete",
952
- onClick: () => deleteCurrentEntry(),
953
- icon: "delete",
954
- label: "Delete",
955
- })
956
- );
957
- }
958
- if (isLocalDevHost()) {
959
- actions.push(
960
- toolbarActionButton({
961
- className: "btn btn-tool btn-reexport",
962
- title: "Export content JSON locally and reload",
963
- ariaLabel: "Export locally",
964
- action: "reexport",
965
- onClick: () => reexportLocalContent(),
966
- icon: "sync",
967
- label: "Export",
968
- })
969
- );
970
- }
1190
+ })
1191
+ );
971
1192
 
972
1193
  const kids = [
973
1194
  brandCluster,
@@ -3782,6 +4003,10 @@ function discardChanges() {
3782
4003
  }
3783
4004
 
3784
4005
  async function reexportLocalContent() {
4006
+ if (state.reexporting) return;
4007
+ state.reexporting = true;
4008
+ state.pipeline.state = "syncing";
4009
+ state.pipeline.message = "";
3785
4010
  state.status = "Reexporting…";
3786
4011
  clearError();
3787
4012
  renderShell();
@@ -3799,6 +4024,7 @@ async function reexportLocalContent() {
3799
4024
  if (!res.ok || !data.ok) {
3800
4025
  throw new Error((data && data.error) || `Export failed: ${res.status}`);
3801
4026
  }
4027
+ state.pipeline.state = "ok";
3802
4028
  state.status = "Reexported — reloading…";
3803
4029
  renderShell();
3804
4030
  setTimeout(() => {
@@ -3811,8 +4037,12 @@ async function reexportLocalContent() {
3811
4037
  }
3812
4038
  }, 200);
3813
4039
  } catch (err) {
4040
+ state.pipeline.state = "error";
4041
+ state.pipeline.message = String(err.message || err);
3814
4042
  captureError(err);
3815
4043
  renderShell();
4044
+ } finally {
4045
+ state.reexporting = false;
3816
4046
  }
3817
4047
  }
3818
4048
 
@@ -3872,7 +4102,7 @@ async function createNewEntry() {
3872
4102
  }
3873
4103
  if (exists) throw new Error(`${path} already exists`);
3874
4104
 
3875
- await studioApi.putFile({
4105
+ const created = await studioApi.putFile({
3876
4106
  path,
3877
4107
  content,
3878
4108
  sha: null,
@@ -3880,6 +4110,7 @@ async function createNewEntry() {
3880
4110
  });
3881
4111
  invalidateTreeCache(col.name);
3882
4112
  state.status = `Created ${path}`;
4113
+ armPipelineWatch(created?.commit);
3883
4114
  await selectCollection(col);
3884
4115
  await openFile(path);
3885
4116
  } catch (err) {
@@ -3904,7 +4135,7 @@ async function deleteEntry(path, sha = null) {
3904
4135
  const data = await studioApi.getFile(path);
3905
4136
  fileSha = data.sha;
3906
4137
  }
3907
- await studioApi.deleteFile({
4138
+ const deleted = await studioApi.deleteFile({
3908
4139
  path,
3909
4140
  sha: fileSha,
3910
4141
  message: `studio: delete ${path}`,
@@ -3920,6 +4151,7 @@ async function deleteEntry(path, sha = null) {
3920
4151
  state.body = "";
3921
4152
  }
3922
4153
  state.status = `Deleted ${path}`;
4154
+ armPipelineWatch(deleted?.commit);
3923
4155
  if (col) await selectCollection(col);
3924
4156
  else renderShell();
3925
4157
  } catch (err) {
@@ -3999,6 +4231,7 @@ async function saveCurrent() {
3999
4231
  captureBaseline();
4000
4232
  // List thumbs/titles may have changed — drop session tree for this collection.
4001
4233
  if (col?.name) invalidateTreeCache(col.name);
4234
+ armPipelineWatch(result?.commit);
4002
4235
  } catch (err) {
4003
4236
  captureError(err);
4004
4237
  } finally {
@@ -4034,6 +4267,7 @@ async function bootApp(clerk) {
4034
4267
  }
4035
4268
 
4036
4269
  renderShell();
4270
+ void refreshPipelineStatus();
4037
4271
 
4038
4272
  scheduleTreePrefetch();
4039
4273
 
@@ -544,13 +544,16 @@ async function deleteMediaFile(item, opts) {
544
544
  const data = await api.getFile(path);
545
545
  sha = data.sha;
546
546
  }
547
- await api.deleteFile({
547
+ const deleted = await api.deleteFile({
548
548
  path,
549
549
  sha,
550
550
  message: `studio: delete ${path}`,
551
551
  });
552
552
  state.mediaBusy = false;
553
553
  state.status = `Deleted ${path}`;
554
+ if (typeof opts.onCommitted === "function") {
555
+ opts.onCommitted(deleted?.commit);
556
+ }
554
557
  await openMediaDir(state.mediaPath, opts);
555
558
  } catch (err) {
556
559
  state.mediaBusy = false;
@@ -600,12 +603,15 @@ async function createFolder(opts) {
600
603
  state.status = `Creating ${name}…`;
601
604
  render();
602
605
  try {
603
- await api.createMediaFolder({
606
+ const created = await api.createMediaFolder({
604
607
  path,
605
608
  message: `studio: mkdir ${path}`,
606
609
  });
607
610
  state.mediaBusy = false;
608
611
  state.status = `Created ${toPublicPath(path) || path}`;
612
+ if (typeof opts.onCommitted === "function") {
613
+ opts.onCommitted(created?.commit);
614
+ }
609
615
  await openMediaDir(state.mediaPath, opts);
610
616
  } catch (err) {
611
617
  state.mediaBusy = false;
@@ -619,6 +625,7 @@ async function uploadFiles(files, opts) {
619
625
  clearError();
620
626
  const dir = state.mediaPath || MEDIA_ROOT;
621
627
  let ok = 0;
628
+ let lastCommit = null;
622
629
  state.mediaBusy = true;
623
630
 
624
631
  for (const file of files) {
@@ -643,6 +650,7 @@ async function uploadFiles(files, opts) {
643
650
  });
644
651
  ok += 1;
645
652
  state.status = `Uploaded ${result.public_path || toPublicPath(path)}`;
653
+ lastCommit = result?.commit || lastCommit;
646
654
  } catch (err) {
647
655
  captureError(err);
648
656
  state.mediaBusy = false;
@@ -653,5 +661,8 @@ async function uploadFiles(files, opts) {
653
661
 
654
662
  state.mediaBusy = false;
655
663
  if (ok) state.status = ok === 1 ? state.status : `Uploaded ${ok} files`;
664
+ if (ok && lastCommit && typeof opts.onCommitted === "function") {
665
+ opts.onCommitted(lastCommit);
666
+ }
656
667
  await openMediaDir(dir, opts);
657
668
  }
@@ -719,6 +719,67 @@ button.studio-brand-home.studio-brand-toolbar[aria-current="page"] {
719
719
  background: color-mix(in srgb, #746f30 45%, var(--panel));
720
720
  }
721
721
 
722
+ /* Content-pipeline status chip (production) / Export sync colors (local). */
723
+ .pipeline-chip {
724
+ cursor: default;
725
+ }
726
+
727
+ .pipeline-chip:not(:disabled) {
728
+ cursor: pointer;
729
+ }
730
+
731
+ .pipeline-awaiting,
732
+ .btn-reexport.pipeline-running,
733
+ .pipeline-running {
734
+ color: #5c4b00;
735
+ border-color: color-mix(in srgb, #e0a100 55%, var(--outline-variant));
736
+ background: color-mix(in srgb, #f5c84c 50%, var(--background));
737
+ }
738
+
739
+ .pipeline-ok,
740
+ .btn-reexport.pipeline-ok {
741
+ color: #14532d;
742
+ border-color: color-mix(in srgb, #22c55e 50%, var(--outline-variant));
743
+ background: color-mix(in srgb, #86efac 45%, var(--background));
744
+ }
745
+
746
+ .pipeline-error,
747
+ .btn-reexport.pipeline-error,
748
+ .pipeline-stalled {
749
+ color: #7f1d1d;
750
+ border-color: color-mix(in srgb, #ef4444 50%, var(--outline-variant));
751
+ background: color-mix(in srgb, #fca5a5 40%, var(--background));
752
+ }
753
+
754
+ .pipeline-idle {
755
+ color: var(--on-surface-variant);
756
+ border-color: var(--outline-variant);
757
+ background: var(--surface-container);
758
+ }
759
+
760
+ [data-theme="dark"] .pipeline-awaiting,
761
+ [data-theme="dark"] .pipeline-running,
762
+ [data-theme="dark"] .btn-reexport.pipeline-running {
763
+ color: #f5e6a3;
764
+ border-color: color-mix(in srgb, #e0a100 45%, var(--outline-variant));
765
+ background: color-mix(in srgb, #746f30 50%, var(--panel));
766
+ }
767
+
768
+ [data-theme="dark"] .pipeline-ok,
769
+ [data-theme="dark"] .btn-reexport.pipeline-ok {
770
+ color: #bbf7d0;
771
+ border-color: color-mix(in srgb, #22c55e 45%, var(--outline-variant));
772
+ background: color-mix(in srgb, #14532d 55%, var(--panel));
773
+ }
774
+
775
+ [data-theme="dark"] .pipeline-error,
776
+ [data-theme="dark"] .btn-reexport.pipeline-error,
777
+ [data-theme="dark"] .pipeline-stalled {
778
+ color: #fecaca;
779
+ border-color: color-mix(in srgb, #ef4444 45%, var(--outline-variant));
780
+ background: color-mix(in srgb, #7f1d1d 55%, var(--panel));
781
+ }
782
+
722
783
  .studio-icon-bar .btn-icon {
723
784
  width: var(--studio-list-control-height);
724
785
  min-width: var(--studio-list-control-height);
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.3
4
+ version: 1.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -449,8 +449,8 @@ files:
449
449
  - studio-app/src/preview/render.js
450
450
  - studio-app/src/styles/studio.css
451
451
  - studio-app/vite.config.js
452
- - studio/assets/index-B36OvZMr.css
453
- - studio/assets/index-fGeDVQzi.js
452
+ - studio/assets/index-Cd1mlY_H.css
453
+ - studio/assets/index-OAmrbW7i.js
454
454
  - studio/index.html
455
455
  - studio/schema.yml
456
456
  - templates/articles/README.md