pandorga 1.4.6 → 1.4.8
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/_layouts/home.html +53 -2
- data/assets/css/ledger/home.css +93 -56
- data/lib/pandorga/version.rb +1 -1
- data/scripts/test/render-parity/blocks/project-specimen.json +5 -3
- data/scripts/test/render-parity/blocks/system-card.json +8 -3
- data/scripts/test/test-studio-api-contract.rb +70 -0
- data/scripts/test/test-studio-pipeline-focus.mjs +308 -0
- data/scripts/validate.sh +5 -0
- data/studio/assets/{index-CMVzh8F1.js → index-BJyN-kB3.js} +98 -98
- data/studio/index.html +1 -1
- data/studio-app/src/main.js +126 -32
- metadata +3 -2
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pipeline status updates must patch the toolbar chip. Rebuilding the shell
|
|
4
|
+
* drops editor focus for the whole run.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import vm from "node:vm";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
13
|
+
const source = fs.readFileSync(path.join(root, "studio-app/src/main.js"), "utf8");
|
|
14
|
+
|
|
15
|
+
function skipString(i) {
|
|
16
|
+
const q = source[i];
|
|
17
|
+
i += 1;
|
|
18
|
+
while (i < source.length && source[i] !== q) {
|
|
19
|
+
if (source[i] === "\\") i += 2;
|
|
20
|
+
else i += 1;
|
|
21
|
+
}
|
|
22
|
+
return i + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function functionSource(name) {
|
|
26
|
+
const re = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`);
|
|
27
|
+
const m = re.exec(source);
|
|
28
|
+
if (!m) throw new Error(`missing function ${name}`);
|
|
29
|
+
let i = m.index + m[0].length;
|
|
30
|
+
let paren = 1;
|
|
31
|
+
while (i < source.length && paren > 0) {
|
|
32
|
+
const c = source[i];
|
|
33
|
+
if (c === "'" || c === '"' || c === "`") i = skipString(i);
|
|
34
|
+
else if (c === "/" && source[i + 1] === "/") {
|
|
35
|
+
const nl = source.indexOf("\n", i);
|
|
36
|
+
i = nl === -1 ? source.length : nl + 1;
|
|
37
|
+
} else {
|
|
38
|
+
if (c === "(") paren += 1;
|
|
39
|
+
else if (c === ")") paren -= 1;
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
while (i < source.length && source[i] !== "{") i += 1;
|
|
44
|
+
if (i >= source.length) throw new Error(`missing body ${name}`);
|
|
45
|
+
let depth = 0;
|
|
46
|
+
while (i < source.length) {
|
|
47
|
+
const c = source[i];
|
|
48
|
+
if (c === "{") {
|
|
49
|
+
depth += 1;
|
|
50
|
+
i += 1;
|
|
51
|
+
} else if (c === "}") {
|
|
52
|
+
depth -= 1;
|
|
53
|
+
if (depth === 0) return source.slice(m.index, i + 1);
|
|
54
|
+
i += 1;
|
|
55
|
+
} else if (c === "'" || c === '"' || c === "`") {
|
|
56
|
+
i = skipString(i);
|
|
57
|
+
} else if (c === "/" && source[i + 1] === "/") {
|
|
58
|
+
const nl = source.indexOf("\n", i);
|
|
59
|
+
i = nl === -1 ? source.length : nl + 1;
|
|
60
|
+
} else {
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unclosed function ${name}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class MiniNode {
|
|
68
|
+
constructor(tag) {
|
|
69
|
+
this.nodeType = 1;
|
|
70
|
+
this.tagName = String(tag || "").toUpperCase();
|
|
71
|
+
this.childNodes = [];
|
|
72
|
+
this.parentNode = null;
|
|
73
|
+
this.attributes = new Map();
|
|
74
|
+
this.className = "";
|
|
75
|
+
this.disabled = false;
|
|
76
|
+
this.title = "";
|
|
77
|
+
this.value = "";
|
|
78
|
+
this.selectionStart = 0;
|
|
79
|
+
this.selectionEnd = 0;
|
|
80
|
+
this._text = "";
|
|
81
|
+
this.listeners = new Map();
|
|
82
|
+
const node = this;
|
|
83
|
+
this.classList = {
|
|
84
|
+
toggle(name, force) {
|
|
85
|
+
const parts = node.className.split(/\s+/).filter(Boolean);
|
|
86
|
+
const has = parts.includes(name);
|
|
87
|
+
const on = force === undefined ? !has : !!force;
|
|
88
|
+
const next = parts.filter((part) => part !== name);
|
|
89
|
+
if (on) next.push(name);
|
|
90
|
+
node.className = next.join(" ");
|
|
91
|
+
return on;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get textContent() {
|
|
97
|
+
if (this.childNodes.length === 0) return this._text;
|
|
98
|
+
return this.childNodes.map((child) => child.textContent).join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
set textContent(value) {
|
|
102
|
+
this._text = value == null ? "" : String(value);
|
|
103
|
+
this.childNodes = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setAttribute(name, value) {
|
|
107
|
+
this.attributes.set(name, String(value));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getAttribute(name) {
|
|
111
|
+
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
addEventListener(type, fn) {
|
|
115
|
+
this.listeners.set(type, fn);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
append(...kids) {
|
|
119
|
+
for (const kid of kids) {
|
|
120
|
+
if (kid == null || kid === false) continue;
|
|
121
|
+
kid.parentNode = this;
|
|
122
|
+
this.childNodes.push(kid);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
replaceWith(next) {
|
|
127
|
+
const parent = this.parentNode;
|
|
128
|
+
if (!parent) return;
|
|
129
|
+
const idx = parent.childNodes.indexOf(this);
|
|
130
|
+
if (idx < 0) return;
|
|
131
|
+
next.parentNode = parent;
|
|
132
|
+
parent.childNodes[idx] = next;
|
|
133
|
+
this.parentNode = null;
|
|
134
|
+
if (document.activeElement === this) document.activeElement = document.body;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
querySelector(sel) {
|
|
138
|
+
return querySelector(this, sel);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
focus() {
|
|
142
|
+
document.activeElement = this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
setSelectionRange(start, end) {
|
|
146
|
+
this.selectionStart = start;
|
|
147
|
+
this.selectionEnd = end;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function matches(node, sel) {
|
|
152
|
+
const attr = sel.match(/^\[([^=]+)='([^']*)'\]$/);
|
|
153
|
+
if (attr) return node.getAttribute(attr[1]) === attr[2];
|
|
154
|
+
if (sel.startsWith(".")) {
|
|
155
|
+
const name = sel.slice(1);
|
|
156
|
+
return node.className.split(/\s+/).includes(name);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function querySelector(root, sel) {
|
|
162
|
+
for (const child of root.childNodes || []) {
|
|
163
|
+
if (child.nodeType === 1 && matches(child, sel)) return child;
|
|
164
|
+
const found = querySelector(child, sel);
|
|
165
|
+
if (found) return found;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const document = {
|
|
171
|
+
activeElement: null,
|
|
172
|
+
body: null,
|
|
173
|
+
createElement(tag) {
|
|
174
|
+
return new MiniNode(tag);
|
|
175
|
+
},
|
|
176
|
+
querySelector(sel) {
|
|
177
|
+
return querySelector(document.body, sel);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
document.body = new MiniNode("body");
|
|
181
|
+
document.body.parentNode = document;
|
|
182
|
+
|
|
183
|
+
const editor = new MiniNode("textarea");
|
|
184
|
+
editor.value = "keep typing";
|
|
185
|
+
editor.className = "body-editor";
|
|
186
|
+
const chipHost = new MiniNode("div");
|
|
187
|
+
document.body.append(chipHost, editor);
|
|
188
|
+
|
|
189
|
+
const state = {
|
|
190
|
+
pipeline: {
|
|
191
|
+
state: "idle",
|
|
192
|
+
sha: "",
|
|
193
|
+
runUrl: "",
|
|
194
|
+
message: "",
|
|
195
|
+
pollGen: 0,
|
|
196
|
+
},
|
|
197
|
+
reexporting: false,
|
|
198
|
+
error: "",
|
|
199
|
+
errorDetail: "",
|
|
200
|
+
status: "",
|
|
201
|
+
file: "content/collections/articles/hello.md",
|
|
202
|
+
saving: false,
|
|
203
|
+
dirty: false,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let statusCalls = 0;
|
|
207
|
+
const studioApi = {
|
|
208
|
+
async pipelineStatus() {
|
|
209
|
+
statusCalls += 1;
|
|
210
|
+
if (statusCalls < 3) {
|
|
211
|
+
return {
|
|
212
|
+
state: "running",
|
|
213
|
+
sha: "abc",
|
|
214
|
+
run: { html_url: "https://example.com/actions/runs/9" },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
state: "ok",
|
|
219
|
+
sha: "abc",
|
|
220
|
+
run: { html_url: "https://example.com/actions/runs/9" },
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const context = {
|
|
226
|
+
state,
|
|
227
|
+
document,
|
|
228
|
+
studioApi,
|
|
229
|
+
sleep: async () => {},
|
|
230
|
+
console,
|
|
231
|
+
JSON,
|
|
232
|
+
String,
|
|
233
|
+
window: { open() {}, location: { hostname: "studio.example.com" } },
|
|
234
|
+
};
|
|
235
|
+
vm.createContext(context);
|
|
236
|
+
vm.runInContext(
|
|
237
|
+
[
|
|
238
|
+
functionSource("el"),
|
|
239
|
+
functionSource("pipelineChipMeta"),
|
|
240
|
+
functionSource("pipelineChipViewKey"),
|
|
241
|
+
functionSource("renderPipelineChip"),
|
|
242
|
+
functionSource("syncPipelineChip"),
|
|
243
|
+
functionSource("applyPipelinePayload"),
|
|
244
|
+
functionSource("localExportPresentation"),
|
|
245
|
+
functionSource("syncLocalExportButton"),
|
|
246
|
+
functionSource("pollPipelineStatus"),
|
|
247
|
+
].join("\n"),
|
|
248
|
+
context
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
chipHost.append(vm.runInContext("renderPipelineChip()", context));
|
|
252
|
+
editor.focus();
|
|
253
|
+
editor.setSelectionRange(5, 5);
|
|
254
|
+
const editorAtFocus = document.activeElement;
|
|
255
|
+
|
|
256
|
+
await vm.runInContext(
|
|
257
|
+
`(async () => {
|
|
258
|
+
state.pipeline.pollGen = 1;
|
|
259
|
+
state.pipeline.state = "awaiting_run";
|
|
260
|
+
syncPipelineChip();
|
|
261
|
+
await pollPipelineStatus(1, "abc");
|
|
262
|
+
})()`,
|
|
263
|
+
context
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
assert.equal(document.activeElement, editorAtFocus, "editor focus survives pipeline polls");
|
|
267
|
+
assert.equal(editor.parentNode, document.body, "editor stays mounted");
|
|
268
|
+
assert.equal(editor.value, "keep typing");
|
|
269
|
+
assert.equal(editor.selectionStart, 5);
|
|
270
|
+
assert.equal(state.pipeline.state, "ok");
|
|
271
|
+
|
|
272
|
+
const chip = document.querySelector("[data-studio-action='pipeline-status']");
|
|
273
|
+
assert.ok(chip, "pipeline chip remains");
|
|
274
|
+
assert.match(chip.className, /pipeline-ok/);
|
|
275
|
+
assert.equal(chip.querySelector(".btn-label").textContent, "Live");
|
|
276
|
+
assert.equal(chip.querySelector(".studio-icon-spin"), null);
|
|
277
|
+
assert.equal(chip.disabled, false);
|
|
278
|
+
|
|
279
|
+
editor.focus();
|
|
280
|
+
state.reexporting = true;
|
|
281
|
+
state.pipeline.state = "syncing";
|
|
282
|
+
vm.runInContext("syncLocalExportButton()", context);
|
|
283
|
+
const exportBtn = document.querySelector("[data-studio-action='reexport']");
|
|
284
|
+
assert.equal(exportBtn, null, "production shell has no local export button");
|
|
285
|
+
|
|
286
|
+
const exportHost = new MiniNode("button");
|
|
287
|
+
exportHost.setAttribute("data-studio-action", "reexport");
|
|
288
|
+
exportHost.className = "btn btn-tool btn-reexport";
|
|
289
|
+
const icon = new MiniNode("span");
|
|
290
|
+
icon.className = "material-symbols-outlined";
|
|
291
|
+
icon.textContent = "sync";
|
|
292
|
+
const label = new MiniNode("span");
|
|
293
|
+
label.className = "btn-label";
|
|
294
|
+
label.textContent = "Export";
|
|
295
|
+
exportHost.append(icon, label);
|
|
296
|
+
document.body.append(exportHost);
|
|
297
|
+
|
|
298
|
+
editor.focus();
|
|
299
|
+
vm.runInContext("syncLocalExportButton()", context);
|
|
300
|
+
assert.equal(document.activeElement, editor, "local export spin does not steal editor focus");
|
|
301
|
+
assert.equal(exportHost.disabled, true);
|
|
302
|
+
assert.match(exportHost.className, /pipeline-running/);
|
|
303
|
+
assert.equal(icon.textContent, "progress_activity");
|
|
304
|
+
assert.match(icon.className, /studio-icon-spin/);
|
|
305
|
+
assert.equal(label.textContent, "Exporting…");
|
|
306
|
+
assert.equal(editor.value, "keep typing");
|
|
307
|
+
|
|
308
|
+
console.log("All Studio pipeline focus checks passed.");
|
data/scripts/validate.sh
CHANGED
|
@@ -73,6 +73,11 @@ if ! node scripts/test/test-studio-pipeline-config.mjs; then
|
|
|
73
73
|
failed=1
|
|
74
74
|
fi
|
|
75
75
|
|
|
76
|
+
echo "-- scripts/test/test-studio-pipeline-focus.mjs"
|
|
77
|
+
if ! node scripts/test/test-studio-pipeline-focus.mjs; then
|
|
78
|
+
failed=1
|
|
79
|
+
fi
|
|
80
|
+
|
|
76
81
|
if [[ "$failed" -ne 0 ]]; then
|
|
77
82
|
echo "VALIDATE FAILED"
|
|
78
83
|
exit 1
|