Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
526 changes: 523 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"dev:installer": "tauri dev -- -- --install",
"build": "vite build",
"preview": "vite preview",
"test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts",
"test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
Expand All @@ -28,7 +30,8 @@
"mermaid": "^11.12.2",
"monaco-editor": "^0.55.1",
"monaco-vim": "^0.4.4",
"node-stream-zip": "^1.15.0"
"node-stream-zip": "^1.15.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
Expand All @@ -37,6 +40,7 @@
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tsx": "^4.22.4",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
Expand Down
51 changes: 51 additions & 0 deletions scripts/editorToolbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import {
DEFAULT_EDITOR_TOOLBAR_ORDER,
getEditorToolbarAdjacentMove,
getEditorToolbarReorderMove,
getVisibleEditorToolbarTools,
normalizeEditorToolbarHidden,
normalizeEditorToolbarOrder,
} from '../src/lib/utils/editorToolbar.js';

test('normalizeEditorToolbarOrder drops unknown ids, deduplicates, and appends new defaults', () => {
assert.deepEqual(
normalizeEditorToolbarOrder([
'fmt-heading-1',
'unknown-tool',
'fmt-bold',
'fmt-heading-1',
]),
[
'fmt-heading-1',
'fmt-bold',
...DEFAULT_EDITOR_TOOLBAR_ORDER.filter((id) => id !== 'fmt-heading-1' && id !== 'fmt-bold'),
],
);
});

test('normalizeEditorToolbarHidden keeps only known toolbar ids', () => {
assert.deepEqual(
normalizeEditorToolbarHidden(['fmt-bold', 'unknown-tool', 'fmt-italic']),
['fmt-bold', 'fmt-italic'],
);
});

test('getVisibleEditorToolbarTools applies saved order and hidden ids', () => {
const tools = getVisibleEditorToolbarTools(['fmt-italic', 'fmt-bold'], ['fmt-bold']);

assert.equal(tools[0]?.id, 'fmt-italic');
assert.equal(tools.some((tool) => tool.id === 'fmt-bold'), false);
assert.equal(tools.length, DEFAULT_EDITOR_TOOLBAR_ORDER.length - 1);
});

test('toolbar reorder helpers resolve drag and keyboard moves', () => {
const order = ['fmt-bold', 'fmt-italic', 'fmt-link'];

assert.deepEqual(getEditorToolbarReorderMove(order, 'fmt-link', 'fmt-bold'), { fromIndex: 2, toIndex: 0 });
assert.deepEqual(getEditorToolbarAdjacentMove(order, 'fmt-italic', 'down'), { fromIndex: 1, toIndex: 2 });
assert.equal(getEditorToolbarReorderMove(order, 'fmt-bold', 'fmt-bold'), null);
assert.equal(getEditorToolbarAdjacentMove(order, 'fmt-bold', 'up'), null);
});
58 changes: 58 additions & 0 deletions scripts/exportHtml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { parseFrontMatter } from '../src/lib/utils/frontMatter.js';
import {
normalizeAssetPath,
renderStaticFrontMatterPanel,
resolveExportImagePath,
rewriteMarkdownHrefForExport,
} from '../src/lib/utils/exportHtml.js';

test('normalizeAssetPath decodes Tauri asset URLs for Windows drive paths and UNC paths', () => {
assert.equal(
normalizeAssetPath('asset://localhost/C:/Users/Daniel/My%20Pictures/diagram.png'),
'C:/Users/Daniel/My Pictures/diagram.png',
);
assert.equal(
normalizeAssetPath('asset://localhost/%5C%5Cserver%5Cshare%5Cimage.png'),
'\\\\server\\share\\image.png',
);
});

test('resolveExportImagePath preserves remote/data images and resolves local paths cross-platform', () => {
assert.equal(resolveExportImagePath('https://example.test/a.png', 'C:\\Docs\\note.md'), null);
assert.equal(resolveExportImagePath('data:image/png;base64,abc', '/home/daniel/note.md'), null);
assert.equal(resolveExportImagePath('img/a%20b.png', 'C:\\Docs\\note.md'), 'C:/Docs/img/a b.png');
assert.equal(resolveExportImagePath('./img/a.png', '/home/daniel/docs/note.md'), '/home/daniel/docs/img/a.png');
assert.equal(resolveExportImagePath('/home/daniel/assets/a.png', '/home/daniel/docs/note.md'), '/home/daniel/assets/a.png');
});

test('rewriteMarkdownHrefForExport rewrites local Markdown links and preserves query/hash', () => {
assert.equal(rewriteMarkdownHrefForExport('notes/Plan%20A.md#todo'), 'notes/Plan%20A.html#todo');
assert.equal(rewriteMarkdownHrefForExport('../x/readme.markdown?raw=1#top'), '../x/readme.html?raw=1#top');
assert.equal(rewriteMarkdownHrefForExport('C:/Docs/spec.mdown#sec'), 'C:/Docs/spec.html#sec');
assert.equal(rewriteMarkdownHrefForExport('https://example.test/a.md'), 'https://example.test/a.md');
assert.equal(rewriteMarkdownHrefForExport('mailto:test@example.test'), 'mailto:test@example.test');
});

