starmark 1.0.1 → 1.0.3

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.
@@ -0,0 +1,464 @@
1
+ import { icons } from "./icons.js";
2
+
3
+ const IMAGE_UPLOAD_ACCEPT = ".png,.jpg,.jpeg,.gif,.webp,.svg,.avif,.ico,image/*";
4
+ const IMAGE_UPLOAD_EXTENSIONS = new Set([
5
+ ".png",
6
+ ".jpg",
7
+ ".jpeg",
8
+ ".gif",
9
+ ".webp",
10
+ ".svg",
11
+ ".avif",
12
+ ".ico",
13
+ ]);
14
+
15
+ export function createMediaDialog({
16
+ getProjectPath,
17
+ title = "Media",
18
+ selectMode = "path",
19
+ onSelect,
20
+ onInsert,
21
+ onOpen,
22
+ onClose,
23
+ dialogId = "media-dialog",
24
+ }) {
25
+ let pendingImage = null;
26
+
27
+ const dialog = document.createElement("dialog");
28
+ dialog.id = dialogId;
29
+ dialog.className = "media-dialog";
30
+ dialog.innerHTML = `
31
+ <div class="dialog-header">
32
+ <h2></h2>
33
+ <button type="button" class="dialog-close media-dialog-close" aria-label="Close">&times;</button>
34
+ </div>
35
+ <div class="media-browser">
36
+ <div class="media-toolbar">
37
+ <button type="button" class="media-up-btn" disabled aria-label="Go up one folder">
38
+ ${icons.arrowUp}
39
+ Up
40
+ </button>
41
+ <nav class="media-breadcrumb" aria-label="Media path"></nav>
42
+ </div>
43
+ <div class="media-search">
44
+ <input
45
+ type="search"
46
+ class="media-search-input"
47
+ placeholder="Search by file name"
48
+ autocomplete="off"
49
+ spellcheck="false"
50
+ aria-label="Search images by file name"
51
+ />
52
+ </div>
53
+ <p class="media-status" hidden></p>
54
+ <div class="media-grid"></div>
55
+ </div>
56
+ <form class="image-form" hidden aria-hidden="true">
57
+ <p class="image-selected-name"></p>
58
+ <div class="link-field">
59
+ <label for="${dialogId}-image-alt">Alt text</label>
60
+ <input id="${dialogId}-image-alt" type="text" autocomplete="off" spellcheck="true" />
61
+ </div>
62
+ <label class="image-checkbox-field" for="${dialogId}-image-lazy">
63
+ <input id="${dialogId}-image-lazy" type="checkbox" checked />
64
+ Lazy load
65
+ </label>
66
+ <div class="link-field">
67
+ <label for="${dialogId}-image-caption">Caption</label>
68
+ <input id="${dialogId}-image-caption" type="text" autocomplete="off" spellcheck="true" />
69
+ </div>
70
+ <div class="link-form-actions image-form-actions">
71
+ <button type="button" class="image-back-btn">Back</button>
72
+ <button type="submit" class="primary">Done</button>
73
+ </div>
74
+ </form>
75
+ `;
76
+
77
+ dialog.querySelector(".dialog-header h2").textContent = title;
78
+
79
+ const closeBtn = dialog.querySelector(".media-dialog-close");
80
+ const mediaUpBtn = dialog.querySelector(".media-up-btn");
81
+ const mediaBreadcrumb = dialog.querySelector(".media-breadcrumb");
82
+ const mediaSearchInput = dialog.querySelector(".media-search-input");
83
+ const mediaStatus = dialog.querySelector(".media-status");
84
+ const mediaGrid = dialog.querySelector(".media-grid");
85
+ const mediaBrowserView = dialog.querySelector(".media-browser");
86
+ const imageForm = dialog.querySelector(".image-form");
87
+ const imageSelectedName = dialog.querySelector(".image-selected-name");
88
+ const imageAltInput = dialog.querySelector(`#${dialogId}-image-alt`);
89
+ const imageLazyInput = dialog.querySelector(`#${dialogId}-image-lazy`);
90
+ const imageCaptionInput = dialog.querySelector(`#${dialogId}-image-caption`);
91
+ const imageBackBtn = dialog.querySelector(".image-back-btn");
92
+
93
+ let currentMediaDir = "img";
94
+ let isMediaUploading = false;
95
+
96
+ function formatMediaDirLabel(relativeDir) {
97
+ return relativeDir ? relativeDir.replace(/\//g, " / ") : "public";
98
+ }
99
+
100
+ function getMediaFileUrl(relativePath) {
101
+ return `/api/media/file?project=${encodeURIComponent(getProjectPath())}&path=${encodeURIComponent(relativePath)}`;
102
+ }
103
+
104
+ function getImageSearchLabel(image, searchRootDir) {
105
+ if (image.dir === searchRootDir) {
106
+ return image.name;
107
+ }
108
+
109
+ const fullPath = image.dir ? `${image.dir}/${image.name}` : image.name;
110
+ const prefix = searchRootDir ? `${searchRootDir}/` : "";
111
+
112
+ if (searchRootDir && fullPath.startsWith(prefix)) {
113
+ return fullPath.slice(prefix.length);
114
+ }
115
+
116
+ return fullPath;
117
+ }
118
+
119
+ function navigateToMediaDirectory(relativeDir) {
120
+ mediaSearchInput.value = "";
121
+ loadMediaDirectory(relativeDir);
122
+ }
123
+
124
+ function isImageUploadFile(file) {
125
+ const dotIndex = file.name.lastIndexOf(".");
126
+ if (dotIndex === -1) {
127
+ return false;
128
+ }
129
+
130
+ const extension = file.name.slice(dotIndex).toLowerCase();
131
+ return IMAGE_UPLOAD_EXTENSIONS.has(extension);
132
+ }
133
+
134
+ function createMediaAddItem() {
135
+ const label = document.createElement("label");
136
+ label.className = "media-item-btn media-add-btn";
137
+
138
+ const input = document.createElement("input");
139
+ input.type = "file";
140
+ input.className = "media-upload-input";
141
+ input.accept = IMAGE_UPLOAD_ACCEPT;
142
+ input.disabled = isMediaUploading;
143
+
144
+ const preview = document.createElement("span");
145
+ preview.className = "media-item-preview media-add-preview";
146
+ preview.innerHTML = icons.plus;
147
+ preview.setAttribute("aria-hidden", "true");
148
+
149
+ const name = document.createElement("span");
150
+ name.className = "media-item-name";
151
+ name.textContent = "Add";
152
+
153
+ label.append(input, preview, name);
154
+ label.title = "Upload image";
155
+
156
+ input.addEventListener("change", async () => {
157
+ const file = input.files?.[0];
158
+ input.value = "";
159
+
160
+ if (!file) {
161
+ return;
162
+ }
163
+
164
+ if (!isImageUploadFile(file)) {
165
+ setMediaStatus("Only image files are supported.", { isError: true });
166
+ return;
167
+ }
168
+
169
+ isMediaUploading = true;
170
+ input.disabled = true;
171
+ setMediaStatus("Uploading…");
172
+
173
+ try {
174
+ const params = new URLSearchParams({
175
+ project: getProjectPath(),
176
+ dir: currentMediaDir,
177
+ filename: file.name,
178
+ });
179
+ const response = await fetch(`/api/media/upload?${params.toString()}`, {
180
+ method: "POST",
181
+ headers: {
182
+ "Content-Type": file.type || "application/octet-stream",
183
+ },
184
+ body: file,
185
+ });
186
+ const data = await response.json();
187
+
188
+ if (!response.ok) {
189
+ setMediaStatus(data.error ?? "Could not upload image", { isError: true });
190
+ return;
191
+ }
192
+
193
+ await loadMediaDirectory(currentMediaDir);
194
+ } catch {
195
+ setMediaStatus("Could not upload image", { isError: true });
196
+ } finally {
197
+ isMediaUploading = false;
198
+ input.disabled = false;
199
+ }
200
+ });
201
+
202
+ return label;
203
+ }
204
+
205
+ function setMediaStatus(message, { isError = false } = {}) {
206
+ mediaStatus.hidden = !message;
207
+ mediaStatus.textContent = message ?? "";
208
+ mediaStatus.classList.toggle("is-error", Boolean(isError && message));
209
+ }
210
+
211
+ function renderMediaBreadcrumb(currentDir) {
212
+ mediaBreadcrumb.replaceChildren();
213
+
214
+ const segments = currentDir ? currentDir.split("/") : [];
215
+ const crumbs = [{ label: "public", dir: "" }];
216
+
217
+ for (let index = 0; index < segments.length; index += 1) {
218
+ crumbs.push({
219
+ label: segments[index],
220
+ dir: segments.slice(0, index + 1).join("/"),
221
+ });
222
+ }
223
+
224
+ crumbs.forEach((crumb, index) => {
225
+ if (index > 0) {
226
+ const separator = document.createElement("span");
227
+ separator.className = "media-breadcrumb-separator";
228
+ separator.textContent = "/";
229
+ separator.setAttribute("aria-hidden", "true");
230
+ mediaBreadcrumb.append(separator);
231
+ }
232
+
233
+ const button = document.createElement("button");
234
+ button.type = "button";
235
+ button.textContent = crumb.label;
236
+ button.addEventListener("click", () => {
237
+ navigateToMediaDirectory(crumb.dir);
238
+ });
239
+ mediaBreadcrumb.append(button);
240
+ });
241
+ }
242
+
243
+ function showMediaBrowserView() {
244
+ pendingImage = null;
245
+ mediaBrowserView.hidden = false;
246
+ mediaBrowserView.setAttribute("aria-hidden", "false");
247
+ imageForm.hidden = true;
248
+ imageForm.setAttribute("aria-hidden", "true");
249
+ }
250
+
251
+ function showMediaDetailsView(image) {
252
+ pendingImage = image;
253
+ imageSelectedName.textContent = image.name;
254
+ imageAltInput.value = filenameToAlt(image.name);
255
+ imageLazyInput.checked = true;
256
+ imageCaptionInput.value = "";
257
+ mediaBrowserView.hidden = true;
258
+ mediaBrowserView.setAttribute("aria-hidden", "true");
259
+ imageForm.hidden = false;
260
+ imageForm.setAttribute("aria-hidden", "false");
261
+ imageAltInput.focus();
262
+ }
263
+
264
+ function handleImageSelection(image) {
265
+ if (selectMode === "path") {
266
+ onSelect?.(image);
267
+ dialog.close();
268
+ return;
269
+ }
270
+
271
+ showMediaDetailsView(image);
272
+ }
273
+
274
+ function renderMediaDirectory(data) {
275
+ const isSearching = Boolean(data.searchQuery);
276
+
277
+ mediaUpBtn.disabled = data.parentDir === null;
278
+ mediaUpBtn.onclick = () => {
279
+ if (data.parentDir !== null) {
280
+ navigateToMediaDirectory(data.parentDir === "" ? "" : data.parentDir);
281
+ }
282
+ };
283
+
284
+ renderMediaBreadcrumb(data.currentDir);
285
+ mediaGrid.replaceChildren();
286
+
287
+ if (!isSearching) {
288
+ for (const folder of data.folders) {
289
+ const button = document.createElement("button");
290
+ button.type = "button";
291
+ button.className = "media-item-btn media-folder-btn";
292
+ button.title = folder.name;
293
+
294
+ const preview = document.createElement("span");
295
+ preview.className = "media-item-preview media-folder-preview";
296
+ preview.innerHTML = icons.folder;
297
+ preview.setAttribute("aria-hidden", "true");
298
+
299
+ const label = document.createElement("span");
300
+ label.className = "media-item-name";
301
+ label.textContent = folder.name;
302
+
303
+ button.append(preview, label);
304
+ button.addEventListener("click", () => {
305
+ navigateToMediaDirectory(folder.dir);
306
+ });
307
+ mediaGrid.append(button);
308
+ }
309
+
310
+ mediaGrid.append(createMediaAddItem());
311
+ }
312
+
313
+ for (const image of data.images) {
314
+ const relativePath = image.webPath.replace(/^\//, "");
315
+ const displayName = isSearching
316
+ ? getImageSearchLabel(image, data.currentDir)
317
+ : image.name;
318
+ const button = document.createElement("button");
319
+ button.type = "button";
320
+ button.className = "media-item-btn media-image-btn";
321
+ button.title = displayName;
322
+
323
+ const preview = document.createElement("img");
324
+ preview.className = "media-item-preview media-image-preview";
325
+ preview.src = getMediaFileUrl(relativePath);
326
+ preview.alt = "";
327
+ preview.loading = "lazy";
328
+
329
+ const label = document.createElement("span");
330
+ label.className = "media-item-name";
331
+ label.textContent = displayName;
332
+
333
+ button.append(preview, label);
334
+ button.addEventListener("click", () => {
335
+ handleImageSelection(image);
336
+ });
337
+
338
+ mediaGrid.append(button);
339
+ }
340
+
341
+ if (data.folders.length === 0 && data.images.length === 0) {
342
+ if (isSearching) {
343
+ setMediaStatus(
344
+ `No images matching "${data.searchQuery}" in ${formatMediaDirLabel(data.currentDir)}.`,
345
+ );
346
+ } else {
347
+ setMediaStatus("");
348
+ }
349
+ } else {
350
+ setMediaStatus("");
351
+ }
352
+ }
353
+
354
+ async function loadMediaDirectory(relativeDir = currentMediaDir) {
355
+ currentMediaDir = relativeDir;
356
+ const searchQuery = mediaSearchInput.value.trim();
357
+
358
+ if (!getProjectPath()) {
359
+ setMediaStatus("Open and scan a project first.", { isError: true });
360
+ mediaGrid.replaceChildren();
361
+ mediaUpBtn.disabled = true;
362
+ mediaBreadcrumb.replaceChildren();
363
+ return;
364
+ }
365
+
366
+ setMediaStatus(searchQuery ? "Searching…" : "Loading…");
367
+
368
+ try {
369
+ const params = new URLSearchParams({
370
+ project: getProjectPath(),
371
+ dir: relativeDir,
372
+ });
373
+
374
+ if (searchQuery) {
375
+ params.set("q", searchQuery);
376
+ }
377
+
378
+ const response = await fetch(`/api/media?${params.toString()}`);
379
+ const data = await response.json();
380
+
381
+ if (!response.ok) {
382
+ setMediaStatus(data.error ?? "Could not load media", { isError: true });
383
+ mediaGrid.replaceChildren();
384
+ mediaUpBtn.disabled = true;
385
+ mediaBreadcrumb.replaceChildren();
386
+ return;
387
+ }
388
+
389
+ renderMediaDirectory(data);
390
+ } catch {
391
+ setMediaStatus("Could not load media", { isError: true });
392
+ mediaGrid.replaceChildren();
393
+ mediaUpBtn.disabled = true;
394
+ mediaBreadcrumb.replaceChildren();
395
+ }
396
+ }
397
+
398
+ function openMediaDialog() {
399
+ onOpen?.();
400
+ showMediaBrowserView();
401
+ mediaSearchInput.value = "";
402
+ currentMediaDir = "img";
403
+ dialog.showModal();
404
+ loadMediaDirectory("img");
405
+ }
406
+
407
+ closeBtn.addEventListener("click", () => {
408
+ dialog.close();
409
+ });
410
+
411
+ dialog.addEventListener("click", (event) => {
412
+ if (event.target === dialog) {
413
+ dialog.close();
414
+ }
415
+ });
416
+
417
+ dialog.addEventListener("close", () => {
418
+ onClose?.();
419
+ showMediaBrowserView();
420
+ });
421
+
422
+ imageForm.addEventListener("submit", (event) => {
423
+ event.preventDefault();
424
+
425
+ if (!pendingImage) {
426
+ return;
427
+ }
428
+
429
+ const handled = onInsert?.(pendingImage, {
430
+ alt: imageAltInput.value,
431
+ lazyLoad: imageLazyInput.checked,
432
+ caption: imageCaptionInput.value,
433
+ });
434
+
435
+ if (handled !== false) {
436
+ dialog.close();
437
+ }
438
+ });
439
+
440
+ imageBackBtn.addEventListener("click", () => {
441
+ showMediaBrowserView();
442
+ });
443
+
444
+ mediaSearchInput.addEventListener("input", () => {
445
+ loadMediaDirectory(currentMediaDir);
446
+ });
447
+
448
+ imageAltInput.addEventListener("keydown", (event) => {
449
+ if (event.key === "Enter") {
450
+ event.preventDefault();
451
+ imageCaptionInput.focus();
452
+ }
453
+ });
454
+
455
+ return { dialog, openMediaDialog };
456
+ }
457
+
458
+ function filenameToAlt(filename) {
459
+ const base = filename.replace(/\.[^.]+$/, "");
460
+ return base
461
+ .replace(/[-_]+/g, " ")
462
+ .replace(/\s+/g, " ")
463
+ .trim();
464
+ }
package/public/styles.css CHANGED
@@ -849,17 +849,32 @@ details[open] > .tree-folder-header .tree-folder-chevron {
849
849
  align-items: start;
850
850
  }
851
851
 
852
- .frontmatter-row--compact,
853
- .frontmatter-array-item.frontmatter-row--compact {
854
- grid-template-columns: minmax(7rem, 0.9fr) 6.5rem 2rem;
852
+ .frontmatter-row--compact > .frontmatter-key-input {
853
+ grid-column: 1;
854
+ }
855
+
856
+ .frontmatter-row--compact > .frontmatter-type-select {
857
+ grid-column: 2;
858
+ }
859
+
860
+ .frontmatter-row--compact > .frontmatter-remove-btn {
861
+ grid-column: 4;
855
862
  }
856
863
 
857
864
  .frontmatter-array-item {
858
865
  grid-template-columns: 1rem 6.5rem minmax(0, 1.6fr) 2rem;
859
866
  }
860
867
 
861
- .frontmatter-array-item.frontmatter-row--compact {
862
- grid-template-columns: 1rem 6.5rem 2rem;
868
+ .frontmatter-array-item.frontmatter-row--compact > .frontmatter-array-marker {
869
+ grid-column: 1;
870
+ }
871
+
872
+ .frontmatter-array-item.frontmatter-row--compact > .frontmatter-type-select {
873
+ grid-column: 2;
874
+ }
875
+
876
+ .frontmatter-array-item.frontmatter-row--compact > .frontmatter-remove-btn {
877
+ grid-column: 4;
863
878
  }
864
879
 
865
880
  .frontmatter-children {
@@ -883,9 +898,43 @@ details[open] > .tree-folder-header .tree-folder-chevron {
883
898
  }
884
899
 
885
900
  .frontmatter-scalar {
901
+ display: flex;
902
+ align-items: center;
903
+ gap: 0.35rem;
886
904
  width: 100%;
887
905
  }
888
906
 
907
+ .frontmatter-scalar .frontmatter-value-input {
908
+ flex: 1;
909
+ min-width: 0;
910
+ }
911
+
912
+ .frontmatter-media-btn {
913
+ display: inline-flex;
914
+ align-items: center;
915
+ justify-content: center;
916
+ flex-shrink: 0;
917
+ width: 2rem;
918
+ height: 2.15rem;
919
+ padding: 0;
920
+ border: 1px solid transparent;
921
+ border-radius: 8px;
922
+ background: transparent;
923
+ color: var(--muted);
924
+ cursor: pointer;
925
+ }
926
+
927
+ .frontmatter-media-btn:hover:not(:disabled) {
928
+ border-color: var(--border);
929
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
930
+ color: var(--accent);
931
+ }
932
+
933
+ .frontmatter-media-btn svg {
934
+ display: block;
935
+ flex-shrink: 0;
936
+ }
937
+
889
938
  .frontmatter-boolean-label {
890
939
  display: inline-flex;
891
940
  align-items: center;
@@ -1283,6 +1332,94 @@ details[open] > .tree-folder-header .tree-folder-chevron {
1283
1332
  margin-top: 0.15rem;
1284
1333
  }
1285
1334
 
1335
+ .new-item-frontmatter {
1336
+ display: grid;
1337
+ gap: 0.45rem;
1338
+ }
1339
+
1340
+ .new-item-import-frontmatter {
1341
+ justify-self: start;
1342
+ }
1343
+
1344
+ .new-item-frontmatter-source {
1345
+ margin: 0;
1346
+ font-size: 0.85rem;
1347
+ color: var(--muted);
1348
+ }
1349
+
1350
+ .new-item-frontmatter-source[hidden] {
1351
+ display: none;
1352
+ }
1353
+
1354
+ .frontmatter-source-dialog {
1355
+ width: min(560px, calc(100vw - 2rem));
1356
+ max-height: calc(100vh - 2rem);
1357
+ padding: 0;
1358
+ border: 1px solid var(--border);
1359
+ border-radius: 14px;
1360
+ background: var(--surface);
1361
+ color: var(--text);
1362
+ overflow: auto;
1363
+ }
1364
+
1365
+ .frontmatter-source-dialog::backdrop {
1366
+ background: rgb(0 0 0 / 55%);
1367
+ }
1368
+
1369
+ .frontmatter-source-body {
1370
+ margin: 0.75rem 1.25rem 1.25rem;
1371
+ display: grid;
1372
+ gap: 0.75rem;
1373
+ }
1374
+
1375
+ .frontmatter-source-hint {
1376
+ margin: 0;
1377
+ font-size: 0.9rem;
1378
+ color: var(--muted);
1379
+ }
1380
+
1381
+ .frontmatter-source-error {
1382
+ margin: 0;
1383
+ font-size: 0.85rem;
1384
+ color: #ff8b8b;
1385
+ }
1386
+
1387
+ .frontmatter-source-error[hidden] {
1388
+ display: none;
1389
+ }
1390
+
1391
+ .frontmatter-source-tree {
1392
+ max-height: min(420px, calc(100vh - 12rem));
1393
+ overflow: auto;
1394
+ }
1395
+
1396
+ .frontmatter-source-tree .frontmatter-source-folder-header {
1397
+ grid-template-columns: minmax(0, 1fr);
1398
+ }
1399
+
1400
+ .frontmatter-source-tree .frontmatter-source-file {
1401
+ grid-template-columns: minmax(0, 1fr);
1402
+ }
1403
+
1404
+ .frontmatter-source-new-page {
1405
+ cursor: default;
1406
+ background: color-mix(in srgb, var(--accent) 6%, var(--surface));
1407
+ }
1408
+
1409
+ .frontmatter-source-new-page:hover {
1410
+ background: color-mix(in srgb, var(--accent) 6%, var(--surface));
1411
+ }
1412
+
1413
+ .badge.new-page {
1414
+ color: var(--accent);
1415
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
1416
+ }
1417
+
1418
+ .frontmatter-source-empty {
1419
+ padding: 1rem;
1420
+ color: var(--muted);
1421
+ }
1422
+
1286
1423
  .confirm-dialog {
1287
1424
  width: min(420px, calc(100vw - 2rem));
1288
1425
  padding: 0;