diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js
index abe0e015a..29d3e1a5a 100644
--- a/helpers/HTMLView.js
+++ b/helpers/HTMLView.js
@@ -5,9 +5,7 @@
// Last updated 2025-05-31 by @jgclark
// ---------------------------------------------------------
import showdown from 'showdown' // for Markdown -> HTML from https://github.com/showdownjs/showdown
-import {
- hasFrontMatter
-} from '@helpers/NPFrontMatter'
+import { hasFrontMatter } from '@helpers/NPFrontMatter'
import { getFolderFromFilename } from '@helpers/folders'
import { clo, logDebug, logError, logInfo, logWarn, JSP, timer } from '@helpers/dev'
import { getStoredWindowRect, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows'
@@ -15,6 +13,7 @@ import { generateCSSFromTheme, RGBColourConvert } from '@helpers/NPThemeToCSS'
import { isTermInEventLinkHiddenPart, isTermInNotelinkOrURI, isTermInMarkdownPath } from '@helpers/paragraph'
import { RE_EVENT_LINK, RE_SYNC_MARKER, formRegExForUsersOpenTasks } from '@helpers/regex'
import { getTimeBlockString, isTimeBlockLine } from '@helpers/timeblocks'
+import { createOpenOrDeleteNoteCallbackUrl } from '@helpers/general'
// ---------------------------------------------------------
// Constants and Types
@@ -101,7 +100,6 @@ export function getCallbackCodeString(jsFunctionName: string, commandName: strin
`
}
-
/**
* Convert a note's content to HTML and include any images as base64
* @param {string} content
@@ -153,6 +151,26 @@ export async function getNoteContentAsHTML(content: string, note: TNote): Promis
}
}
+ // Ensure horizontal rules have a blank line before them (required by showdown)
+ // Track which HRs already had blank lines for extra padding
+ // HR patterns: ---, ___, *** (with optional spaces between)
+ const HR_REGEX = /^(\s*)([-_*])\s*\2\s*\2\s*$/
+ const HR_MARKER = ''
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i].match(HR_REGEX)) {
+ if (lines[i - 1].trim() === '') {
+ // Already has blank line - mark it for extra padding
+ lines[i] = `${HR_MARKER}${lines[i]}`
+ } else {
+ // Insert blank line before HR if previous line isn't already blank
+ lines.splice(i, 0, '')
+ i++ // skip the newly inserted blank line
+ }
+ }
+ }
+
+ lines = addParagraphBreakHints(lines)
+
// Make this proper Markdown -> HTML via showdown library
// Set some options to turn on various more advanced HTML conversions (see actual code at https://github.com/showdownjs/showdown/blob/master/src/options.js#L109):
const converterOptions = {
@@ -164,22 +182,58 @@ export async function getNoteContentAsHTML(content: string, note: TNote): Promis
tasklists: true,
metadata: false, // otherwise metadata is swallowed
requireSpaceBeforeHeadingText: true,
- simpleLineBreaks: true // Makes this GFM style. TODO: make an option?
+ simpleLineBreaks: true, // Makes this GFM style. TODO: make an option?
}
const converter = new showdown.Converter(converterOptions)
let body = converter.makeHtml(lines.join(`\n`))
- body = `${body}` // fix for bug in showdown
-
+
+ // Add CSS for proper spacing and layout
+ const inlineStyles = ``
+ body = inlineStyles + body
+
+ // Replace markers for HRs that had blank lines with classed HRs
+ body = body.replace(/
/g, '')
+
const imgTagRegex = /
'
}
}
-
/**
* This function creates the webkit console.log/error handler for HTML messages to get back to NP console.log
* @returns {string} - the javascript (without a tag)
@@ -566,7 +612,10 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise
try {
const screenWidth = NotePlan.environment.screenWidth
const screenHeight = NotePlan.environment.screenHeight
- logDebug('HTMLView / showHTMLV2', `starting with customId ${opts.customId ?? ''} and reuseUsersWindowRect ${String(opts.reuseUsersWindowRect) ?? '??'} for screen dimensions ${screenWidth}x${screenHeight}`)
+ logDebug(
+ 'HTMLView / showHTMLV2',
+ `starting with customId ${opts.customId ?? ''} and reuseUsersWindowRect ${String(opts.reuseUsersWindowRect) ?? '??'} for screen dimensions ${screenWidth}x${screenHeight}`,
+ )
// Assemble the parts of the HTML into a single string
const fullHTMLStr = assembleHTMLParts(body, opts)
@@ -592,8 +641,8 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise
winOptions = {
x: opts.x ?? (screenWidth - (screenWidth - (opts.paddingWidth ?? 0) * 2)) / 2,
y: opts.y ?? (screenHeight - (screenHeight - (opts.paddingHeight ?? 0) * 2)) / 2,
- width: opts.width ?? (screenWidth - (opts.paddingWidth ?? 0) * 2),
- height: opts.height ?? (screenHeight - (opts.paddingHeight ?? 0) * 2),
+ width: opts.width ?? screenWidth - (opts.paddingWidth ?? 0) * 2,
+ height: opts.height ?? screenHeight - (opts.paddingHeight ?? 0) * 2,
shouldFocus: opts.shouldFocus,
id: cId, // don't need both ... but trying to work out which is the current one for the API
windowId: cId,
@@ -603,7 +652,6 @@ export async function showHTMLV2(body: string, opts: HtmlWindowOptions): Promise
// logDebug('showHTMLV2', `- Trying to use user's saved Rect from pref for ${cId}`)
const storedRect = getStoredWindowRect(cId)
if (storedRect) {
-
winOptions = {
x: storedRect.x,
y: storedRect.y,
@@ -958,18 +1006,18 @@ export function convertBoldAndItalicToHTML(input: string): string {
// of the form ``
export function simplifyNPEventLinksForHTML(input: string): string {
try {
- let output = input
- const captures = output.match(RE_EVENT_LINK)
- if (captures) {
- clo(captures, 'results from NP event link matches:')
- // Matches come in threes (plus full match), so process four at a time
- for (let c = 0; c < captures.length; c = c + 3) {
- const eventLink = captures[c]
- const eventTitle = captures[c + 1]
- const eventColor = captures[c + 2]
- output = output.replace(eventLink, ` ${eventTitle}`)
+ let output = input
+ const captures = output.match(RE_EVENT_LINK)
+ if (captures) {
+ clo(captures, 'results from NP event link matches:')
+ // Matches come in threes (plus full match), so process four at a time
+ for (let c = 0; c < captures.length; c = c + 3) {
+ const eventLink = captures[c]
+ const eventTitle = captures[c + 1]
+ const eventColor = captures[c + 2]
+ output = output.replace(eventLink, ` ${eventTitle}`)
+ }
}
- }
// logDebug('simplifyNPEventLinksForHTML', `{${input}} -> {${output}}`)
return output
} catch (error) {
@@ -982,16 +1030,16 @@ export function simplifyNPEventLinksForHTML(input: string): string {
// (This also helps remove false positives for ! priority indicator)
export function simplifyInlineImagesForHTML(input: string): string {
try {
- let output = input
- const captures = output.match(/!\[image\]\([^\)]+\)/g)
- if (captures) {
- // clo(captures, 'results from embedded image match:')
- for (const capture of captures) {
- // logDebug(`simplifyInlineImagesForHTML`, capture)
- output = output.replace(capture, ` `)
- // logDebug(`simplifyInlineImagesForHTML`, `-> ${output}`)
+ let output = input
+ const captures = output.match(/!\[image\]\([^\)]+\)/g)
+ if (captures) {
+ // clo(captures, 'results from embedded image match:')
+ for (const capture of captures) {
+ // logDebug(`simplifyInlineImagesForHTML`, capture)
+ output = output.replace(capture, ` `)
+ // logDebug(`simplifyInlineImagesForHTML`, `-> ${output}`)
+ }
}
- }
// logDebug('simplifyInlineImagesForHTML', `{${input}} -> {${output}}`)
return output
} catch (error) {
@@ -1020,8 +1068,14 @@ export function convertHashtagsToHTML(input: string): string {
// logDebug('convertHashtagsToHTML', `results from hashtag matches: ${String(matches)}`)
for (const match of matches) {
// logDebug('convertHashtagsToHTML', `- match: ${String(match)}`)
- if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output) || isTermAColorStyleDefinition(match, output)
- ) { continue }
+ if (
+ isTermInNotelinkOrURI(match, output) ||
+ isTermInMarkdownPath(match, output) ||
+ isTermInEventLinkHiddenPart(match, output) ||
+ isTermAColorStyleDefinition(match, output)
+ ) {
+ continue
+ }
output = output.replace(match, `${match}`)
}
}
@@ -1033,9 +1087,8 @@ export function convertHashtagsToHTML(input: string): string {
}
}
-
function isTermAColorStyleDefinition(term: string, input: string): boolean {
- const RE_CSS_STYLE_DEFINITION = new RegExp(`style="color:\\s*${term}"`, "i")
+ const RE_CSS_STYLE_DEFINITION = new RegExp(`style="color:\\s*${term}"`, 'i')
return RE_CSS_STYLE_DEFINITION.test(input)
}
@@ -1052,7 +1105,7 @@ export function convertMentionsToHTML(input: string): string {
// regex from @EduardMe's file
// const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d[:punct:]]+(\s|$))(@([^[:punct:]\s]|[\-_\/])+?\(.*?\)|@([^[:punct:]\s]|[\-_\/])+)/, 'g')
// regex from @EduardMe's file, without [:punct:]
- // const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d\`\"]+(\s|$))(@([^\`\"\s]|[\-_\/])+?\(.*?\)|@([^\`\"\s]|[\-_\/])+)/, 'g')
+ // const RE_MENTION_G = new RegExp(/(\s|^|\"|\'|\(|\[|\{)(?!@[\d\`\"]+(\s|$))(@([^\`\"\s]|[\-_\/])+?\(.*?\)|@([^\`\"\s]|[\-_\/])+)/, 'g')
// now copes with Unicode characters, with help from https://stackoverflow.com/a/74926188/3238281
const RE_MENTION_G = new RegExp(/\B@((?![\p{N}_]+(?:$|\s|\b))(?:[\p{L}\p{M}\p{N}_\/\-]{1,60})(\(.*?\))?)/, 'gu')
const matches = input.match(RE_MENTION_G)
@@ -1060,7 +1113,9 @@ export function convertMentionsToHTML(input: string): string {
// logDebug('convertMentionsToHTML', `results from mention matches: ${String(matches)}`)
for (const match of matches) {
// logDebug('convertMentionsToHTML', `- match: ${String(match)}`)
- if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output)) { continue }
+ if (isTermInNotelinkOrURI(match, output) || isTermInMarkdownPath(match, output) || isTermInEventLinkHiddenPart(match, output)) {
+ continue
+ }
output = output.replace(match, `${match}`)
}
}
@@ -1104,6 +1159,120 @@ export function convertHighlightsToHTML(input: string): string {
return output
}
+function addParagraphBreakHints(lines: Array): Array {
+ const output: Array = []
+ for (let i = 0; i < lines.length; i++) {
+ const currentLine = lines[i]
+ output.push(currentLine)
+
+ const trimmedCurrent = currentLine.trim()
+ if (trimmedCurrent === '') {
+ continue
+ }
+
+ const nextLine = lines[i + 1]
+ if (nextLine == null) {
+ continue
+ }
+ const trimmedNext = nextLine.trim()
+ if (trimmedNext === '') {
+ continue
+ }
+
+ const currentIsList = isListLine(trimmedCurrent)
+ const nextIsList = isListLine(trimmedNext)
+
+ if ((currentIsList && !nextIsList) || isIsolatedWikiLink(trimmedCurrent)) {
+ output.push('')
+ }
+ }
+ return output
+}
+
+function isListLine(line: string): boolean {
+ return /^([*\-+]|\d+\.)\s+/.test(line)
+}
+
+function isIsolatedWikiLink(line: string): boolean {
+ return /^\[\[[^\]]+\]\]$/.test(line)
+}
+
+function convertWikiLinksToHTML(input: string): string {
+ return input.replace(/\[\[(.+?)\]\]/g, (_match, content) => {
+ const { url, displayText } = buildNotePlanLinkFromWikiContent(content)
+ const safeText = escapeHTML(displayText)
+ if (!url) {
+ return safeText
+ }
+ return `${safeText}`
+ })
+}
+
+function buildNotePlanLinkFromWikiContent(content: string): { url: string | null, displayText: string } {
+ let linkTarget = content.trim()
+ let displayText = linkTarget
+
+ const aliasIndex = linkTarget.indexOf('|')
+ if (aliasIndex !== -1) {
+ displayText = linkTarget.slice(aliasIndex + 1).trim() || linkTarget.slice(0, aliasIndex).trim()
+ linkTarget = linkTarget.slice(0, aliasIndex)
+ }
+
+ let heading = ''
+ const headingIndex = linkTarget.indexOf('#')
+ if (headingIndex !== -1) {
+ heading = linkTarget.slice(headingIndex + 1).trim()
+ linkTarget = linkTarget.slice(0, headingIndex)
+ }
+
+ let blockID = ''
+ const blockIndex = linkTarget.indexOf('^')
+ if (blockIndex !== -1) {
+ blockID = linkTarget.slice(blockIndex).trim()
+ linkTarget = linkTarget.slice(0, blockIndex)
+ }
+
+ const trimmedTarget = linkTarget.trim()
+ if (trimmedTarget.length === 0) {
+ return { url: null, displayText }
+ }
+
+ const calendarMatch = trimmedTarget.match(/^(\d{4}-\d{2}-\d{2}|\d{8})$/)
+ if (calendarMatch) {
+ const dateID = trimmedTarget.includes('-') ? trimmedTarget : `${trimmedTarget.slice(0, 4)}-${trimmedTarget.slice(4, 6)}-${trimmedTarget.slice(6)}`
+ const url = createOpenOrDeleteNoteCallbackUrl(dateID, 'date', heading)
+ return { url, displayText }
+ }
+
+ const defaultExt = DataStore?.defaultFileExtension ?? 'md'
+ const possibleFilename = trimmedTarget.endsWith(`.${defaultExt}`) ? trimmedTarget : `${trimmedTarget}.${defaultExt}`
+
+ let targetNote = typeof DataStore?.projectNoteByFilename === 'function' ? DataStore.projectNoteByFilename(possibleFilename) : null
+
+ if (!targetNote) {
+ const possibleNotes = DataStore?.projectNoteByTitle?.(trimmedTarget, true, true) ?? []
+ if (possibleNotes.length > 0) {
+ targetNote = possibleNotes.find((n) => n?.title === trimmedTarget) ?? possibleNotes[0]
+ }
+ }
+
+ if (targetNote) {
+ if (blockID && targetNote?.title) {
+ const urlForBlock = createOpenOrDeleteNoteCallbackUrl(targetNote.title, 'title', null, null, false, blockID)
+ return { url: urlForBlock, displayText }
+ }
+ const urlForFilename = createOpenOrDeleteNoteCallbackUrl(targetNote.filename, 'filename', heading)
+ return { url: urlForFilename, displayText }
+ }
+
+ const fallbackUrl = createOpenOrDeleteNoteCallbackUrl(trimmedTarget, 'title', heading)
+ return { url: fallbackUrl, displayText }
+}
+
+function escapeHTML(input: string): string {
+ return input.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''')
+}
+
/**
* Display time blocks with .timeBlock style
* Note: uses definition of time block syntax from plugin helpers, not directly from NP itself. So it may vary slightly.
diff --git a/helpers/__tests__/HTMLView.test.js b/helpers/__tests__/HTMLView.test.js
index 66e1b624c..4dd2c82ab 100644
--- a/helpers/__tests__/HTMLView.test.js
+++ b/helpers/__tests__/HTMLView.test.js
@@ -198,16 +198,65 @@ describe('replaceMarkdownLinkWithHTMLLink()' /* function */, () => {
expect(result).toEqual(orig)
})
test('should replace a url', () => {
- const orig = 'foo [link](http://) bar'
+ const orig = '[foo](http://bar.com)'
+ const expected = 'foo'
const result = h.replaceMarkdownLinkWithHTMLLink(orig)
- const expected = 'foo link bar'
expect(result).toEqual(expected)
})
- test('should replace > 1 url', () => {
- const orig = 'foo [link](http://) bar [link2](http://) baz [link3](noteplan://)'
- const result = h.replaceMarkdownLinkWithHTMLLink(orig)
- const expected = 'foo link bar link2 baz link3'
- expect(result).toEqual(expected)
+})
+
+describe('getNoteContentAsHTML()', () => {
+ test('converts wiki links to noteplan callback URLs', async () => {
+ const targetNote = {
+ filename: 'Projects/Personal Projects/Anique Music/Rehearsal Setup.md',
+ title: 'Rehearsal Setup for Playback Tracks',
+ }
+ DataStore.projectNoteByTitle = jest.fn().mockReturnValue([targetNote])
+ DataStore.projectNoteByFilename = jest.fn().mockReturnValue(targetNote)
+
+ const result = await h.getNoteContentAsHTML('[[Rehearsal Setup for Playback Tracks]]', {
+ filename: 'Projects/Test/Test Note.md',
+ title: 'Test Note',
+ type: 'Notes',
+ })
+
+ const expectedUrl =
+ 'noteplan://x-callback-url/openNote?filename=Projects%2FPersonal%20Projects%2FAnique%20Music%2FRehearsal%20Setup.md'
+ expect(result).toContain(
+ `Rehearsal Setup for Playback Tracks`,
+ )
+ })
+
+ test('keeps wiki-link paragraph separate from following text', async () => {
+ const targetNote = {
+ filename: 'Library/Example.md',
+ title: 'Example',
+ }
+ DataStore.projectNoteByTitle = jest.fn().mockReturnValue([targetNote])
+ DataStore.projectNoteByFilename = jest.fn().mockReturnValue(targetNote)
+
+ const result = await h.getNoteContentAsHTML('[[Example]]\nFollow up text', {
+ filename: 'Projects/Test/Test Note.md',
+ title: 'Test Note',
+ type: 'Notes',
+ })
+
+ expect(result).toContain('Follow up text
')
+ expect(result).not.toMatch(/]*>Example<\/a>
Follow up text/)
+ })
+
+ test('ensures text following a list is not captured inside the list item', async () => {
+ const note = {
+ filename: 'Projects/Test/Test Note.md',
+ title: 'Test Note',
+ type: 'Notes',
+ }
+
+ const markdown = '- Item one\n- Item two\nFollow up text'
+ const result = await h.getNoteContentAsHTML(markdown, note)
+
+ expect(result).toContain('Follow up text
')
+ expect(result).not.toMatch(/[^<]*Follow up text/)
})
})
diff --git a/np.Preview/CHANGELOG.md b/np.Preview/CHANGELOG.md
index 7fb230e17..e17b9ee58 100644
--- a/np.Preview/CHANGELOG.md
+++ b/np.Preview/CHANGELOG.md
@@ -1,6 +1,24 @@
# What's Changed in 🖥️ Previews plugin?
See [website README for more details](https://github.com/NotePlan/plugins/tree/main/np.Preview), and how to configure it.
+## [1.0.0] - 2025-11-07 @dwertheimer
+
+Arbitrary elevation to 1.0.0
+
+### General HTML Helper Change
+- convert `[[wikilinks]]` in HTML output to be real NotePlan callback URLs rather than just underlined text that does nothing
+- fix bug in showdown relative to NP to ensure horizontal rules have a blank line before them so they render properly
+- add paragraph break hints so isolated wikilinks and text following lists render in their own paragraphs
+
+### Preview-specific Changes
+- move preview-specific spacing and typography to `previewStyles` file, with:
+ - table formatting
+ - body padding
+ - h1 line-height
+ - list indentation/line-height tweaks
+ - internal note links (no underline, bold on hover)
+ - adjust horizontal rule appearance
+
## [0.4.5] - 2025-03-14
- upgraded to use Mermaid v11.x
diff --git a/np.Preview/plugin.json b/np.Preview/plugin.json
index 970c8787a..7904789b0 100644
--- a/np.Preview/plugin.json
+++ b/np.Preview/plugin.json
@@ -7,8 +7,8 @@
"plugin.icon": "",
"plugin.author": "Jonathan Clark",
"plugin.url": "https://github.com/NotePlan/plugins/tree/main/np.Preview/",
- "plugin.version": "0.4.5",
- "plugin.lastUpdateInfo": "v0.4.5: updated to use Mermaid v11.\nv0.4.4: added embed images to preview, fixed some bugs",
+ "plugin.version": "1.0.0",
+ "plugin.lastUpdateInfo": "v1.0.0: improved wiki-link handling, preview styling tweaks.\nv0.4.5: updated to use Mermaid v11.\nv0.4.4: added embed images to preview, fixed some bugs",
"plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/np.Preview/CHANGELOG.md",
"plugin.dependencies": [],
"plugin.requiredFiles": [
diff --git a/np.Preview/src/previewMain.js b/np.Preview/src/previewMain.js
index 4f0b8daca..925fa41a0 100644
--- a/np.Preview/src/previewMain.js
+++ b/np.Preview/src/previewMain.js
@@ -5,18 +5,14 @@
// by Jonathan Clark, last updated 2025-03-14 for v0.4.5
//--------------------------------------------------------------
-
// import open, { openApp, apps } from 'open'
import pluginJson from '../plugin.json'
+import { getPreviewSpecificCSS } from './previewStyles'
import { logDebug, logError, logWarn } from '@helpers/dev'
import { addTrigger } from '@helpers/NPFrontMatter'
import { displayTitle } from '@helpers/general'
-import {
- getNoteContentAsHTML,
- type HtmlWindowOptions,
- showHTMLV2
-} from '@helpers/HTMLView'
+import { getNoteContentAsHTML, type HtmlWindowOptions, showHTMLV2 } from '@helpers/HTMLView'
import { showMessageYesNo } from '@helpers/userInput'
//--------------------------------------------------------------
@@ -33,13 +29,11 @@ const initMathJaxScripts = `
// Set up for Mermaid, using live copy of the Mermaid library (for now)
// is current NP theme dark or light?
-const isDarkTheme = (Editor.currentTheme.mode === 'dark')
+const isDarkTheme = Editor.currentTheme.mode === 'dark'
// Note: using CDN version of mermaid.js, because whatever we tried for a packaged local version didn't work for Gantt charts.
function initMermaidScripts(mermaidTheme?: string): string {
- const mermaidThemeToUse = mermaidTheme
- ? mermaidTheme : isDarkTheme
- ? 'dark' : 'default'
+ const mermaidThemeToUse = mermaidTheme ? mermaidTheme : isDarkTheme ? 'dark' : 'default'
return `