test('renderStaticFrontMatterPanel exports a collapsed, non-interactive properties block', () => {
const parsed = parseFrontMatter(`---
type: plan
keywords: [logger, synlog]
draft: false
---

# Body
`);

const html = renderStaticFrontMatterPanel(parsed);

assert.match(html, /<details class="frontmatter-panel export-frontmatter-panel">/);
assert.match(html, /<summary class="frontmatter-summary">/);
assert.match(html, /<span class="frontmatter-title">Properties<\/span>/);
assert.match(html, /<span class="frontmatter-tag">logger<\/span>/);
assert.match(html, /<span class="frontmatter-tag">synlog<\/span>/);
assert.doesNotMatch(html, /\sopen(?:\s|>)/);
assert.doesNotMatch(html, /<(?:input|button|textarea|select)\b/i);
});
71 changes: 71 additions & 0 deletions scripts/exportOpenPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { askToOpenExportedFile } from '../src/lib/utils/openExportedFile.js';

test('askToOpenExportedFile opens the exported file after user confirmation', async () => {
const opened: string[] = [];
const asked: Array<{ message: string; options?: unknown }> = [];

const result = await askToOpenExportedFile('C:/Docs/export.html', 'HTML', {
ask: async (message, options) => {
asked.push({ message, options });
return true;
},
openPath: async (path) => {
opened.push(path);
},
labels: {
title: 'Open exported file?',
message: 'Open exported HTML file now?',
},
});

assert.equal(result, 'opened');
assert.deepEqual(opened, ['C:/Docs/export.html']);
assert.equal(asked[0]?.message, 'Open exported HTML file now?');
assert.deepEqual(asked[0]?.options, {
title: 'Open exported file?',
kind: 'info',
});
});

test('askToOpenExportedFile leaves the file closed when user declines', async () => {
const opened: string[] = [];

const result = await askToOpenExportedFile('/tmp/export.html', 'HTML', {
ask: async () => false,
openPath: async (path) => {
opened.push(path);
},
labels: {
title: 'Open exported file?',
message: 'Open exported HTML file now?',
},
});

assert.equal(result, 'declined');
assert.deepEqual(opened, []);
});

test('askToOpenExportedFile reports opener failures without throwing', async () => {
const errors: unknown[] = [];

const result = await askToOpenExportedFile('/tmp/export.html', 'HTML', {
ask: async () => true,
openPath: async () => {
throw new Error('no default app');
},
onError: (error) => {
errors.push(error);
},
labels: {
title: 'Open exported file?',
message: 'Open exported HTML file now?',
},
});

assert.equal(result, 'failed');
assert.equal(errors.length, 1);
assert.match(String(errors[0]), /no default app/);
});
124 changes: 124 additions & 0 deletions scripts/frontMatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import {
addFrontMatterListItem,
addFrontMatterListItems,
getMarkdownBodyWithoutFrontMatter,
getFrontMatterListItems,
parseFrontMatter,
removeFrontMatterListItem,
updateFrontMatterListItem,
updateFrontMatterField,
} from '../src/lib/utils/frontMatter.js';

const planMarkdown = `---
type: plan
name: dflex-logger-onoff-dir-synlog
title: "Sterowanie loggerami dFLEX: on/off domyslnie OFF"
project: "dflex"
keywords: [logger, synlog, appconfig, onoff, codesite]
created: 2026-06-26T18:29:35+02:00
status: in-progress
---

## Summary

Body text.
`;

test('parseFrontMatter extracts top-level YAML metadata and body', () => {
const parsed = parseFrontMatter(planMarkdown);

assert.equal(parsed.exists, true);
assert.equal(parsed.valid, true);
assert.equal(parsed.body, '## Summary\n\nBody text.\n');
assert.deepEqual(
parsed.fields.map((field) => [field.key, field.kind, field.displayValue]),
[
['type', 'string', 'plan'],
['name', 'string', 'dflex-logger-onoff-dir-synlog'],
['title', 'string', 'Sterowanie loggerami dFLEX: on/off domyslnie OFF'],
['project', 'string', 'dflex'],
['keywords', 'list', 'logger, synlog, appconfig, onoff, codesite'],
['created', 'string', '2026-06-26T18:29:35+02:00'],
['status', 'string', 'in-progress'],
],
);
});

test('parseFrontMatter ignores thematic breaks outside the first document line', () => {
const markdown = `# Title

---

Body`;
const parsed = parseFrontMatter(markdown);

assert.equal(parsed.exists, false);
assert.equal(parsed.body, markdown);
assert.equal(getMarkdownBodyWithoutFrontMatter(markdown), markdown);
});

