diff --git a/.vscode/settings.json b/.vscode/settings.json index d0434d3d2..3d30a4f5b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "javascript.format.semicolons": "insert", - "typescript.format.semicolons": "insert" + "typescript.format.semicolons": "insert", + "snyk.advanced.autoSelectOrganization": true } diff --git a/apps/vscode-editor/src/sync.ts b/apps/vscode-editor/src/sync.ts index 098dae454..1f91b6fce 100644 --- a/apps/vscode-editor/src/sync.ts +++ b/apps/vscode-editor/src/sync.ts @@ -36,6 +36,7 @@ import { VSC_VE_GetSlideIndex, VSC_VE_GetActiveBlockContext, VSC_VE_SetBlockSelection, + VSC_VE_GetSelectedText, VSC_VE_Init, VSC_VE_Focus, VSC_VEH_FlushEditorUpdates, @@ -181,7 +182,7 @@ export async function syncEditorToHost( // visual editor => text editor (just send the state, host will call back for markdown) editor.subscribe(UpdateEvent, () => host.onEditorUpdated(editor.getStateJson())); - editor.subscribe(StateChangeEvent, () => host.onEditorStateChanged(editor.getEditorSourcePos())); + editor.subscribe(StateChangeEvent, () => host.onEditorStateChanged(editor.getEditorSourcePos(), editor.getSelectedText())); // return canonical markdown @@ -252,6 +253,9 @@ export async function syncEditorToHost( }, async setBlockSelection(context: CodeViewActiveBlockContext, action: CodeViewSelectionAction) { editor.setBlockSelection(context, action); + }, + async getSelectedText(): Promise { + return editor.getSelectedText(); } }) @@ -334,6 +338,7 @@ function visualEditorHostServer(vscode: WebviewApi, editor: VSCodeVisua [VSC_VE_ApplyExternalEdit]: args => editor.applyExternalEdit(args[0]), [VSC_VE_GetActiveBlockContext]: () => editor.getActiveBlockContext(), [VSC_VE_SetBlockSelection]: args => editor.setBlockSelection(args[0], args[1]), + [VSC_VE_GetSelectedText]: () => editor.getSelectedText(), [VSC_VE_PrefsChanged]: args => editor.prefsChanged(args[0]), [VSC_VE_ImageChanged]: args => editor.imageChanged(args[0]) }) @@ -346,7 +351,7 @@ function editorJsonRpcContainer(request: JsonRpcRequestTransport) : VSCodeVisual reopenSourceMode: () => request(VSC_VEH_ReopenSourceMode, []), onEditorReady: () => request(VSC_VEH_OnEditorReady, []), onEditorUpdated: (state: unknown) => request(VSC_VEH_OnEditorUpdated, [state]), - onEditorStateChanged: (sourcePos: SourcePos) => request(VSC_VEH_OnEditorStateChanged, [sourcePos]), + onEditorStateChanged: (sourcePos: SourcePos, selectedText: string) => request(VSC_VEH_OnEditorStateChanged, [sourcePos, selectedText]), flushEditorUpdates: () => request(VSC_VEH_FlushEditorUpdates, []), saveDocument: () => request(VSC_VEH_SaveDocument, []), renderDocument: () => request(VSC_VEH_RenderDocument, []), diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 20b974d91..38830c174 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,6 +2,8 @@ ## 1.134.0 (Unreleased) +- Exposed the visual editor's text selection: added a `quarto.editor.getSelectedText` command and an `onDidChangeVisualEditorSelection` event on the public extension API (). + ## 1.133.0 (Release on 2026-06-03) - Added diagnostics (i.e. squiggly underlines) to code cells in qmds (). diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 9a7f526af..13183f358 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -232,6 +232,11 @@ "title": "New Quarto Presentation (qmd)", "category": "Quarto" }, + { + "command": "quarto.toggleWordCount", + "title": "Toggle Word Count", + "category": "Quarto" + }, { "command": "quarto.newNotebook", "title": "New Quarto Notebook (ipynb)", @@ -1074,6 +1079,20 @@ "default": true, "markdownDescription": "Use reticulate to execute Python cells within Knitr engine documents." }, + "quarto.wordCount.enabled": { + "order": 28, + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "Show word counts (per-section code lens / visual editor badge, plus document total and selection count in the status bar)." + }, + "quarto.wordCount.includeCodeCells": { + "order": 29, + "scope": "window", + "type": "boolean", + "default": false, + "markdownDescription": "Include executable code cells, fenced code, and raw blocks in word counts. YAML front matter is always excluded." + }, "quarto.mathjax.scale": { "order": 15, "scope": "window", diff --git a/apps/vscode/src/api.ts b/apps/vscode/src/api.ts index d033f77ac..1cadd1596 100644 --- a/apps/vscode/src/api.ts +++ b/apps/vscode/src/api.ts @@ -13,8 +13,20 @@ * */ +import * as vscode from "vscode"; + import { QuartoContext } from "quarto-core"; +/** + * Payload for the {@link QuartoExtensionApi.onDidChangeVisualEditorSelection} event. + */ +export interface VisualEditorSelection { + /** The document whose visual editor selection changed. */ + uri: vscode.Uri; + /** The currently selected text (empty string when nothing is selected). */ + selectedText: string; +} + /** * Public API for the Quarto extension. * @@ -29,11 +41,16 @@ import { QuartoContext } from "quarto-core"; * copy this interface definition into your own codebase: * * ```typescript - * // Copy this interface into your extension + * // Copy these definitions into your extension + * interface VisualEditorSelection { + * uri: { toString(): string }; + * selectedText: string; + * } * interface QuartoExtensionApi { * getQuartoPath(): string | undefined; * getQuartoVersion(): string | undefined; * isQuartoAvailable(): boolean; + * onDidChangeVisualEditorSelection: (listener: (e: VisualEditorSelection) => unknown) => { dispose(): unknown }; * } * * // Then use it like this: @@ -67,12 +84,27 @@ export interface QuartoExtensionApi { * Check if Quarto is available. */ isQuartoAvailable(): boolean; + + /** + * Fires when the text selection changes in a Quarto visual editor. + * + * The event carries the document URI and the currently selected text. An + * empty `selectedText` is a meaningful event (e.g. the selection was cleared), + * so consumers can use it to reset their UI. Events are de-duplicated against + * the previous selection for a given document, but not time-debounced — the + * underlying editor reports state changes on every cursor move, so consumers + * that need throttling should debounce themselves. + */ + onDidChangeVisualEditorSelection: vscode.Event; } /** * Create the public API for the Quarto extension. */ -export function createQuartoExtensionApi(quartoContext: QuartoContext): QuartoExtensionApi { +export function createQuartoExtensionApi( + quartoContext: QuartoContext, + onDidChangeVisualEditorSelection: vscode.Event +): QuartoExtensionApi { return { getQuartoPath(): string | undefined { if (!quartoContext.available) { @@ -91,5 +123,7 @@ export function createQuartoExtensionApi(quartoContext: QuartoContext): QuartoEx isQuartoAvailable(): boolean { return quartoContext.available; }, + + onDidChangeVisualEditorSelection, }; } diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 5d7a158dd..23ca794e5 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -50,8 +50,9 @@ import { activateYamlLinks } from "./providers/yaml-links"; import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions"; import { activateContextKeySetter } from "./providers/context-keys"; import { activateDivBracketDecorations } from "./providers/div-brackets"; +import { activateWordCount } from "./providers/wordcount/wordcount"; import { CommandManager } from "./core/command"; -import { createQuartoExtensionApi, QuartoExtensionApi } from "./api"; +import { createQuartoExtensionApi, QuartoExtensionApi, VisualEditorSelection } from "./api"; let embeddedDiagnostics: EmbeddedDiagnosticsService | undefined; @@ -67,6 +68,10 @@ export async function activate(context: vscode.ExtensionContext): Promise(); + context.subscriptions.push(visualEditorSelectionEmitter); + // create markdown engine const engine = new MarkdownEngine(); @@ -133,7 +138,7 @@ export async function activate(context: vscode.ExtensionContext): Promise request(VSC_VE_SetBlockSelection, [context, action]), + getSelectedText: () => request(VSC_VE_GetSelectedText, []), applyExternalEdit: (markdown: string) => request(VSC_VE_ApplyExternalEdit, [markdown]), prefsChanged: (prefs: Prefs) => request(VSC_VE_PrefsChanged, [prefs]), imageChanged: (file: string) => request(VSC_VE_ImageChanged, [file]) @@ -144,7 +146,7 @@ function editorHostMethods(host: VSCodeVisualEditorHost): Record host.reopenSourceMode(), [VSC_VEH_OnEditorReady]: () => host.onEditorReady(), [VSC_VEH_OnEditorUpdated]: args => host.onEditorUpdated(args[0]), - [VSC_VEH_OnEditorStateChanged]: args => host.onEditorStateChanged(args[0]), + [VSC_VEH_OnEditorStateChanged]: args => host.onEditorStateChanged(args[0], args[1]), [VSC_VEH_FlushEditorUpdates]: () => host.flushEditorUpdates(), [VSC_VEH_SaveDocument]: () => host.saveDocument(), [VSC_VEH_RenderDocument]: () => host.renderDocument(), diff --git a/apps/vscode/src/providers/editor/editor.ts b/apps/vscode/src/providers/editor/editor.ts index 83c7af1b4..1907f2266 100644 --- a/apps/vscode/src/providers/editor/editor.ts +++ b/apps/vscode/src/providers/editor/editor.ts @@ -35,7 +35,8 @@ import { Selection, TextEditorRevealType, GlobPattern, - TabInputText + TabInputText, + EventEmitter } from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; @@ -67,6 +68,7 @@ import { } from "./toggle"; import { ExtensionHost } from "../../host"; import { TabInputCustom } from "vscode"; +import { VisualEditorSelection } from "../../api"; const kVisualModeConfirmed = "visualModeConfirmed"; @@ -74,6 +76,7 @@ export interface QuartoVisualEditor extends QuartoEditor { hasFocus(): Promise; getActiveBlockContext(): Promise; setBlockSelection(context: CodeViewActiveBlockContext, action: CodeViewSelectionAction): Promise; + getSelectedText(): Promise; } export function activateEditor( @@ -81,10 +84,11 @@ export function activateEditor( host: ExtensionHost, quartoContext: QuartoContext, lspClient: LanguageClient, - engine: MarkdownEngine + engine: MarkdownEngine, + selectionEmitter: EventEmitter ): Command[] { // register the provider - context.subscriptions.push(VisualEditorProvider.register(context, host, quartoContext, lspClient, engine)); + context.subscriptions.push(VisualEditorProvider.register(context, host, quartoContext, lspClient, engine, selectionEmitter)); // return commands return [ @@ -100,6 +104,16 @@ export function activateEditor( return VisualEditorProvider.activeEditor() !== undefined; } }, + { + id: 'quarto.editor.getSelectedText', + async execute() { + const editor = VisualEditorProvider.activeEditor(); + if (editor) { + return await editor.getSelectedText(); + } + return ''; + } + }, editInVisualModeCommand(), editInSourceModeCommand(), toggleRenderOnSaveCommand() @@ -136,7 +150,8 @@ export class VisualEditorProvider implements CustomTextEditorProvider { host: ExtensionHost, quartoContext: QuartoContext, lspClient: LanguageClient, - engine: MarkdownEngine + engine: MarkdownEngine, + selectionEmitter: EventEmitter ): Disposable { // setup request transport @@ -260,7 +275,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider { } }, 100))); - const provider = new VisualEditorProvider(context, host, quartoContext, lspRequest, engine); + const provider = new VisualEditorProvider(context, host, quartoContext, lspRequest, engine, selectionEmitter); const providerRegistration = window.registerCustomEditorProvider( VisualEditorProvider.viewType, provider, @@ -303,6 +318,9 @@ export class VisualEditorProvider implements CustomTextEditorProvider { setBlockSelection: async (context, action) => { await editor.editor.setBlockSelection(context, action); }, + getSelectedText: async () => { + return await editor.editor.getSelectedText(); + }, viewColumn: editor.webviewPanel.viewColumn }; } else { @@ -322,7 +340,21 @@ export class VisualEditorProvider implements CustomTextEditorProvider { private readonly extensionHost: ExtensionHost, private readonly quartoContext: QuartoContext, private readonly lspRequest: JsonRpcRequestTransport, - private readonly engine: MarkdownEngine) { } + private readonly engine: MarkdownEngine, + private readonly selectionEmitter: EventEmitter) { } + + // last selection emitted per document (used to de-duplicate selection-change events) + private readonly lastSelectedText = new Map(); + + // fire the public selection-change event, de-duplicating against the previous selection + private fireSelectionChanged(uri: Uri, selectedText: string) { + const key = uri.toString(); + if (this.lastSelectedText.get(key) === selectedText) { + return; + } + this.lastSelectedText.set(key, selectedText); + this.selectionEmitter.fire({ uri, selectedText }); + } public async resolveCustomTextEditor( document: TextDocument, @@ -478,8 +510,9 @@ export class VisualEditorProvider implements CustomTextEditorProvider { // notify sync manager when visual editor is updated onEditorUpdated: syncManager.onVisualEditorChanged, - onEditorStateChanged: async (sourcePos: SourcePos) => { + onEditorStateChanged: async (sourcePos: SourcePos, selectedText: string) => { VisualEditorProvider.visualEditorLastSourcePos.set(document.uri.toString(), sourcePos); + this.fireSelectionChanged(document.uri, selectedText); }, // flush any pending updates @@ -571,6 +604,7 @@ export class VisualEditorProvider implements CustomTextEditorProvider { for (const disposable of disposables) { disposable.dispose(); } + this.lastSelectedText.delete(document.uri.toString()); }); } diff --git a/apps/vscode/src/providers/editor/prefs.ts b/apps/vscode/src/providers/editor/prefs.ts index 813451787..076da845a 100644 --- a/apps/vscode/src/providers/editor/prefs.ts +++ b/apps/vscode/src/providers/editor/prefs.ts @@ -50,6 +50,8 @@ const kQuartoEditorMarkdownWrap = "quarto.visualEditor.markdownWrap"; const kQuartoEditorMarkdownWrapColumn = "quarto.visualEditor.markdownWrapColumn"; const kQuartoEditorMarkdownReferences = "quarto.visualEditor.markdownReferences"; const kQuartoEditorMarkdownReferenceLinks = "quarto.visualEditor.markdownReferenceLinks"; +const kQuartoWordCountEnabled = "quarto.wordCount.enabled"; +const kQuartoWordCountIncludeCodeCells = "quarto.wordCount.includeCodeCells"; const kMonitoredConfigurations = [ kEditorAutoClosingBrackets, @@ -71,6 +73,8 @@ const kMonitoredConfigurations = [ kQuartoEditorMarkdownWrapColumn, kQuartoEditorMarkdownReferences, kQuartoEditorMarkdownReferenceLinks, + kQuartoWordCountEnabled, + kQuartoWordCountIncludeCodeCells, kEditorQuickSuggestions ]; @@ -109,6 +113,10 @@ export async function vscodePrefsServer( // quarto editor settings listSpacing: configuration.get<'spaced' | 'tight'>(kQuartoEditorDefaultListSpacing, defaults.listSpacing), + // word count settings + showWordCount: configuration.get(kQuartoWordCountEnabled, defaults.showWordCount), + wordCountIncludeCodeCells: configuration.get(kQuartoWordCountIncludeCodeCells, defaults.wordCountIncludeCodeCells), + // markdown writer settings ...(await readMarkdownPrefs(context, engine, document)), diff --git a/apps/vscode/src/providers/wordcount/codelens.ts b/apps/vscode/src/providers/wordcount/codelens.ts new file mode 100644 index 000000000..d9d2e4707 --- /dev/null +++ b/apps/vscode/src/providers/wordcount/codelens.ts @@ -0,0 +1,70 @@ +/* + * codelens.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { + CancellationToken, + CodeLens, + CodeLensProvider, + Event, + EventEmitter, + ProviderResult, + Range, + TextDocument, +} from "vscode"; + +import { MarkdownEngine } from "../../markdown/engine"; +import { countSections } from "./count"; +import { formatWordCount, wordCountEnabled, wordCountOptions } from "./config"; + +export class WordCountCodeLensProvider implements CodeLensProvider { + private readonly onDidChangeCodeLensesEmitter = new EventEmitter(); + public readonly onDidChangeCodeLenses: Event = + this.onDidChangeCodeLensesEmitter.event; + + constructor(private readonly engine: MarkdownEngine) {} + + // refresh lenses (e.g. when the configuration changes) + public refresh() { + this.onDidChangeCodeLensesEmitter.fire(); + } + + public dispose() { + this.onDidChangeCodeLensesEmitter.dispose(); + } + + public provideCodeLenses( + document: TextDocument, + token: CancellationToken + ): ProviderResult { + if (!wordCountEnabled()) { + return []; + } + + const tokens = this.engine.parse(document); + if (token.isCancellationRequested) { + return []; + } + + const sections = countSections(tokens, document.getText(), wordCountOptions()); + return sections.map((section) => { + const range = new Range(section.line, 0, section.line, 0); + return new CodeLens(range, { + title: formatWordCount(section.words), + tooltip: "Words in this section (including nested subsections)", + command: "", + }); + }); + } +} diff --git a/apps/vscode/src/providers/wordcount/config.ts b/apps/vscode/src/providers/wordcount/config.ts new file mode 100644 index 000000000..f577eeb08 --- /dev/null +++ b/apps/vscode/src/providers/wordcount/config.ts @@ -0,0 +1,41 @@ +/* + * config.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { workspace } from "vscode"; +import { WordCountOptions } from "./count"; + +export const kWordCountEnabled = "quarto.wordCount.enabled"; +export const kWordCountIncludeCodeCells = "quarto.wordCount.includeCodeCells"; + +export function wordCountEnabled(): boolean { + return workspace.getConfiguration("quarto").get("wordCount.enabled", true); +} + +export function wordCountOptions(): WordCountOptions { + return { + includeCodeCells: workspace + .getConfiguration("quarto") + .get("wordCount.includeCodeCells", false), + }; +} + +export function affectsWordCount(affects: (section: string) => boolean): boolean { + return affects(kWordCountEnabled) || affects(kWordCountIncludeCodeCells); +} + +// "1,432 words" / "1 word" +export function formatWordCount(words: number): string { + return `${words.toLocaleString()} ${words === 1 ? "word" : "words"}`; +} diff --git a/apps/vscode/src/providers/wordcount/count.ts b/apps/vscode/src/providers/wordcount/count.ts new file mode 100644 index 000000000..0285f2a03 --- /dev/null +++ b/apps/vscode/src/providers/wordcount/count.ts @@ -0,0 +1,157 @@ +/* + * count.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { wordBreaker } from "core"; +import { + Token, + TokenHeader, + isCodeBlock, + isFrontMatter, + isHeader, + isMath, + isRawBlock, + markdownToText, +} from "quarto-core"; + +export interface WordCountOptions { + // when true, code cells / fenced code / raw blocks are counted as words + // (YAML front matter and display math are always excluded) + includeCodeCells: boolean; +} + +export interface SectionWordCount { + // 0-based line of the heading + line: number; + level: number; + words: number; +} + +const wb = wordBreaker(); + +/** + * Count the prose words in an entire document. + */ +export function countDocument( + tokens: Token[], + text: string, + options: WordCountOptions +): number { + const lines = splitLines(text); + const excluded = excludedLines(tokens, lines.length, options); + return countLines(lines, excluded, 0, lines.length - 1); +} + +/** + * Count the prose words in an arbitrary span of markdown text (e.g. the current + * selection). Inline markup is reduced to plain text before counting. + */ +export function countText(text: string): number { + if (!text) { + return 0; + } + // collapse line breaks to spaces first (see countLines) + return wb.breakWords(markdownToText(text.replace(/\r\n|\n|\r/g, " "))).length; +} + +/** + * Count the prose words in each section subtree. A section runs from its heading + * to the next heading of equal-or-higher level and includes nested content and + * nested sub-headings, but excludes the section's own heading text. + */ +export function countSections( + tokens: Token[], + text: string, + options: WordCountOptions +): SectionWordCount[] { + const lines = splitLines(text); + const excluded = excludedLines(tokens, lines.length, options); + + const headers = tokens.filter(isHeader) as TokenHeader[]; + return headers.map((header, i) => { + const headerLine = header.range.start.line; + const level = header.data.level; + + // section ends just before the next heading of equal-or-higher level + let endLine = lines.length - 1; + for (let j = i + 1; j < headers.length; j++) { + if (headers[j].data.level <= level) { + endLine = headers[j].range.start.line - 1; + break; + } + } + + return { + line: headerLine, + level, + words: countLines(lines, excluded, header.range.end.line + 1, endLine), + }; + }); +} + +function countLines( + lines: string[], + excluded: boolean[], + fromLine: number, + toLine: number +): number { + if (toLine < fromLine) { + return 0; + } + const prose: string[] = []; + for (let line = fromLine; line <= toLine && line < lines.length; line++) { + if (!excluded[line]) { + prose.push(lines[line]); + } + } + if (prose.length === 0) { + return 0; + } + // join with spaces (not newlines) so that words on adjacent lines stay + // separate: the inline parser collapses soft line breaks, which would + // otherwise merge the last/first words of consecutive lines into one + const text = markdownToText(prose.join(" ")); + return wb.breakWords(text).length; +} + +// compute the set of lines that should not contribute to the word count +function excludedLines( + tokens: Token[], + lineCount: number, + options: WordCountOptions +): boolean[] { + const excluded = new Array(lineCount).fill(false); + const exclude = (token: Token) => { + for ( + let line = token.range.start.line; + line <= token.range.end.line && line < lineCount; + line++ + ) { + excluded[line] = true; + } + }; + for (const token of tokens) { + if (isFrontMatter(token) || isMath(token)) { + // YAML front matter and display math never count as prose + exclude(token); + } else if (!options.includeCodeCells && (isCodeBlock(token) || isRawBlock(token))) { + exclude(token); + } + } + return excluded; +} + +function splitLines(text: string): string[] { + return text.split(/\r\n|\n|\r/); +} diff --git a/apps/vscode/src/providers/wordcount/statusbar.ts b/apps/vscode/src/providers/wordcount/statusbar.ts new file mode 100644 index 000000000..dd6dd162a --- /dev/null +++ b/apps/vscode/src/providers/wordcount/statusbar.ts @@ -0,0 +1,141 @@ +/* + * statusbar.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { + Event, + ExtensionContext, + StatusBarAlignment, + TextDocument, + window, + workspace, +} from "vscode"; + +import { MarkdownEngine } from "../../markdown/engine"; +import { isQuartoDoc } from "../../core/doc"; +import { VisualEditorProvider } from "../editor/editor"; +import { VisualEditorSelection } from "../../api"; +import { countDocument, countText } from "./count"; +import { + affectsWordCount, + formatWordCount, + wordCountEnabled, + wordCountOptions, +} from "./config"; + +export function activateWordCountStatusBar( + context: ExtensionContext, + engine: MarkdownEngine, + onDidChangeVisualEditorSelection: Event +) { + const statusItem = window.createStatusBarItem( + "quarto.wordCount", + StatusBarAlignment.Right, + 100 + ); + statusItem.name = "Quarto Word Count"; + context.subscriptions.push(statusItem); + + // last reported visual editor selection text, keyed by document uri + const visualSelection = new Map(); + + // cache the document total (keyed by uri@version) so that selection / cursor + // changes don't re-count the whole document on every event + let totalCache: { key: string; total: number } | undefined; + const documentTotal = (document: TextDocument): number => { + const key = `${document.uri.toString()}@${document.version}`; + if (totalCache?.key !== key) { + const tokens = engine.parse(document); + totalCache = { key, total: countDocument(tokens, document.getText(), wordCountOptions()) }; + } + return totalCache.total; + }; + + const update = () => { + if (!wordCountEnabled()) { + statusItem.hide(); + return; + } + + // resolve the active quarto document and its current selection (if any), + // preferring an active source editor and falling back to a visual editor + let document: TextDocument | undefined; + let selectedText = ""; + + const textEditor = window.activeTextEditor; + if (textEditor && isQuartoDoc(textEditor.document)) { + document = textEditor.document; + selectedText = textEditor.selection.isEmpty + ? "" + : document.getText(textEditor.selection); + } else { + const visualEditor = VisualEditorProvider.activeEditor(); + if (visualEditor && isQuartoDoc(visualEditor.document)) { + document = visualEditor.document; + selectedText = visualSelection.get(document.uri.toString()) || ""; + } + } + + if (!document) { + statusItem.hide(); + return; + } + + const total = documentTotal(document); + + if (selectedText) { + const selected = countText(selectedText); + statusItem.text = `${selected.toLocaleString()} of ${formatWordCount(total)}`; + } else { + statusItem.text = formatWordCount(total); + } + statusItem.show(); + }; + + context.subscriptions.push( + window.onDidChangeActiveTextEditor(() => update()), + window.tabGroups.onDidChangeTabs(() => update()), + window.onDidChangeTextEditorSelection(() => update()), + workspace.onDidChangeTextDocument((e) => { + const active = activeDocumentUri(); + if (active && e.document.uri.toString() === active) { + update(); + } + }), + onDidChangeVisualEditorSelection((e) => { + visualSelection.set(e.uri.toString(), e.selectedText); + update(); + }), + workspace.onDidChangeConfiguration((e) => { + if (affectsWordCount((section) => e.affectsConfiguration(section))) { + totalCache = undefined; // includeCodeCells may have changed + update(); + } + }) + ); + + update(); +} + +function activeDocumentUri(): string | undefined { + const textEditor = window.activeTextEditor; + if (textEditor && isQuartoDoc(textEditor.document)) { + return textEditor.document.uri.toString(); + } + const visualEditor = VisualEditorProvider.activeEditor(); + if (visualEditor && isQuartoDoc(visualEditor.document)) { + return visualEditor.document.uri.toString(); + } + return undefined; +} diff --git a/apps/vscode/src/providers/wordcount/wordcount.ts b/apps/vscode/src/providers/wordcount/wordcount.ts new file mode 100644 index 000000000..11ea5cb80 --- /dev/null +++ b/apps/vscode/src/providers/wordcount/wordcount.ts @@ -0,0 +1,65 @@ +/* + * wordcount.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { + ConfigurationTarget, + Event, + ExtensionContext, + commands, + languages, + workspace, +} from "vscode"; + +import { MarkdownEngine } from "../../markdown/engine"; +import { kQuartoDocSelector } from "../../core/doc"; +import { VisualEditorSelection } from "../../api"; +import { WordCountCodeLensProvider } from "./codelens"; +import { activateWordCountStatusBar } from "./statusbar"; +import { affectsWordCount, wordCountEnabled } from "./config"; + +export function activateWordCount( + context: ExtensionContext, + engine: MarkdownEngine, + onDidChangeVisualEditorSelection: Event +) { + // per-section code lens (source mode) + const codeLensProvider = new WordCountCodeLensProvider(engine); + context.subscriptions.push(codeLensProvider); + context.subscriptions.push( + languages.registerCodeLensProvider(kQuartoDocSelector, codeLensProvider) + ); + + // document total + selection (status bar, both modes) + activateWordCountStatusBar(context, engine, onDidChangeVisualEditorSelection); + + // refresh the code lens when the configuration changes (status bar refreshes + // itself; the editor badges follow the prefs channel) + context.subscriptions.push( + workspace.onDidChangeConfiguration((e) => { + if (affectsWordCount((section) => e.affectsConfiguration(section))) { + codeLensProvider.refresh(); + } + }) + ); + + // toggle command + context.subscriptions.push( + commands.registerCommand("quarto.toggleWordCount", async () => { + await workspace + .getConfiguration("quarto") + .update("wordCount.enabled", !wordCountEnabled(), ConfigurationTarget.Global); + }) + ); +} diff --git a/apps/vscode/src/test/api.test.ts b/apps/vscode/src/test/api.test.ts index e26f7cc00..af8c27f81 100644 --- a/apps/vscode/src/test/api.test.ts +++ b/apps/vscode/src/test/api.test.ts @@ -16,6 +16,7 @@ suite("Quarto Extension API", function () { assert.strictEqual(typeof api.getQuartoPath, "function", "API should have getQuartoPath method"); assert.strictEqual(typeof api.getQuartoVersion, "function", "API should have getQuartoVersion method"); assert.strictEqual(typeof api.isQuartoAvailable, "function", "API should have isQuartoAvailable method"); + assert.strictEqual(typeof api.onDidChangeVisualEditorSelection, "function", "API should have onDidChangeVisualEditorSelection event"); }); test("API methods return expected types", async function () { diff --git a/apps/vscode/src/test/editor-commands.test.ts b/apps/vscode/src/test/editor-commands.test.ts new file mode 100644 index 000000000..4b2d7e641 --- /dev/null +++ b/apps/vscode/src/test/editor-commands.test.ts @@ -0,0 +1,24 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { extension } from "./extension"; +import { QuartoExtensionApi } from "../api"; + +suite("Visual Editor Commands", function () { + test("quarto.editor.getSelectedText returns '' when no visual editor is active", async function () { + const ext = extension(); + + if (!ext.isActive) { + await ext.activate(); + } + + // the command is only registered when Quarto is available + const api = ext.exports as QuartoExtensionApi; + if (!api.isQuartoAvailable()) { + this.skip(); + } + + // no visual editor is open/focused, so the command should report an empty selection + const selected = await vscode.commands.executeCommand("quarto.editor.getSelectedText"); + assert.strictEqual(selected, "", "getSelectedText should return '' when no visual editor is active"); + }); +}); diff --git a/apps/vscode/src/test/wordcount.test.ts b/apps/vscode/src/test/wordcount.test.ts new file mode 100644 index 000000000..f95af9afa --- /dev/null +++ b/apps/vscode/src/test/wordcount.test.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import { Token, markdownitParser } from "quarto-core"; +import { countDocument, countSections, countText } from "../providers/wordcount/count"; + +// a small document exercising: yaml front matter, nested headings, a paragraph +// with an inline link, a bullet list, and an executable code cell +const kFixture = `--- +title: "Test" +author: "Jane" +--- + +# Introduction + +This is a short paragraph with a [link](http://example.com) inside. + +- first item +- second item + +## Methods + +We ran the analysis here. + +\`\`\`{r} +x <- 1 +mean(x) +\`\`\` + +# Conclusion + +All done now. +`; + +function parse(text: string): Token[] { + const parser = markdownitParser(); + const doc = { + uri: "file:///wordcount-test.qmd", + version: 1, + lineCount: text.split("\n").length, + getText: () => text, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return parser(doc as any); +} + +suite("Word count", function () { + const tokens = parse(kFixture); + + test("counts prose per section, excluding code/yaml by default", function () { + const sections = countSections(tokens, kFixture, { includeCodeCells: false }); + assert.deepStrictEqual( + sections.map((s) => s.words), + // Introduction (paragraph + link + list + nested Methods heading + Methods + // paragraph) = 19; Methods (paragraph) = 5; Conclusion (paragraph) = 3 + [19, 5, 3] + ); + assert.deepStrictEqual( + sections.map((s) => s.level), + [1, 2, 1] + ); + }); + + test("counts the whole document, excluding code/yaml by default", function () { + const total = countDocument(tokens, kFixture, { includeCodeCells: false }); + assert.strictEqual(total, 24); + }); + + test("includes code cells when requested", function () { + const sections = countSections(tokens, kFixture, { includeCodeCells: true }); + // the {r} cell adds words to the sections that contain it + assert.deepStrictEqual( + sections.map((s) => s.words), + [23, 9, 3] + ); + const total = countDocument(tokens, kFixture, { includeCodeCells: true }); + assert.strictEqual(total, 28); + }); + + test("counts an arbitrary selection of text", function () { + assert.strictEqual(countText("We ran the analysis here."), 5); + // inline markup is reduced before counting + assert.strictEqual(countText("a [link](http://example.com) here"), 3); + // line breaks separate words + assert.strictEqual(countText("one two\nthree four"), 4); + assert.strictEqual(countText(""), 0); + }); +}); diff --git a/packages/editor-types/src/prefs.ts b/packages/editor-types/src/prefs.ts index b2f025809..250d83051 100644 --- a/packages/editor-types/src/prefs.ts +++ b/packages/editor-types/src/prefs.ts @@ -49,6 +49,10 @@ export interface Prefs extends MarkdownPrefs { readonly tabKeyMoveFocus: boolean; readonly equationPreview: boolean; + // word count + readonly showWordCount: boolean; + readonly wordCountIncludeCodeCells: boolean; + // citations readonly zoteroUseBetterBibtex: boolean; readonly bibliographyDefaultType: 'bib' | 'yaml' | 'json'; @@ -101,6 +105,10 @@ export function defaultPrefs() : Prefs { tabKeyMoveFocus: false, equationPreview: true, + // word count + showWordCount: true, + wordCountIncludeCodeCells: false, + // markdown ...defaultMarkdownPrefs(), diff --git a/packages/editor-types/src/vscode.ts b/packages/editor-types/src/vscode.ts index 370996782..ac401d9f4 100644 --- a/packages/editor-types/src/vscode.ts +++ b/packages/editor-types/src/vscode.ts @@ -31,6 +31,7 @@ export const VSC_VE_PrefsChanged = 'vsc_ve_prefs_changed'; export const VSC_VE_ImageChanged = 'vsc_ve_image_changed'; export const VSC_VE_GetActiveBlockContext = 'vsc_ve_get_active_block_context'; export const VSC_VE_SetBlockSelection = 'vsc_ve_set_block_selection'; +export const VSC_VE_GetSelectedText = 'vsc_ve_get_selected_text'; export const VSC_VEH_GetHostContext = 'vsc_ve_get_host_context'; export const VSC_VEH_ReopenSourceMode = 'vsc_ve_reopen_source_mode'; @@ -60,6 +61,7 @@ export interface VSCodeVisualEditor { getSlideIndex: () => Promise; getActiveBlockContext: () => Promise; setBlockSelection: (context: CodeViewActiveBlockContext, action: CodeViewSelectionAction) => Promise; + getSelectedText: () => Promise; applyExternalEdit: (markdown: string) => Promise; prefsChanged: (prefs: Prefs) => Promise; imageChanged: (file: string) => Promise; @@ -78,7 +80,7 @@ export interface VSCodeVisualEditorHost extends EditorDisplay, EditorUIImageReso reopenSourceMode: () => Promise; onEditorReady: () => Promise; onEditorUpdated: (state: unknown) => Promise; - onEditorStateChanged: (sourcePos: SourcePos) => Promise; + onEditorStateChanged: (sourcePos: SourcePos, selectedText: string) => Promise; flushEditorUpdates: () => Promise; saveDocument: () => Promise; renderDocument: () => Promise; diff --git a/packages/editor-ui/src/context/context.ts b/packages/editor-ui/src/context/context.ts index 0268851bb..c6cd85329 100644 --- a/packages/editor-ui/src/context/context.ts +++ b/packages/editor-ui/src/context/context.ts @@ -147,6 +147,12 @@ function editorPrefs(provider: () => EditorPrefs): EditorUIPrefs { equationPreview(): boolean { return provider().prefs().equationPreview; }, + showWordCount(): boolean { + return provider().prefs().showWordCount; + }, + wordCountIncludeCodeCells(): boolean { + return provider().prefs().wordCountIncludeCodeCells; + }, packageListingEnabled(): boolean { return provider().prefs().packageListingEnabled; }, diff --git a/packages/editor-ui/src/editor/Editor.tsx b/packages/editor-ui/src/editor/Editor.tsx index 18fca6519..bd9087f5c 100644 --- a/packages/editor-ui/src/editor/Editor.tsx +++ b/packages/editor-ui/src/editor/Editor.tsx @@ -328,6 +328,9 @@ export const Editor : React.FC = (props) => { setBlockSelection(context, action) { editorRef.current!.setBlockSelection(context, action); }, + getSelectedText() { + return editorRef.current!.getSelectedText(); + }, getFindReplace() { return editorRef.current?.getFindReplace(); }, diff --git a/packages/editor/src/api/ui-types.ts b/packages/editor/src/api/ui-types.ts index cb534d09f..a75c0eac6 100644 --- a/packages/editor/src/api/ui-types.ts +++ b/packages/editor/src/api/ui-types.ts @@ -148,6 +148,8 @@ export interface EditorUIPrefs { darkMode: () => boolean; listSpacing: () => ListSpacing; equationPreview: () => boolean; + showWordCount: () => boolean; + wordCountIncludeCodeCells: () => boolean; packageListingEnabled: () => boolean; tabKeyMoveFocus: () => boolean; emojiSkinTone: () => SkinTone; diff --git a/packages/editor/src/behaviors/word_count.css b/packages/editor/src/behaviors/word_count.css new file mode 100644 index 000000000..5a72dd581 --- /dev/null +++ b/packages/editor/src/behaviors/word_count.css @@ -0,0 +1,12 @@ +.pm-word-count-badge { + float: right; + margin-left: 1em; + font-size: 0.7em; + font-weight: normal; + font-style: normal; + line-height: 1.6; + opacity: 0.65; + user-select: none; + pointer-events: none; + white-space: nowrap; +} diff --git a/packages/editor/src/behaviors/word_count.ts b/packages/editor/src/behaviors/word_count.ts new file mode 100644 index 000000000..0e36a55c3 --- /dev/null +++ b/packages/editor/src/behaviors/word_count.ts @@ -0,0 +1,153 @@ +/* + * word_count.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { Plugin, PluginKey, EditorState, Transaction, EditorStateConfig } from 'prosemirror-state'; +import { Schema, Node as ProsemirrorNode } from 'prosemirror-model'; +import { DecorationSet, Decoration } from 'prosemirror-view'; + +import { findChildrenByType } from 'prosemirror-utils'; + +import { wordBreaker } from 'core'; + +import { Extension, ExtensionContext } from '../api/extension'; +import { EditorUIPrefs } from '../api/ui-types'; +import { findTopLevelBodyNodes } from '../api/node'; +import { kSetMarkdownTransaction, kThemeChangedTransaction } from '../api/transaction'; + +import './word_count.css'; + +// node types whose text is code/raw rather than prose +const kExcludedNodeTypes = ['rmd_chunk', 'code_block', 'raw_block', 'yaml_metadata']; + +const wb = wordBreaker(); + +const extension = (context: ExtensionContext): Extension => { + const { ui } = context; + + return { + plugins: (schema: Schema) => { + return [wordCountPlugin(schema, ui.prefs)]; + }, + }; +}; + +function wordCountPlugin(schema: Schema, prefs: EditorUIPrefs) { + const key = new PluginKey('word-count'); + + function decorationsForDoc(state: EditorState): DecorationSet { + if (!prefs.showWordCount()) { + return DecorationSet.empty; + } + + const includeCode = prefs.wordCountIncludeCodeCells(); + + // top-level headings in document order + const headings = findTopLevelBodyNodes(state.doc, node => node.type === schema.nodes.heading); + + // sections are bounded by the body node (footnotes / annotations live in + // sibling nodes after the body and should not be attributed to a section) + const body = findChildrenByType(state.doc, schema.nodes.body, false)[0]; + const bodyEnd = body ? body.pos + body.node.nodeSize - 1 : state.doc.content.size; + + const decorations: Decoration[] = []; + headings.forEach((heading, i) => { + const level = heading.node.attrs.level as number; + + // section content runs from just after this heading to the next heading + // of equal-or-higher level (or the end of the body) + const from = heading.pos + heading.node.nodeSize; + let to = bodyEnd; + for (let j = i + 1; j < headings.length; j++) { + if ((headings[j].node.attrs.level as number) <= level) { + to = headings[j].pos; + break; + } + } + + const words = countWords(state.doc, from, to, includeCode); + decorations.push( + Decoration.widget(heading.pos + 1, () => badgeElement(words), { + key: `word-count-${words}`, + side: -1, + ignoreSelection: true, + stopEvent: () => true, + }), + ); + }); + + return DecorationSet.create(state.doc, decorations); + } + + return new Plugin({ + key, + + state: { + init(_config: EditorStateConfig, instance: EditorState) { + return decorationsForDoc(instance); + }, + + apply(tr: Transaction, set: DecorationSet, _oldState: EditorState, newState: EditorState) { + // rebuild on a full document replace, a theme/prefs change (the host + // dispatches a theme-changed transaction whenever prefs are applied), + // or any change to the document; otherwise just map positions + if (tr.getMeta(kSetMarkdownTransaction) || tr.getMeta(kThemeChangedTransaction) || tr.docChanged) { + return decorationsForDoc(newState); + } else { + return set.map(tr.mapping, tr.doc); + } + }, + }, + + props: { + decorations(state: EditorState) { + return key.getState(state); + }, + }, + }); +} + +// count prose words in [from, to), skipping code/raw nodes unless includeCode +function countWords(doc: ProsemirrorNode, from: number, to: number, includeCode: boolean): number { + if (to <= from) { + return 0; + } + let text = ''; + doc.nodesBetween(from, to, (node, pos) => { + if (!includeCode && kExcludedNodeTypes.includes(node.type.name)) { + return false; // don't descend into (or count) code/raw content + } + if (node.isText) { + const start = Math.max(from, pos); + const end = Math.min(to, pos + node.nodeSize); + text += node.text!.slice(start - pos, end - pos); + } else if (node.type.name === 'hard_break') { + text += ' '; // keep words separated across hard line breaks + } else if (node.isBlock) { + text += ' '; // keep words in adjacent blocks separate + } + return true; + }); + return wb.breakWords(text).length; +} + +function badgeElement(words: number): HTMLElement { + const badge = window.document.createElement('span'); + badge.className = 'pm-word-count-badge pm-light-text-color'; + badge.contentEditable = 'false'; + badge.textContent = `${words.toLocaleString()} ${words === 1 ? 'word' : 'words'}`; + return badge; +} + +export default extension; diff --git a/packages/editor/src/editor/editor-extensions.ts b/packages/editor/src/editor/editor-extensions.ts index 1ca820bae..141dc3ca8 100644 --- a/packages/editor/src/editor/editor-extensions.ts +++ b/packages/editor/src/editor/editor-extensions.ts @@ -70,6 +70,7 @@ import behaviorTrailingP from '../behaviors/trailing_p'; import behaviorEmptyMark from '../behaviors/empty_mark'; import behaviorEscapeMark from '../behaviors/escape_mark'; import behaviorOutline from '../behaviors/outline'; +import behaviorWordCount from '../behaviors/word_count'; import beahviorCodeBlockInput from '../behaviors/code_block_input'; import behaviorPasteText from '../behaviors/paste'; import behaviorBottomPadding from '../behaviors/bottom_padding'; @@ -161,6 +162,7 @@ export function initExtensions( behaviorEmptyMark, behaviorEscapeMark, behaviorOutline, + behaviorWordCount, beahviorCodeBlockInput, behaviorPasteText, behaviorBottomPadding, diff --git a/packages/editor/src/editor/editor.ts b/packages/editor/src/editor/editor.ts index 5773b0fea..dc9508ac9 100644 --- a/packages/editor/src/editor/editor.ts +++ b/packages/editor/src/editor/editor.ts @@ -302,6 +302,9 @@ export interface EditorOperations { getCodeViewActiveBlockContext() : CodeViewActiveBlockContext | undefined; setBlockSelection(context: CodeViewActiveBlockContext, action: CodeViewSelectionAction) : void; + // selection + getSelectedText() : string; + // subsystems getFindReplace() : EditorFindReplace | undefined @@ -791,7 +794,10 @@ export class Editor { } public getSelectedText(): string { - return this.state.doc.textBetween(this.state.selection.from, this.state.selection.to); + const { from, to } = this.state.selection; + if (from === to) return ''; + // join blocks with blank lines; preserve hard_break as a newline but omit other leaf nodes + return this.state.doc.textBetween(from, to, '\n\n', leaf => leaf.type.name === 'hard_break' ? '\n' : ''); } public replaceSelection(value: string): void { diff --git a/packages/quarto-core/src/markdown/parsers/markdownit.ts b/packages/quarto-core/src/markdown/parsers/markdownit.ts index 377a510b0..783fb8f41 100644 --- a/packages/quarto-core/src/markdown/parsers/markdownit.ts +++ b/packages/quarto-core/src/markdown/parsers/markdownit.ts @@ -48,18 +48,28 @@ export function markdownitParser() : Parser { md.use(yamlPlugin); md.use(divPlugin); - // inline parser - const mdInline = MarkdownIt("commonmark"); - const mdToText = (markdown: string ) => { - const tokens = mdInline.parseInline(markdown, {}); - return tokensToText(tokens); - } - return cachingParser((doc: Document) => { - return parseDocument(md, mdToText, doc.getText()); + return parseDocument(md, markdownToText, doc.getText()); }) } +// shared commonmark inline parser used to reduce a span of markdown to its plain +// text (e.g. so that `[label](http://url)` becomes `label`). lazily constructed +// so importers that only need the parser don't pay for it. +let mdInline: MarkdownIt | undefined; + +/** + * Reduce a span of (inline) markdown to its plain text, discarding markup such + * as links, emphasis and code span delimiters. Used both when parsing headers + * and by consumers that need a markup-free string (e.g. word counting). + */ +export function markdownToText(markdown: string): string { + if (!mdInline) { + mdInline = MarkdownIt("commonmark"); + } + return tokensToText(mdInline.parseInline(markdown, {})); +} + type MarkdownToPlainText = (markdown: string) => string; function parseDocument(