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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.40.0",
"version": "2.41.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
"version": "2.40.0",
"version": "2.41.0",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.40.0",
"version": "2.41.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/export-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc'
import { LazyPreview as Preview } from '@renderer/components/LazyPreview'
import { useStore } from '@renderer/store'
import { withExportTitle } from '@shared/export-title'
import '@renderer/styles/index.css'

const PREFS_KEY = 'zen:prefs:v2'
Expand Down Expand Up @@ -136,7 +137,10 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
selectedPath: noteContent.path,
activeNote: noteContent
})
document.title = `${noteContent.title}.pdf`
// The browser's print dialog seeds the filename from document.title, so
// it takes the resolved export title (frontmatter `title:` beats the
// filename) rather than the note's filename alone.
document.title = `${withExportTitle(noteContent.body, noteContent.title).title}.pdf`
setNote(noteContent)
} catch (err) {
if (cancelled) return
Expand Down Expand Up @@ -244,7 +248,11 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
}
`}</style>
<main className="export-note-shell">
<Preview markdown={note.body} notePath={note.path} onRendered={() => void triggerPrint()} />
<Preview
markdown={withExportTitle(note.body, note.title).markdown}
notePath={note.path}
onRendered={() => void triggerPrint()}
/>
</main>
</>
)
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
"version": "2.40.0",
"version": "2.41.0",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
"version": "2.40.0",
"version": "2.41.0",
"type": "module",
"exports": {
"./main": "./src/main.tsx"
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2472,7 +2472,7 @@ export function SettingsModal(): JSX.Element {
/>
<ToggleRow
label="Auto-close Markdown"
description="Auto-close markdown as you type: ** / __ / ~~ / ` / == / [[ / %% then Space wrap the cursor, and ``` / ~~~ / $$ then Enter expand a fenced block. In Vim mode this only applies in insert mode."
description="Auto-close markdown as you type: ** / __ / ~~ / ` / == / [[ / %% then Space wrap the cursor, and ``` / ~~~ / $$ then Enter expand a fenced block. Never fires inside a code block or inline code, so a == comparison in code stays code. In Vim mode this only applies in insert mode."
value={markdownSnippets}
settingId="markdown-overrides"
onChange={setMarkdownSnippets}
Expand Down
66 changes: 66 additions & 0 deletions packages/app-core/src/lib/cm-markdown-snippets.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { EditorState } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { ensureSyntaxTree } from '@codemirror/language'
import { markdownSnippetExtension, markdownSnippetTransaction } from './cm-markdown-snippets'

function createState(doc: string, pos = doc.length): EditorState {
Expand Down Expand Up @@ -186,3 +188,67 @@ describe('block snippets inside list items (#405)', () => {
expect(state?.doc.toString()).toBe('- ```bash\n x\n ```\n- ```\n \n ```')
})
})

// #718: no snippet may fire inside code. The plain harness above has no
// markdown parser, so its states carry no FencedCode/InlineCode nodes; this
// one parses the doc the way the real editor does.
describe('markdownSnippetTransaction inside code (#718)', () => {
function createParsedState(doc: string, pos = doc.length): EditorState {
const state = EditorState.create({
doc,
selection: { anchor: pos },
extensions: [markdownSnippetExtension(), markdown({ base: markdownLanguage })]
})
ensureSyntaxTree(state, state.doc.length, 5000)
return state
}

function parsedTrigger(doc: string, typed: string, key: string, pos = doc.length): EditorState | null {
let state = createParsedState(doc, pos)
for (const char of typed) {
const head = state.selection.main.head
state = state.update({
changes: { from: head, to: head, insert: char },
selection: { anchor: head + 1 },
userEvent: 'input.type'
}).state
}
ensureSyntaxTree(state, state.doc.length, 5000)
const transaction = markdownSnippetTransaction(state, key)
return transaction ? state.update(transaction).state : null
}

it('== followed by Space in a fenced code block stays code', () => {
// The report: typing a comparison in a code block wrapped == into a
// highlight pair the moment Space followed it.
expect(parsedTrigger('```js\nif (a ==', '', 'Space')).toBeNull()
})

it('other inline pairs stay quiet in a fenced block too', () => {
expect(parsedTrigger('```\nbold **', '', 'Space')).toBeNull()
expect(parsedTrigger('```\nstrike ~~', '', 'Space')).toBeNull()
expect(parsedTrigger('```\ntick `', '', 'Space')).toBeNull()
expect(parsedTrigger('```\nlink [[', '', 'Space')).toBeNull()
})

it('== inside an inline code span stays code', () => {
const doc = '`a ==` rest'
expect(parsedTrigger(doc, '', 'Space', doc.indexOf('`', 1))).toBeNull()
})

it('$$ typed inside an open fence does not become a math block', () => {
expect(parsedTrigger('```\n', '$$', 'Enter')).toBeNull()
})

it('a fence opener still expands with Enter when the parser is live', () => {
// The opener line parses as an unclosed FencedCode the moment it is typed;
// it must stay exempt or the Enter-to-close feature dies with the fix.
const state = parsedTrigger('', '```', 'Enter')
expect(state?.doc.toString()).toBe('```\n\n```')
})

it('== after a closed code block fires again', () => {
const state = parsedTrigger('```\nx\n```\n\n==', '', 'Space')
expect(state?.doc.toString()).toBe('```\nx\n```\n\n====')
})
})
39 changes: 39 additions & 0 deletions packages/app-core/src/lib/cm-markdown-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type TransactionSpec
} from '@codemirror/state'
import { keymap, type EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'

export type MarkdownSnippetMode = 'inline' | 'block'

Expand Down Expand Up @@ -157,6 +158,10 @@ function blockSnippetTransaction(
if (line.from !== pending.lineFrom || selection.head !== line.to) return null
if (!isBlockOpenerLine(rule, line.text)) return null
if (hasUnclosedBlockOpenerAbove(state, line.number, rule)) return null
// A `$$` (or nested fence marker) typed inside someone else's code block is
// code content, not an opener; this line's own unclosed fence is the one
// legitimate case (#718).
if (isInsideCode(state, selection.head, line.number)) return null

// Align to the fence column so a block opened inside a list item keeps its
// content and closing fence inside the item instead of escaping to col 0 (#405).
Expand All @@ -170,6 +175,36 @@ function blockSnippetTransaction(
}
}

/**
* True when `pos` sits in code the parser already recognizes: a fenced block or
* an inline code span. Markdown formatting means nothing there, so no snippet
* may fire; `==` in a code block used to wrap into a highlight pair the moment
* a comparison was followed by a space (#718).
*
* A fence's own opener line is exempt for block rules, via
* `exceptFenceOpenedAtLine`: typing ``` parses as an unclosed FencedCode
* immediately, and that line is exactly what the Enter-to-close snippet is
* for. Only checked at trigger time, on a settled state, so the tree is
* current rather than mid-update.
*/
function isInsideCode(
state: EditorState,
pos: number,
exceptFenceOpenedAtLine?: number
): boolean {
let node = syntaxTree(state).resolveInner(pos, -1)
while (node) {
if (node.name === 'InlineCode') return true
if (node.name === 'FencedCode') {
const openerLine = state.doc.lineAt(node.from).number
return exceptFenceOpenedAtLine == null || openerLine !== exceptFenceOpenedAtLine
}
if (!node.parent) break
node = node.parent
}
return false
}

function hasOddBackslashRun(text: string, before: number): boolean {
let count = 0
for (let i = before - 1; i >= 0 && text[i] === '\\'; i--) count++
Expand Down Expand Up @@ -240,6 +275,10 @@ export function markdownSnippetTransaction(
}
}

// Inside a code block or inline code span, `==`, `**`, a backtick and the
// rest are code, and wrapping them in formatting pairs is never right (#718).
if (isInsideCode(state, selection.head)) return null

for (const rule of rules) {
if (rule.mode === 'block') continue
if (!rule.triggerKeys.includes(triggerKey)) continue
Expand Down
Loading
Loading