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
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tauri-apps/cli": "^2",
"@types/node": "^24.13.3",
"svelte": "^5.56.8",
"svelte-check": "^4.7.4",
"tsx": "^4.22.4",
Expand Down
20 changes: 7 additions & 13 deletions scripts/findCollapsedMatches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,17 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

const findBar = readFileSync('src/lib/components/FindBar.svelte', 'utf8');
import { sliceBetween } from './sourceTree.js';

function slice(source: string, start: string, end: string): string {
const from = source.indexOf(start);
assert.notEqual(from, -1, `expected to find ${start}`);
const to = source.indexOf(end, from + start.length);
assert.notEqual(to, -1, `expected to find ${end} after ${start}`);
return source.slice(from, to);
}
const findBar = readFileSync('src/lib/components/FindBar.svelte', 'utf8');

test('find keeps counting matches inside collapsed folds', () => {
// Reference systems all keep hidden-but-present text findable and reveal
// it on a hit: VS Code unfolds the region it jumps into, and Chrome makes
// `hidden=until-found` / closed `<details>` content matchable precisely
// because it can reveal it. Dropping the matches from the count would be
// the `display: none` rule, which is not what a collapsed fold is.
const acceptNode = slice(findBar, 'function isHostElement(', 'function isInsideHost(');
const acceptNode = sliceBetween(findBar, 'function isHostElement(', 'function isInsideHost(');

assert.doesNotMatch(acceptNode, /is-collapsed/);
assert.doesNotMatch(acceptNode, /foldable-content-wrapper/);
Expand All @@ -31,7 +25,7 @@ test('find knows both kinds of collapsed fold container', () => {
});

test('activating a match reveals the folds hiding it before scrolling to it', () => {
const setActive = slice(findBar, 'function setActive(', 'export function next()');
const setActive = sliceBetween(findBar, 'function setActive(', 'export function next()');

const reveal = setActive.indexOf('revealFoldsAround(');
const scroll = setActive.indexOf('scrollIntoView(');
Expand All @@ -46,7 +40,7 @@ test('revealing a fold goes through the viewer toggle instead of stripping the c
// fold state: it re-applies `is-collapsed` on every re-render and feeds
// the ToC. Clearing the class here would create a second source of truth
// that the next render silently reverts.
const reveal = slice(findBar, 'function foldToggleFor(', 'export function clearHighlights()');
const reveal = sliceBetween(findBar, 'function foldToggleFor(', 'export function clearHighlights()');

assert.match(reveal, /\.header-fold-icon/);
assert.match(reveal, /\.callout-toggle/);
Expand All @@ -57,7 +51,7 @@ test('revealing a fold goes through the viewer toggle instead of stripping the c
});

test('nested folds are opened outermost first', () => {
const reveal = slice(findBar, 'function revealFoldsAround(', 'export function clearHighlights()');
const reveal = sliceBetween(findBar, 'function revealFoldsAround(', 'export function clearHighlights()');

assert.match(reveal, /while \(curr && curr !== root\)/);
assert.match(reveal, /collapsed\.reverse\(\)/);
Expand All @@ -66,7 +60,7 @@ test('nested folds are opened outermost first', () => {
test('the scroll is re-aimed once the fold height transition has settled', () => {
// styles.css animates `.foldable-content-wrapper` height for 0.25s, so the
// first scrollIntoView aims at a target that is still moving.
const setActive = slice(findBar, 'function setActive(', 'export function next()');
const setActive = sliceBetween(findBar, 'function setActive(', 'export function next()');

assert.match(setActive, /setTimeout\(/);
assert.match(setActive, /FOLD_TRANSITION_MS/);
Expand Down
30 changes: 19 additions & 11 deletions scripts/issue261EditorPdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import { sliceBetween, sliceFrom } from './sourceTree.js';

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');
const styles = readFileSync('src/styles.css', 'utf8');

test('editor context menu is not intercepted by the document menu', () => {
const start = viewer.indexOf('function handleContextMenu(e: MouseEvent)');
const handler = viewer.slice(start, viewer.indexOf('\n\tfunction handleMouseOver', start));
const handler = sliceBetween(viewer, 'function handleContextMenu(e: MouseEvent)', '\n\tfunction handleMouseOver');
const editorReturn = handler.indexOf('if (isInsideEditor) return;');
const preventDefault = handler.indexOf('e.preventDefault();');

Expand Down Expand Up @@ -45,10 +46,7 @@ test('print layout lets the viewer pane escape the interactive split flex ratio'
});

test('floating toc toggle keeps a visible translucent surface outside edit mode', () => {
const selector = viewer.slice(
viewer.indexOf('\t.toc-toggle-floating {'),
viewer.indexOf('\n\t.toc-toggle-floating.expanded {'),
);
const selector = sliceBetween(viewer, '\t.toc-toggle-floating {', '\n\t.toc-toggle-floating.expanded {');

assert.match(selector, /background-color:\s*color-mix\(in srgb, var\(--color-canvas-default\) 82%, transparent\);/);
assert.match(selector, /border:\s*1px solid var\(--color-border-default\);/);
Expand All @@ -59,7 +57,7 @@ test('floating toc toggle keeps a visible translucent surface outside edit mode'
});

test('print layout gives document content paper-specific rhythm and boundaries', () => {
const printStyles = styles.slice(styles.indexOf('@media print {'));
const printStyles = sliceFrom(styles, '@media print {');

assert.match(printStyles, /\.markdown-body h1,[\s\S]*?line-height:\s*1\.2;/);
assert.match(printStyles, /\.markdown-body p\s*\{[\s\S]*?margin:\s*0 0 0\.75em;/);
Expand All @@ -72,8 +70,7 @@ test('print layout gives document content paper-specific rhythm and boundaries',
});

test('print layout paints a theme-independent page and keeps Markdown alerts intact', () => {
const printStyles = styles.slice(styles.indexOf('@media print {'));
const viewerPrintStyles = viewer.slice(viewer.indexOf('\t@media print {'));
const printStyles = sliceFrom(styles, '@media print {');

assert.match(printStyles, /@page\s*\{[\s\S]*?margin:\s*0;/);
assert.match(printStyles, /\.markdown-body\s*\{[\s\S]*?box-sizing:\s*border-box\s*!important;/);
Expand All @@ -88,11 +85,22 @@ test('print layout paints a theme-independent page and keeps Markdown alerts int
assert.match(printStyles, /\.markdown-body \.markdown-alert\s*\{[\s\S]*?box-decoration-break:\s*clone\s*!important;/);
assert.match(printStyles, /\.markdown-body details\.markdown-alert\s*\{[\s\S]*?break-inside:\s*avoid\s*!important;/);
assert.match(printStyles, /\.markdown-body tr\s*\{[\s\S]*?break-inside:\s*avoid;/);
assert.doesNotMatch(viewerPrintStyles, /\.markdown-body\s*\{[\s\S]*?padding:\s*0\s*!important;/);

// The paper padding above is set globally, so a component-scoped print rule
// in MarkdownViewer.svelte would beat it: Svelte adds a hash class to its
// own selectors, and `.markdown-body.svelte-xxxx { padding: 0 !important }`
// outranks `.markdown-body { padding: 0.75in !important }`. Exactly that
// rule lived in the component until 37b3693 removed it.
//
// This is searched over the whole component rather than over a slice from
// `@media print {`: the component now has no print block at all, so slicing
// to that marker sliced from -1 — the last character of the file — and the
// assertion could not fail for the entire time it existed.
assert.doesNotMatch(viewer, /@media print\s*\{[\s\S]*?\.markdown-body\s*\{[\s\S]*?padding:\s*0\s*!important;/);
});

test('print layout keeps wide metadata tables readable', () => {
const printStyles = styles.slice(styles.indexOf('@media print {'));
const printStyles = sliceFrom(styles, '@media print {');

// Diagram colours are no longer patched from CSS — the export re-renders
// them with Mermaid's light theme instead (see utils/mermaidPrint.ts).
Expand Down
26 changes: 10 additions & 16 deletions scripts/lossyDecodeSaveGuard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from 'node:test';

import type { Tab } from '../src/lib/stores/tabs.svelte.js';
import { buildTransferredTab, snapshotTab, validateTransferPayload } from '../src/lib/utils/tabTransfer.js';
import { sliceBetween } from './sourceTree.js';

// Every read path decodes leniently (#371): a file in a legacy encoding
// (GBK, Big5, Shift-JIS, EUC-KR, CP1251 ...) opens as U+FFFD mojibake instead
Expand All @@ -24,18 +25,10 @@ const windowSession = readFileSync('src/lib/sessions/windowSession.svelte.ts', '
const tabs = readFileSync('src/lib/stores/tabs.svelte.ts', 'utf8');
const rust = readFileSync('src-tauri/src/lib.rs', 'utf8');

function slice(source: string, start: string, end: string): string {
const from = source.indexOf(start);
assert.notEqual(from, -1, `expected to find ${start}`);
const to = source.indexOf(end, from + start.length);
assert.notEqual(to, -1, `expected to find ${end} after ${start}`);
return source.slice(from, to);
}

const loadMarkdown = () => slice(session, 'async function loadMarkdown', 'async function saveContent');
const saveContent = () => slice(session, 'async function saveContent(', 'async function saveContentAs');
const saveContentAs = () => slice(session, 'async function saveContentAs', 'async function toggleTaskCheckbox');
const guard = () => slice(session, 'function refuseIfLossilyDecoded', 'function updateLoading');
const loadMarkdown = () => sliceBetween(session, 'async function loadMarkdown', 'async function saveContent');
const saveContent = () => sliceBetween(session, 'async function saveContent(', 'async function saveContentAs');
const saveContentAs = () => sliceBetween(session, 'async function saveContentAs', 'async function toggleTaskCheckbox');
const guard = () => sliceBetween(session, 'function refuseIfLossilyDecoded', 'function updateLoading');

test('one decoder, and it reports what it destroyed', () => {
// The fact is known exactly once — when the bytes are decoded — and was
Expand All @@ -57,7 +50,7 @@ test('a preview cut inside a multi-byte character is not called lossy', () => {
// The preview stops at a fixed byte count, so a perfectly valid UTF-8 file
// is routinely cut mid-character. Reporting that as lossy would lock every
// large CJK/emoji document out of saving — a guard worse than the bug.
const preview = slice(rust, 'fn build_markdown_preview', '#[tauri::command]');
const preview = sliceBetween(rust, 'fn build_markdown_preview', '#[tauri::command]');
const trim = preview.indexOf('utf8_truncation_boundary');
assert.notEqual(trim, -1, 'the split tail must be dropped before decoding');
assert.ok(trim < preview.indexOf('decode_utf8_lossy'), 'trim first, then judge fidelity');
Expand Down Expand Up @@ -101,11 +94,11 @@ test('the editable-pane shortcut reports fidelity too', () => {
const bare = session.match(/invoke\('read_file_content'/g)?.length ?? 0;
assert.equal(bare, 0, 'no writable buffer may be filled by the unchecked command');
assert.match(
slice(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'),
sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'),
/invoke\('read_file_content_checked'/,
);
assert.match(
slice(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'),
sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'),
/setTabDecodedLossy\(tabId, lossy\)/,
);
});
Expand Down Expand Up @@ -216,6 +209,7 @@ function makeTab(overrides: Partial<Tab> = {}): Tab {
splitRatio: 0.5,
isScrollSynced: false,
hasReplacementChars: true,
collapsedHeaders: new Set<string>(),
...overrides,
};
}
Expand Down Expand Up @@ -243,7 +237,7 @@ test('a payload without the flag is rejected, not defaulted', () => {
// The file's own doctrine: no coercion, no defaults. Defaulting a missing
// flag to false would silently re-open the hole for any sender that
// forgets it.
const snap = snapshotTab(makeTab()) as Record<string, unknown>;
const snap: Record<string, unknown> = { ...snapshotTab(makeTab()) };
delete snap.hasReplacementChars;
assert.equal(validateTransferPayload(JSON.stringify(snap)), null);
});
7 changes: 5 additions & 2 deletions scripts/mermaidPrintTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ function makeMermaid(behaviour: (source: string) => string) {
};
}

/** The fake seen as the `Element` the helper's signature asks for. */
const asElement = (element: FakeElement) => element as unknown as Element;

function diagram(source: string | null, html: string) {
const element = new FakeElement('mermaid-diagram');
if (source !== null) rememberDiagramSource(element, source);
if (source !== null) rememberDiagramSource(asElement(element), source);
element.innerHTML = html;
return element;
}
Expand All @@ -75,7 +78,7 @@ test('the diagram theme follows the app appearance', () => {

test('the source is kept on the container so the diagram can be rebuilt', () => {
const element = diagram('flowchart TD\n A --> B', '<svg>screen</svg>');
assert.equal(readDiagramSource(element), 'flowchart TD\n A --> B');
assert.equal(readDiagramSource(asElement(element)), 'flowchart TD\n A --> B');
assert.equal(findRestorableDiagrams(new FakeRoot([element]) as unknown as ParentNode).length, 1);
// A diagram rendered before this change carries no source and is skipped
// rather than blanked.
Expand Down
34 changes: 34 additions & 0 deletions scripts/monaco-internals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// monaco-editor publishes types for its public surface only
// (`monaco-editor/esm/vs/editor/editor.api.d.ts`). The ESM internals ship as
// plain `.js` with no declarations beside them.
//
// pasteUrlContext.test.ts drives Monarch directly — `monaco.editor.tokenize()`
// needs a browser-backed editor, which the Node test runner has no way to
// create — so it reaches for three of those internals. Declared here with the
// shapes that test actually uses, rather than left implicitly `any`.

declare module 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js' {
import type { languages } from 'monaco-editor';

/** Compiles a Monarch language definition into the lexer the tokenizer runs. */
export function compile(languageId: string, json: languages.IMonarchLanguage): unknown;
}

declare module 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js' {
/**
* Constructed with (languageService, themeService, languageId, lexer,
* configurationService). The services are stubs here, so the parameters stay
* `unknown` — only the tokenization surface below is relied on.
*/
export const MonarchTokenizer: new (...args: unknown[]) => {
getInitialState(): unknown;
tokenize(line: string, hasEOL: boolean, state: unknown): { tokens: unknown[]; endState: unknown };
};
}

declare module 'monaco-editor/esm/vs/basic-languages/markdown/markdown.js' {
import type { languages } from 'monaco-editor';

export const conf: languages.LanguageConfiguration;
export const language: languages.IMonarchLanguage;
}
2 changes: 1 addition & 1 deletion scripts/pasteUrlContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function createMarkdownTokenizer() {
onDidChangeConfiguration: () => ({ dispose() {} }),
};

const tokenizer = new (MonarchTokenizer as any)(
const tokenizer = new MonarchTokenizer(
languageService,
themeService,
'markdown',
Expand Down
4 changes: 2 additions & 2 deletions scripts/settingsPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ function createRecordingStore(): { proxy: InstanceType<typeof SettingsStore>; re
const reads = new Set<string>();
resetStorage();
const real = new SettingsStore();
const proxy = new Proxy(real as Record<string, unknown>, {
const proxy = new Proxy(real, {
get(target, property, receiver) {
if (typeof property === 'string') reads.add(property);
return Reflect.get(target, property, receiver);
},
}) as InstanceType<typeof SettingsStore>;
});
return { proxy, reads };
}

Expand Down
36 changes: 36 additions & 0 deletions scripts/sourceTree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

Expand All @@ -14,6 +15,41 @@ import { join } from 'node:path';

export type SourceFile = { path: string; text: string };

/**
* `source` from the first occurrence of `start` onwards.
*
* The assertion is the whole point. `source.slice(source.indexOf(marker))` with
* an absent marker slices from -1, which yields the last *character* of the file
* rather than nothing — so an `assert.doesNotMatch` against the result can never
* fail, and an `assert.match` fails while blaming the wrong thing.
*
* Both instances found in this suite so far were silent for their whole life:
* scrollSyncInput.test.ts (the anchor drifted when the code around it changed)
* and issue261EditorPdf.test.ts (the subject was deleted in the same commit that
* added the assertion). Neither was noticed by a test run, because neither
* failed.
*/
export function sliceFrom(source: string, start: string): string {
const from = source.indexOf(start);
assert.notEqual(from, -1, `expected to find ${JSON.stringify(start)}`);
return source.slice(from);
}

/**
* `source` from the first `start` to the first `end` that follows it.
*
* Searching `end` from the end of `start` rather than from 0 is deliberate: an
* `end` that happens to occur earlier in the file produces an empty string, and
* an empty subject is degenerate for the same reason -1 is.
*/
export function sliceBetween(source: string, start: string, end: string): string {
const from = source.indexOf(start);
assert.notEqual(from, -1, `expected to find ${JSON.stringify(start)}`);
const to = source.indexOf(end, from + start.length);
assert.notEqual(to, -1, `expected to find ${JSON.stringify(end)} after ${JSON.stringify(start)}`);
return source.slice(from, to);
}

/** Every compilable source file under `dir`, with forward-slash paths. */
export function walkSourceFiles(dir: string): string[] {
const out: string[] = [];
Expand Down
Loading
Loading