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 +30 -0
- package/package.json +7 -2
- package/public/app.js +514 -14
- package/public/frontmatter.js +35 -0
- package/public/styles.css +88 -0
- package/src/server.js +17 -6
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.
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
1711
|
-
resetEditorHistory(
|
|
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
|
-
|
|
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
|
|
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">×</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",
|
|
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 {
|
|
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;
|
package/public/frontmatter.js
CHANGED
|
@@ -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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|