starmark 1.0.0 → 1.0.2

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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # starmark
2
+
3
+ *starmark* is a local web-based CMS for your [Astro](https://astro.build) site. It has been designed to work with [Asto Accelerator](https://astro.stevefenton.co.uk).
4
+
5
+ With *starmark* you get:
6
+
7
+ - File browser
8
+ - Markdown editing
9
+ - Frontmatter editing
10
+ - Media library and image insertion
11
+
12
+ Browse and edit markdown content in local Astro sites.
13
+
14
+ Starmark runs a small local web app that scans an Astro project for `.md` and `.mdx` files, lets you browse them in a file tree, and edit content in the browser.
15
+
16
+ ## Running starmark
17
+
18
+ With a terminal open in your Astro site, simply run:
19
+
20
+ ```bash
21
+ npx starmark
22
+ ```
23
+
24
+ You'll be given a local address for *starmark*, usually: [http://localhost:5748](http://localhost:5748).
25
+
26
+ Visit the URL and go edit. All the changes will be in your changes tab ready for review and commit.
27
+
28
+ ## License
29
+
30
+ [CC-BY-NC-ND-4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starmark",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Browse and edit markdown content in local Astro sites",
5
5
  "keywords": [
6
6
  "cms",
@@ -32,11 +32,16 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "start": "node bin/starmark.js",
35
- "dev": "node --watch bin/starmark.js"
35
+ "dev": "node --watch bin/starmark.js",
36
+ "test": "cucumber-js"
36
37
  },
37
38
  "dependencies": {
38
39
  "express": "^4.21.2"
39
40
  },
41
+ "devDependencies": {
42
+ "@cucumber/cucumber": "^11.3.0",
43
+ "supertest": "^7.1.0"
44
+ },
40
45
  "engines": {
41
46
  "node": ">=18"
42
47
  }
package/public/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { icons } from "./icons.js";
2
2
  import { createFrontmatterEditor } from "./frontmatter-editor.js";
3
- import { normalizeFrontmatter } from "./frontmatter.js";
3
+ import { normalizeFrontmatter, prepareImportedFrontmatter } from "./frontmatter.js";
4
4
 
5
5
  const folderInput = document.getElementById("folder-path");
6
6
  const browseBtn = document.getElementById("browse-btn");
@@ -366,6 +366,46 @@ function saveEditorCaret() {
366
366
  return { lineIndex: lineElements.length, offset: 0 };
367
367
  }
368
368
 
369
+ function normalizeEditorDom() {
370
+ for (const child of [...markdownEditor.childNodes]) {
371
+ if (child.nodeType === Node.TEXT_NODE) {
372
+ if (!child.textContent?.trim()) {
373
+ child.remove();
374
+ continue;
375
+ }
376
+
377
+ const paragraph = document.createElement("p");
378
+ paragraph.textContent = child.textContent;
379
+ child.replaceWith(paragraph);
380
+ continue;
381
+ }
382
+
383
+ if (child.nodeType !== Node.ELEMENT_NODE) {
384
+ child.remove();
385
+ continue;
386
+ }
387
+
388
+ const tagName = child.tagName.toLowerCase();
389
+ if (tagName === "br") {
390
+ child.remove();
391
+ continue;
392
+ }
393
+
394
+ if (tagName === "div" && !child.matches(EDITOR_LINE_SELECTOR)) {
395
+ const paragraph = document.createElement("p");
396
+ while (child.firstChild) {
397
+ paragraph.appendChild(child.firstChild);
398
+ }
399
+
400
+ if (paragraph.childNodes.length === 0) {
401
+ paragraph.append(document.createElement("br"));
402
+ }
403
+
404
+ child.replaceWith(paragraph);
405
+ }
406
+ }
407
+ }
408
+
369
409
  function getEditorLineElements() {
370
410
  return [...markdownEditor.children].filter((child) => child.matches(EDITOR_LINE_SELECTOR));
371
411
  }
@@ -375,6 +415,7 @@ function getEditorLines() {
375
415
  }
376
416
 
377
417
  function getEditorContent() {
418
+ normalizeEditorDom();
378
419
  return getEditorLines().join("\n");
379
420
  }
380
421
 
@@ -533,6 +574,40 @@ function runEditorHistoryAction(action) {
533
574
  flushEditorHistory();
534
575
  }
535
576
 
577
+ function insertParagraphAtEditorCaret() {
578
+ if (markdownEditor.dataset.loading || !currentEditFile) {
579
+ return;
580
+ }
581
+
582
+ normalizeEditorDom();
583
+ const caret = getEditorCaretSnapshot();
584
+ if (!caret) {
585
+ return;
586
+ }
587
+
588
+ const lines = getEditorLines();
589
+ const { lineIndex, offset } = caret;
590
+ const currentLine = lines[lineIndex] ?? "";
591
+ const before = currentLine.slice(0, offset);
592
+ const after = currentLine.slice(offset);
593
+ const nextLines = [
594
+ ...lines.slice(0, lineIndex),
595
+ before,
596
+ after,
597
+ ...lines.slice(lineIndex + 1),
598
+ ];
599
+
600
+ renderMarkdownEditor(nextLines.join("\n"));
601
+
602
+ const lineElements = getEditorLineElements();
603
+ const nextLineElement = lineElements[lineIndex + 1];
604
+ if (nextLineElement) {
605
+ setCaretOffsetInElement(nextLineElement, 0);
606
+ }
607
+
608
+ scheduleEditorHistoryCommit();
609
+ }
610
+
536
611
  function insertMarkdownAtCaret(markdown, caret = pendingEditorCaret) {
537
612
  if (!caret) {
538
613
  return false;
@@ -810,9 +885,8 @@ function reevaluateMarkdownEditorLines() {
810
885
  return;
811
886
  }
812
887
 
813
- const lineElements = [...markdownEditor.children].filter((child) =>
814
- child.matches(EDITOR_LINE_SELECTOR),
815
- );
888
+ normalizeEditorDom();
889
+ const lineElements = getEditorLineElements();
816
890
  const lineStates = getEditorLineStates(lineElements.map(getEditorLineText));
817
891
 
818
892
  lineElements.forEach((child, index) => {
@@ -1558,9 +1632,14 @@ function setFileInUrl(filePath) {
1558
1632
  window.history.replaceState({}, "", url);
1559
1633
  }
1560
1634
 
1635
+ function scrollMainToTop() {
1636
+ window.scrollTo(0, 0);
1637
+ }
1638
+
1561
1639
  function showListView() {
1562
1640
  listView.hidden = false;
1563
1641
  editView.hidden = true;
1642
+ scrollMainToTop();
1564
1643
  currentEditFile = null;
1565
1644
  currentEditFrontmatter = null;
1566
1645
  frontmatterPanel.hidden = true;
@@ -1667,12 +1746,13 @@ async function saveCurrentFile() {
1667
1746
 
1668
1747
  flushEditorHistory();
1669
1748
  currentEditFrontmatter = normalizeFrontmatter(frontmatterEditor.getValue());
1670
- const content = buildFileContent(currentEditFrontmatter, getEditorContent());
1671
1749
 
1672
1750
  isSavingFile = true;
1673
1751
  setSaveButtonState({ label: "Saving…", disabled: true });
1674
1752
 
1675
- try {
1753
+ const writeEditorContent = async () => {
1754
+ const body = getEditorContent();
1755
+ const content = buildFileContent(currentEditFrontmatter, body);
1676
1756
  const response = await fetch("/api/file", {
1677
1757
  method: "POST",
1678
1758
  headers: { "Content-Type": "application/json" },
@@ -1682,6 +1762,11 @@ async function saveCurrentFile() {
1682
1762
  }),
1683
1763
  });
1684
1764
  const data = await response.json();
1765
+ return { body, content, response, data };
1766
+ };
1767
+
1768
+ try {
1769
+ let { body, content, response, data } = await writeEditorContent();
1685
1770
 
1686
1771
  if (!response.ok) {
1687
1772
  setSaveButtonState({
@@ -1693,7 +1778,21 @@ async function saveCurrentFile() {
1693
1778
  return;
1694
1779
  }
1695
1780
 
1696
- const { frontmatter, body } = splitFrontmatter(content);
1781
+ const latestBody = getEditorContent();
1782
+ if (latestBody !== body) {
1783
+ ({ body, content, response, data } = await writeEditorContent());
1784
+ if (!response.ok) {
1785
+ setSaveButtonState({
1786
+ label: data.error ?? "Save failed",
1787
+ disabled: false,
1788
+ error: true,
1789
+ });
1790
+ resetSaveButtonSoon(2500);
1791
+ return;
1792
+ }
1793
+ }
1794
+
1795
+ const { frontmatter, body: savedBody } = splitFrontmatter(content);
1697
1796
  currentEditFrontmatter = normalizeFrontmatter(frontmatter ?? "");
1698
1797
  frontmatterEditor.setValue(currentEditFrontmatter ?? "");
1699
1798
  updateEditHeader(currentEditFile, currentEditFrontmatter);
@@ -1707,8 +1806,8 @@ async function saveCurrentFile() {
1707
1806
  updateFileResults();
1708
1807
  }
1709
1808
 
1710
- renderMarkdownEditor(body);
1711
- resetEditorHistory(body);
1809
+ renderMarkdownEditor(savedBody);
1810
+ resetEditorHistory(savedBody);
1712
1811
  setSaveButtonState({ label: "Saved", disabled: false });
1713
1812
  resetSaveButtonSoon();
1714
1813
  } catch {
@@ -1737,6 +1836,7 @@ function handleFrontmatterInput() {
1737
1836
  function showEditView() {
1738
1837
  listView.hidden = true;
1739
1838
  editView.hidden = false;
1839
+ scrollMainToTop();
1740
1840
  }
1741
1841
 
1742
1842
  async function openEditView(file) {
@@ -2332,7 +2432,40 @@ async function scanFolder(pathValue = folderInput.value.trim()) {
2332
2432
  }
2333
2433
  }
2334
2434
 
2335
- async function createEntry(parentPath, name) {
2435
+ function isMarkdownEntryName(name) {
2436
+ const lower = name.trim().toLowerCase();
2437
+ return lower.endsWith(".md") || lower.endsWith(".mdx");
2438
+ }
2439
+
2440
+ function getPathsToExpand(relativePath) {
2441
+ const normalized = normalizeRelativePath(relativePath);
2442
+ const paths = new Set();
2443
+
2444
+ if (!normalized) {
2445
+ return paths;
2446
+ }
2447
+
2448
+ let current = "";
2449
+ for (const segment of normalized.split("/")) {
2450
+ current = current ? `${current}/${segment}` : segment;
2451
+ paths.add(current);
2452
+ }
2453
+
2454
+ return paths;
2455
+ }
2456
+
2457
+ async function fetchFileFrontmatter(absolutePath) {
2458
+ const response = await fetch(`/api/file?path=${encodeURIComponent(absolutePath)}`);
2459
+ const data = await response.json();
2460
+
2461
+ if (!response.ok) {
2462
+ throw new Error(data.error ?? "Could not read file");
2463
+ }
2464
+
2465
+ return data.frontmatter ?? "";
2466
+ }
2467
+
2468
+ async function createEntry(parentPath, name, { frontmatter = null } = {}) {
2336
2469
  if (!currentProjectPath) {
2337
2470
  return { ok: false, error: "Choose a project folder first" };
2338
2471
  }
@@ -2360,6 +2493,7 @@ async function createEntry(parentPath, name) {
2360
2493
  projectPath: currentProjectPath,
2361
2494
  parentPath,
2362
2495
  name: trimmedName,
2496
+ ...(frontmatter ? { frontmatter } : {}),
2363
2497
  }),
2364
2498
  });
2365
2499
  const data = await response.json();
@@ -2634,7 +2768,298 @@ function createConfirmDeleteDialog() {
2634
2768
 
2635
2769
  const { openConfirmDeleteDialog } = createConfirmDeleteDialog();
2636
2770
 
2637
- function createNewItemDialog() {
2771
+ function createFrontmatterSourceDialog() {
2772
+ const dialog = document.createElement("dialog");
2773
+ dialog.id = "frontmatter-source-dialog";
2774
+ dialog.className = "frontmatter-source-dialog";
2775
+ dialog.innerHTML = `
2776
+ <div class="dialog-header">
2777
+ <h2>Import frontmatter from</h2>
2778
+ <button type="button" class="dialog-close frontmatter-source-dialog-close" aria-label="Close">&times;</button>
2779
+ </div>
2780
+ <div class="frontmatter-source-body">
2781
+ <p class="frontmatter-source-hint">Choose a markdown file to copy its frontmatter.</p>
2782
+ <p class="frontmatter-source-error" hidden></p>
2783
+ <ul class="frontmatter-source-tree file-tree"></ul>
2784
+ </div>
2785
+ `;
2786
+
2787
+ const closeBtn = dialog.querySelector(".frontmatter-source-dialog-close");
2788
+ const treeRoot = dialog.querySelector(".frontmatter-source-tree");
2789
+ const errorEl = dialog.querySelector(".frontmatter-source-error");
2790
+ let onSelect = null;
2791
+
2792
+ function clearDialogError() {
2793
+ errorEl.hidden = true;
2794
+ errorEl.textContent = "";
2795
+ }
2796
+
2797
+ function showDialogError(message) {
2798
+ errorEl.textContent = message;
2799
+ errorEl.hidden = false;
2800
+ }
2801
+
2802
+ function createPickerNewPageRow(name, depth) {
2803
+ const item = document.createElement("li");
2804
+ item.className = "tree-file frontmatter-source-file frontmatter-source-new-page";
2805
+ item.style.setProperty("--depth", depth);
2806
+ item.dataset.newPageTarget = "true";
2807
+
2808
+ const badge = document.createElement("span");
2809
+ badge.className = "badge new-page";
2810
+ badge.innerHTML = `${icons.fileText}<span>new</span>`;
2811
+
2812
+ const label = document.createElement("span");
2813
+ label.className = "file-name";
2814
+ label.textContent = name;
2815
+
2816
+ const main = document.createElement("div");
2817
+ main.className = "tree-file-main";
2818
+ main.append(badge, label);
2819
+ item.append(main);
2820
+
2821
+ return item;
2822
+ }
2823
+
2824
+ function appendPickerChildren(container, node, depth, expandPaths, pickerContext) {
2825
+ for (const child of node.children) {
2826
+ container.append(createPickerRow(child, depth, expandPaths, pickerContext));
2827
+ }
2828
+
2829
+ if (pickerContext.newPageName && node.path === pickerContext.parentPath) {
2830
+ container.append(createPickerNewPageRow(pickerContext.newPageName, depth));
2831
+ }
2832
+ }
2833
+
2834
+ function createPickerFolderRow(node, depth, expandPaths, pickerContext) {
2835
+ const item = document.createElement("li");
2836
+ item.className = "tree-folder frontmatter-source-folder";
2837
+ item.style.setProperty("--depth", depth);
2838
+
2839
+ const details = document.createElement("details");
2840
+ details.open = expandPaths.has(node.path);
2841
+
2842
+ if (node.path === pickerContext.parentPath && !pickerContext.newPageName) {
2843
+ item.dataset.parentTarget = "true";
2844
+ }
2845
+
2846
+ const summary = document.createElement("summary");
2847
+ summary.className = "tree-folder-header frontmatter-source-folder-header";
2848
+
2849
+ const label = document.createElement("span");
2850
+ label.className = "tree-folder-name";
2851
+ label.textContent = depth === 0 ? (node.path || node.name) : node.name;
2852
+
2853
+ if (depth > 0) {
2854
+ summary.append(
2855
+ createFolderMain(createFolderPrefix(createFolderBadge(), createFolderChevron()), label),
2856
+ );
2857
+ } else if (depth === 0 && node.source) {
2858
+ summary.append(
2859
+ createFolderMain(
2860
+ createFolderPrefix(createSourceBadge(node.source), createFolderChevron()),
2861
+ label,
2862
+ ),
2863
+ );
2864
+ } else {
2865
+ summary.append(createFolderMain(createFolderPrefix(createFolderChevron()), label));
2866
+ }
2867
+
2868
+ details.append(summary);
2869
+
2870
+ if (node.children.length > 0 || (pickerContext.newPageName && node.path === pickerContext.parentPath)) {
2871
+ const children = document.createElement("ul");
2872
+ children.className = "tree-children";
2873
+ appendPickerChildren(children, node, depth + 1, expandPaths, pickerContext);
2874
+ details.append(children);
2875
+ }
2876
+
2877
+ item.append(details);
2878
+ return item;
2879
+ }
2880
+
2881
+ function createPickerPageFolderRow(node, depth, expandPaths, pickerContext) {
2882
+ const { pageFile } = node;
2883
+ const item = document.createElement("li");
2884
+ item.className = "tree-folder tree-page-folder frontmatter-source-folder";
2885
+ item.style.setProperty("--depth", depth);
2886
+
2887
+ const details = document.createElement("details");
2888
+ details.open = expandPaths.has(node.path);
2889
+
2890
+ if (node.path === pickerContext.parentPath && !pickerContext.newPageName) {
2891
+ item.dataset.parentTarget = "true";
2892
+ }
2893
+
2894
+ const summary = document.createElement("summary");
2895
+ summary.className = "tree-folder-header frontmatter-source-folder-header";
2896
+
2897
+ const pageBadge = document.createElement("span");
2898
+ pageBadge.className = "badge page";
2899
+ pageBadge.innerHTML = `${icons.folder}<span>page folder</span>`;
2900
+
2901
+ const label = document.createElement("span");
2902
+ label.className = "tree-folder-name";
2903
+ label.textContent = node.name;
2904
+
2905
+ summary.append(
2906
+ createFolderMain(createFolderPrefix(pageBadge, createFolderChevron()), label),
2907
+ );
2908
+
2909
+ details.append(summary);
2910
+
2911
+ const children = document.createElement("ul");
2912
+ children.className = "tree-children";
2913
+ children.append(createPickerFileRow(
2914
+ {
2915
+ type: "file",
2916
+ name: pageFile.name,
2917
+ path: pageFile.relativePath,
2918
+ file: pageFile,
2919
+ },
2920
+ depth + 1,
2921
+ ));
2922
+ appendPickerChildren(children, node, depth + 1, expandPaths, pickerContext);
2923
+ details.append(children);
2924
+
2925
+ item.append(details);
2926
+ return item;
2927
+ }
2928
+
2929
+ function createPickerFileRow(node, depth) {
2930
+ const file = node.file;
2931
+ const item = document.createElement("li");
2932
+ item.className = "tree-file frontmatter-source-file";
2933
+ item.style.setProperty("--depth", depth);
2934
+
2935
+ const extBadge = document.createElement("span");
2936
+ extBadge.className = `badge ${file.extension}`;
2937
+ extBadge.innerHTML = `${icons.fileText}<span>${file.extension}</span>`;
2938
+
2939
+ const name = document.createElement("span");
2940
+ name.className = "file-name";
2941
+ name.textContent = file.name;
2942
+ name.title = file.relativePath;
2943
+
2944
+ const main = document.createElement("div");
2945
+ main.className = "tree-file-main";
2946
+ main.append(extBadge, name);
2947
+ item.append(main);
2948
+
2949
+ item.addEventListener("click", async () => {
2950
+ if (!onSelect) {
2951
+ return;
2952
+ }
2953
+
2954
+ clearDialogError();
2955
+
2956
+ try {
2957
+ const sourceFrontmatter = await fetchFileFrontmatter(file.absolutePath);
2958
+ const importedFrontmatter = prepareImportedFrontmatter(sourceFrontmatter);
2959
+
2960
+ if (!importedFrontmatter) {
2961
+ showDialogError(`"${file.name}" has no frontmatter to import.`);
2962
+ return;
2963
+ }
2964
+
2965
+ onSelect({
2966
+ file,
2967
+ frontmatter: importedFrontmatter,
2968
+ });
2969
+ dialog.close();
2970
+ } catch (error) {
2971
+ showDialogError(error.message ?? "Could not import frontmatter");
2972
+ }
2973
+ });
2974
+
2975
+ return item;
2976
+ }
2977
+
2978
+ function createPickerRow(node, depth, expandPaths, pickerContext) {
2979
+ if (node.type === "folder") {
2980
+ return createPickerFolderRow(node, depth, expandPaths, pickerContext);
2981
+ }
2982
+
2983
+ if (node.type === "page-folder") {
2984
+ return createPickerPageFolderRow(node, depth, expandPaths, pickerContext);
2985
+ }
2986
+
2987
+ return createPickerFileRow(node, depth);
2988
+ }
2989
+
2990
+ function scrollPickerToTarget() {
2991
+ const target =
2992
+ treeRoot.querySelector("[data-new-page-target='true']") ??
2993
+ treeRoot.querySelector("[data-parent-target='true']");
2994
+
2995
+ if (!target) {
2996
+ return;
2997
+ }
2998
+
2999
+ target.scrollIntoView({ block: "center" });
3000
+ }
3001
+
3002
+ function renderPickerTree(parentPath, newPageName = "") {
3003
+ treeRoot.replaceChildren();
3004
+
3005
+ const expandPaths = getPathsToExpand(parentPath);
3006
+ const pickerContext = {
3007
+ parentPath: normalizeRelativePath(parentPath),
3008
+ newPageName: newPageName.trim(),
3009
+ };
3010
+ const markdownFiles = scannedFiles.filter((file) =>
3011
+ ["md", "mdx"].includes(file.extension),
3012
+ );
3013
+ const tree = buildFileTree(markdownFiles, {
3014
+ directories: scannedDirectories,
3015
+ scanTargets: lastScanTargets,
3016
+ });
3017
+
3018
+ if (tree.length === 0) {
3019
+ const empty = document.createElement("li");
3020
+ empty.className = "frontmatter-source-empty";
3021
+ empty.textContent = "No markdown files found in this project.";
3022
+ treeRoot.append(empty);
3023
+ return;
3024
+ }
3025
+
3026
+ for (const node of tree) {
3027
+ treeRoot.append(createPickerRow(node, 0, expandPaths, pickerContext));
3028
+ }
3029
+ }
3030
+
3031
+ closeBtn.addEventListener("click", () => {
3032
+ dialog.close();
3033
+ });
3034
+
3035
+ dialog.addEventListener("click", (event) => {
3036
+ if (event.target === dialog) {
3037
+ dialog.close();
3038
+ }
3039
+ });
3040
+
3041
+ dialog.addEventListener("close", () => {
3042
+ onSelect = null;
3043
+ clearDialogError();
3044
+ treeRoot.replaceChildren();
3045
+ });
3046
+
3047
+ function openFrontmatterSourceDialog({ parentPath, newPageName = "" }, selectHandler) {
3048
+ onSelect = selectHandler;
3049
+ clearDialogError();
3050
+ renderPickerTree(parentPath, newPageName);
3051
+ dialog.showModal();
3052
+ requestAnimationFrame(() => {
3053
+ requestAnimationFrame(scrollPickerToTarget);
3054
+ });
3055
+ }
3056
+
3057
+ document.body.append(dialog);
3058
+
3059
+ return { openFrontmatterSourceDialog };
3060
+ }
3061
+
3062
+ function createNewItemDialog({ openFrontmatterSourceDialog }) {
2638
3063
  const dialog = document.createElement("dialog");
2639
3064
  dialog.id = "new-item-dialog";
2640
3065
  dialog.className = "new-item-dialog";
@@ -2654,6 +3079,10 @@ function createNewItemDialog() {
2654
3079
  placeholder="post.md or my-folder"
2655
3080
  />
2656
3081
  <p class="new-item-hint">.md / .mdx creates a file; anything else creates a folder.</p>
3082
+ <div class="new-item-frontmatter" hidden>
3083
+ <button type="button" class="new-item-import-frontmatter">Import frontmatter from file…</button>
3084
+ <p class="new-item-frontmatter-source" hidden></p>
3085
+ </div>
2657
3086
  <p class="new-item-error" hidden></p>
2658
3087
  </div>
2659
3088
  <div class="new-item-form-actions">
@@ -2665,8 +3094,12 @@ function createNewItemDialog() {
2665
3094
  const closeBtn = dialog.querySelector(".new-item-dialog-close");
2666
3095
  const form = dialog.querySelector(".new-item-form");
2667
3096
  const nameInput = dialog.querySelector("#new-item-name");
3097
+ const frontmatterSection = dialog.querySelector(".new-item-frontmatter");
3098
+ const importFrontmatterBtn = dialog.querySelector(".new-item-import-frontmatter");
3099
+ const frontmatterSourceEl = dialog.querySelector(".new-item-frontmatter-source");
2668
3100
  const errorEl = dialog.querySelector(".new-item-error");
2669
3101
  let pendingParentPath = "";
3102
+ let pendingImportedFrontmatter = null;
2670
3103
 
2671
3104
  function clearDialogError() {
2672
3105
  errorEl.hidden = true;
@@ -2678,6 +3111,28 @@ function createNewItemDialog() {
2678
3111
  errorEl.hidden = false;
2679
3112
  }
2680
3113
 
3114
+ function clearImportedFrontmatter() {
3115
+ pendingImportedFrontmatter = null;
3116
+ frontmatterSourceEl.hidden = true;
3117
+ frontmatterSourceEl.textContent = "";
3118
+ }
3119
+
3120
+ function updateFrontmatterImportVisibility() {
3121
+ const showImport = isMarkdownEntryName(nameInput.value);
3122
+ frontmatterSection.hidden = !showImport;
3123
+
3124
+ if (!showImport) {
3125
+ clearImportedFrontmatter();
3126
+ }
3127
+ }
3128
+
3129
+ function setImportedFrontmatter(sourceName, frontmatter) {
3130
+ pendingImportedFrontmatter = frontmatter;
3131
+ frontmatterSourceEl.textContent = `Using frontmatter from ${sourceName}`;
3132
+ frontmatterSourceEl.hidden = false;
3133
+ clearDialogError();
3134
+ }
3135
+
2681
3136
  closeBtn.addEventListener("click", () => {
2682
3137
  dialog.close();
2683
3138
  });
@@ -2690,11 +3145,29 @@ function createNewItemDialog() {
2690
3145
 
2691
3146
  dialog.addEventListener("close", () => {
2692
3147
  pendingParentPath = "";
3148
+ pendingImportedFrontmatter = null;
2693
3149
  form.reset();
2694
3150
  clearDialogError();
3151
+ clearImportedFrontmatter();
3152
+ frontmatterSection.hidden = true;
2695
3153
  });
2696
3154
 
2697
- nameInput.addEventListener("input", clearDialogError);
3155
+ nameInput.addEventListener("input", () => {
3156
+ clearDialogError();
3157
+ updateFrontmatterImportVisibility();
3158
+ });
3159
+
3160
+ importFrontmatterBtn.addEventListener("click", () => {
3161
+ openFrontmatterSourceDialog(
3162
+ {
3163
+ parentPath: pendingParentPath,
3164
+ newPageName: nameInput.value.trim(),
3165
+ },
3166
+ ({ file, frontmatter }) => {
3167
+ setImportedFrontmatter(file.name, frontmatter);
3168
+ },
3169
+ );
3170
+ });
2698
3171
 
2699
3172
  form.addEventListener("submit", async (event) => {
2700
3173
  event.preventDefault();
@@ -2705,7 +3178,9 @@ function createNewItemDialog() {
2705
3178
 
2706
3179
  clearDialogError();
2707
3180
 
2708
- const result = await createEntry(pendingParentPath, name);
3181
+ const result = await createEntry(pendingParentPath, name, {
3182
+ frontmatter: pendingImportedFrontmatter,
3183
+ });
2709
3184
  if (!result.ok) {
2710
3185
  showDialogError(result.error);
2711
3186
  nameInput.focus();
@@ -2718,7 +3193,9 @@ function createNewItemDialog() {
2718
3193
 
2719
3194
  function openNewItemDialog(parentPath) {
2720
3195
  pendingParentPath = parentPath;
3196
+ clearImportedFrontmatter();
2721
3197
  clearDialogError();
3198
+ updateFrontmatterImportVisibility();
2722
3199
  dialog.showModal();
2723
3200
  nameInput.focus();
2724
3201
  }
@@ -2728,7 +3205,8 @@ function createNewItemDialog() {
2728
3205
  return { openNewItemDialog };
2729
3206
  }
2730
3207
 
2731
- const { openNewItemDialog } = createNewItemDialog();
3208
+ const { openFrontmatterSourceDialog } = createFrontmatterSourceDialog();
3209
+ const { openNewItemDialog } = createNewItemDialog({ openFrontmatterSourceDialog });
2732
3210
 
2733
3211
  browseBtn.addEventListener("click", browseFolder);
2734
3212
  scanBtn.addEventListener("click", () => scanFolder());
@@ -2798,6 +3276,14 @@ function createImageLightbox() {
2798
3276
 
2799
3277
  editBackBtn.insertAdjacentHTML("afterbegin", icons.chevronLeft);
2800
3278
 
3279
+ try {
3280
+ markdownEditor.focus({ preventScroll: true });
3281
+ document.execCommand("defaultParagraphSeparator", false, "p");
3282
+ markdownEditor.blur();
3283
+ } catch {
3284
+ // Browser may not support defaultParagraphSeparator.
3285
+ }
3286
+
2801
3287
  editBackBtn.addEventListener("click", showListView);
2802
3288
  editSaveBtn.addEventListener("click", saveCurrentFile);
2803
3289
  markdownEditor.addEventListener("click", (event) => {
@@ -2829,7 +3315,21 @@ markdownEditor.addEventListener("beforeinput", (event) => {
2829
3315
  }
2830
3316
  });
2831
3317
 
3318
+ markdownEditor.addEventListener("focus", () => {
3319
+ try {
3320
+ document.execCommand("defaultParagraphSeparator", false, "p");
3321
+ } catch {
3322
+ // Browser may not support defaultParagraphSeparator.
3323
+ }
3324
+ });
3325
+
2832
3326
  markdownEditor.addEventListener("keydown", (event) => {
3327
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
3328
+ event.preventDefault();
3329
+ insertParagraphAtEditorCaret();
3330
+ return;
3331
+ }
3332
+
2833
3333
  const isMod = event.metaKey || event.ctrlKey;
2834
3334
  if (!isMod) {
2835
3335
  return;
@@ -552,3 +552,38 @@ export function defaultValueForType(type) {
552
552
  return "";
553
553
  }
554
554
  }
555
+
556
+ export function getTodayDateString() {
557
+ const now = new Date();
558
+ return [
559
+ now.getFullYear(),
560
+ String(now.getMonth() + 1).padStart(2, "0"),
561
+ String(now.getDate()).padStart(2, "0"),
562
+ ].join("-");
563
+ }
564
+
565
+ export function prepareImportedFrontmatter(frontmatterText) {
566
+ const trimmed = (frontmatterText ?? "").trim();
567
+ if (trimmed === "") {
568
+ return null;
569
+ }
570
+
571
+ let data;
572
+ try {
573
+ data = parseFrontmatter(trimmed);
574
+ } catch {
575
+ return null;
576
+ }
577
+
578
+ if (!isPlainObject(data)) {
579
+ return null;
580
+ }
581
+
582
+ delete data.modDate;
583
+
584
+ if (Object.prototype.hasOwnProperty.call(data, "pubDate")) {
585
+ data.pubDate = getTodayDateString();
586
+ }
587
+
588
+ return normalizeFrontmatter(stringifyFrontmatter(data));
589
+ }
package/public/styles.css CHANGED
@@ -1283,6 +1283,94 @@ details[open] > .tree-folder-header .tree-folder-chevron {
1283
1283
  margin-top: 0.15rem;
1284
1284
  }
1285
1285
 
1286
+ .new-item-frontmatter {
1287
+ display: grid;
1288
+ gap: 0.45rem;
1289
+ }
1290
+
1291
+ .new-item-import-frontmatter {
1292
+ justify-self: start;
1293
+ }
1294
+
1295
+ .new-item-frontmatter-source {
1296
+ margin: 0;
1297
+ font-size: 0.85rem;
1298
+ color: var(--muted);
1299
+ }
1300
+
1301
+ .new-item-frontmatter-source[hidden] {
1302
+ display: none;
1303
+ }
1304
+
1305
+ .frontmatter-source-dialog {
1306
+ width: min(560px, calc(100vw - 2rem));
1307
+ max-height: calc(100vh - 2rem);
1308
+ padding: 0;
1309
+ border: 1px solid var(--border);
1310
+ border-radius: 14px;
1311
+ background: var(--surface);
1312
+ color: var(--text);
1313
+ overflow: auto;
1314
+ }
1315
+
1316
+ .frontmatter-source-dialog::backdrop {
1317
+ background: rgb(0 0 0 / 55%);
1318
+ }
1319
+
1320
+ .frontmatter-source-body {
1321
+ margin: 0.75rem 1.25rem 1.25rem;
1322
+ display: grid;
1323
+ gap: 0.75rem;
1324
+ }
1325
+
1326
+ .frontmatter-source-hint {
1327
+ margin: 0;
1328
+ font-size: 0.9rem;
1329
+ color: var(--muted);
1330
+ }
1331
+
1332
+ .frontmatter-source-error {
1333
+ margin: 0;
1334
+ font-size: 0.85rem;
1335
+ color: #ff8b8b;
1336
+ }
1337
+
1338
+ .frontmatter-source-error[hidden] {
1339
+ display: none;
1340
+ }
1341
+
1342
+ .frontmatter-source-tree {
1343
+ max-height: min(420px, calc(100vh - 12rem));
1344
+ overflow: auto;
1345
+ }
1346
+
1347
+ .frontmatter-source-tree .frontmatter-source-folder-header {
1348
+ grid-template-columns: minmax(0, 1fr);
1349
+ }
1350
+
1351
+ .frontmatter-source-tree .frontmatter-source-file {
1352
+ grid-template-columns: minmax(0, 1fr);
1353
+ }
1354
+
1355
+ .frontmatter-source-new-page {
1356
+ cursor: default;
1357
+ background: color-mix(in srgb, var(--accent) 6%, var(--surface));
1358
+ }
1359
+
1360
+ .frontmatter-source-new-page:hover {
1361
+ background: color-mix(in srgb, var(--accent) 6%, var(--surface));
1362
+ }
1363
+
1364
+ .badge.new-page {
1365
+ color: var(--accent);
1366
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
1367
+ }
1368
+
1369
+ .frontmatter-source-empty {
1370
+ padding: 1rem;
1371
+ color: var(--muted);
1372
+ }
1373
+
1286
1374
  .confirm-dialog {
1287
1375
  width: min(420px, calc(100vw - 2rem));
1288
1376
  padding: 0;
package/src/server.js CHANGED
@@ -467,7 +467,7 @@ app.post("/api/scan", async (req, res) => {
467
467
  });
468
468
 
469
469
  app.post("/api/entry", async (req, res) => {
470
- const { projectPath, parentPath = "", name } = req.body ?? {};
470
+ const { projectPath, parentPath = "", name, frontmatter } = req.body ?? {};
471
471
 
472
472
  if (!projectPath || typeof projectPath !== "string") {
473
473
  return res.status(400).json({ error: "A project path is required" });
@@ -531,11 +531,20 @@ app.post("/api/entry", async (req, res) => {
531
531
 
532
532
  try {
533
533
  if (isMarkdownFileName(nameResult.name)) {
534
- const initialContent = await buildInitialFileContent(
535
- resolvedProject,
536
- targetRelativePath.replace(/\\/g, "/"),
537
- source,
538
- );
534
+ let initialContent;
535
+ const trimmedFrontmatter =
536
+ typeof frontmatter === "string" ? frontmatter.trim() : "";
537
+
538
+ if (trimmedFrontmatter) {
539
+ initialContent = `---\n${trimmedFrontmatter}\n---\n`;
540
+ } else {
541
+ initialContent = await buildInitialFileContent(
542
+ resolvedProject,
543
+ targetRelativePath.replace(/\\/g, "/"),
544
+ source,
545
+ );
546
+ }
547
+
539
548
  await fs.writeFile(targetResolved.target, initialContent, "utf8");
540
549
  const ext = path.extname(nameResult.name).toLowerCase().slice(1);
541
550
 
@@ -1033,6 +1042,8 @@ app.get("/api/media/file", async (req, res) => {
1033
1042
  res.sendFile(resolved.target);
1034
1043
  });
1035
1044
 
1045
+ export { app };
1046
+
1036
1047
  export function startServer(options = {}) {
1037
1048
  const port = options.port ?? PORT;
1038
1049