test('parseFrontMatter strips invalid front matter from rendered body but keeps the error', () => {
const parsed = parseFrontMatter(`---
title: [broken
---

# Body
`);

assert.equal(parsed.exists, true);
assert.equal(parsed.valid, false);
assert.match(parsed.error || '', /Flow sequence|unexpected|end/i);
assert.equal(parsed.body, '# Body\n');
assert.deepEqual(parsed.fields, []);
});

test('updateFrontMatterField updates one property without changing markdown body', () => {
const updated = updateFrontMatterField(planMarkdown, 'status', 'done');
const parsed = parseFrontMatter(updated);

assert.equal(parsed.exists, true);
assert.equal(parsed.valid, true);
assert.equal(parsed.fields.find((field) => field.key === 'status')?.displayValue, 'done');
assert.equal(parsed.body, '## Summary\n\nBody text.\n');
});

test('updateFrontMatterField preserves CRLF documents', () => {
const markdown = '---\r\nname: old\r\ntags:\r\n - one\r\n---\r\n\r\nText\r\n';
const updated = updateFrontMatterField(markdown, 'name', 'new');

assert.match(updated, /^---\r\nname: new\r\ntags:/);
assert.equal(parseFrontMatter(updated).body, 'Text\r\n');
});

test('getFrontMatterListItems returns string tag values for YAML lists', () => {
const parsed = parseFrontMatter(planMarkdown);
const keywords = parsed.fields.find((field) => field.key === 'keywords');

assert.ok(keywords);
assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog', 'appconfig', 'onoff', 'codesite']);
});

test('front matter tag helpers add, edit, and remove tags predictably', () => {
const original = ['logger', 'synlog'];

assert.deepEqual(addFrontMatterListItem(original, ' appconfig '), ['logger', 'synlog', 'appconfig']);
assert.deepEqual(addFrontMatterListItem(original, 'logger'), ['logger', 'synlog']);
assert.deepEqual(addFrontMatterListItems(original, [' ', 'synlog', 'codesite, onoff']), ['logger', 'synlog', 'codesite', 'onoff']);
assert.deepEqual(updateFrontMatterListItem(original, 1, ' syslog '), ['logger', 'syslog']);
assert.deepEqual(updateFrontMatterListItem(original, 1, 'logger'), ['logger', 'synlog']);
assert.deepEqual(updateFrontMatterListItem(original, 1, ' '), ['logger', 'synlog']);
assert.deepEqual(removeFrontMatterListItem(original, 0), ['synlog']);
});

test('updateFrontMatterField writes edited keywords as a YAML list', () => {
const updated = updateFrontMatterField(planMarkdown, 'keywords', ['logger', 'synlog']);
const parsed = parseFrontMatter(updated);
const keywords = parsed.fields.find((field) => field.key === 'keywords');

assert.ok(keywords);
assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog']);
assert.equal(parsed.body, '## Summary\n\nBody text.\n');
});
50 changes: 50 additions & 0 deletions scripts/reloadOpenToolbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');
const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8');
const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8');
const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8');

test('manual reload is wired through menu, titlebar, and F5 with dirty-state protection', () => {
assert.match(markdownViewer, /async function reloadFromDisk\(\)/);
assert.match(markdownViewer, /await canCloseTab\(activeId\)/);
assert.match(markdownViewer, /loadMarkdown\(tab\.path,\s*\{[\s\S]*preserveEditState:\s*true/);
assert.match(markdownViewer, /code === 'F5'[\s\S]*reloadFromDisk\(\)/);
assert.match(markdownViewer, /listen\('menu-file-reload'[\s\S]*reloadFromDisk\(\)/);
assert.match(titleBar, /onreloadFromDisk/);
assert.match(titleBar, /id === 'reload'/);
assert.match(tauriLib, /menu-file-reload/);
assert.match(tauriLib, /\.accelerator\("F5"\)/);
});

test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco without double-handling macOS native menu', () => {
assert.match(markdownViewer, /settings\.osType === 'macos'[\s\S]*key === 'o'[\s\S]*return/);
assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/);
});

test('editor toolbar delegates to Monaco actions and exposes expected Markdown commands', () => {
assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/);
assert.match(markdownViewer, /<EditorToolbar[\s\S]*onaction=\{\(actionId\) => editorPane\?\.runEditorAction\(actionId\)\}/);
assert.match(editor, /export function runEditorAction\(actionId: string\)/);

for (const actionId of [
'fmt-bold',
'fmt-italic',
'fmt-underline',
'fmt-inline-code',
'fmt-code-block',
'fmt-quote',
'fmt-heading-1',
'fmt-heading-2',
'fmt-heading-3',
'fmt-bullet-list',
'fmt-numbered-list',
'fmt-checklist',
'fmt-link',
'insert-table-simple',
]) {
assert.match(editor, new RegExp(`id: "${actionId}"`));
}
});
Loading
Loading