├── .gitignore ├── readme_media └── editor_with_preview.png ├── src ├── err.ts ├── constants.ts ├── test │ ├── suite │ │ ├── extension.test.ts │ │ └── index.ts │ └── runTest.ts ├── resources.ts ├── types.ts ├── notebook.ts ├── check_codebraid.ts ├── util.ts ├── pandoc_info.ts ├── pandoc_settings.ts ├── pandoc_util.ts ├── pandoc_defaults_file.ts ├── pandoc_build_configs.ts └── extension.ts ├── pandoc ├── readers │ ├── gfm.lua │ ├── org.lua │ ├── rst.lua │ ├── latex.lua │ ├── markdown.lua │ ├── textile.lua │ ├── commonmark.lua │ ├── commonmark_x.lua │ ├── markdown_mmd.lua │ ├── markdown_strict.lua │ └── markdown_phpextra.lua ├── lib │ ├── reader_generator.py │ ├── readerlib.lua │ └── sourceposlib.lua └── filters │ ├── show_raw.lua │ ├── sourcepos_sync.lua │ └── codebraid_output.lua ├── .vscodeignore ├── .eslintrc.json ├── tsconfig.json ├── LICENSE.txt ├── webpack.config.js ├── media ├── vscode-markdown.css └── codebraid-preview.css ├── scripts └── codebraid-preview.js └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | _dev_research/ 7 | pandoc/defaults 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /readme_media/editor_with_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gpoore/codebraid-preview-vscode/HEAD/readme_media/editor_with_preview.png -------------------------------------------------------------------------------- /src/err.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | export default class CodebraidPreviewError extends Error { 10 | } 11 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as os from 'os'; 10 | import * as process from 'process'; 11 | 12 | 13 | export const homedir = os.homedir(); 14 | export const isWindows = process.platform === 'win32'; 15 | -------------------------------------------------------------------------------- /pandoc/readers/gfm.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'gfm' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/org.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'org' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/rst.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'rst' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/latex.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'latex' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/markdown.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'markdown' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/textile.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'textile' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/commonmark.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'commonmark' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/commonmark_x.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'commonmark_x' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/markdown_mmd.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'markdown_mmd' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/markdown_strict.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'markdown_strict' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /pandoc/readers/markdown_phpextra.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | local format = 'markdown_phpextra' 9 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 10 | 11 | Extensions = readerlib.getExtensions(format) 12 | 13 | function Reader(sources, opts) 14 | return readerlib.read(sources, format, opts) 15 | end 16 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/** 4 | node_modules/** 5 | !node_modules/katex/dist/*.md 6 | !node_modules/katex/dist/*.txt 7 | !node_modules/katex/dist/*.min.js 8 | !node_modules/katex/dist/*.min.css 9 | !node_modules/katex/dist/fonts/** 10 | !node_modules/@vscode/codicons/dist/*.css 11 | !node_modules/@vscode/codicons/dist/*.ttf 12 | src/** 13 | .gitignore 14 | .yarnrc 15 | webpack.config.js 16 | vsc-extension-quickstart.md 17 | **/tsconfig.json 18 | **/.eslintrc.json 19 | **/*.map 20 | **/*.ts 21 | _dev_research/** 22 | readme_media/** 23 | pandoc/defaults/** 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | }, 19 | "ignorePatterns": [ 20 | "out", 21 | "dist", 22 | "**/*.d.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2021", 5 | "lib": [ 6 | "ES2022" 7 | ], 8 | "sourceMap": true, 9 | "rootDir": "src", 10 | "strict": true /* enable all strict type-checking options */ 11 | /* Additional Checks */ 12 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 13 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 14 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | ".vscode-test", 19 | "_dev_research" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from '@vscode/test-electron'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests'); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /pandoc/lib/reader_generator.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | formats = [ 5 | 'commonmark', 'commonmark_x', 'gfm', 6 | 'markdown', 'markdown_mmd', 'markdown_phpextra', 'markdown_strict', 7 | 'latex', 8 | 'org', 9 | 'rst', 10 | 'textile' 11 | ] 12 | 13 | template = '''\ 14 | -- Copyright (c) 2023, Geoffrey M. Poore 15 | -- All rights reserved. 16 | -- 17 | -- Licensed under the BSD 3-Clause License: 18 | -- http://opensource.org/licenses/BSD-3-Clause 19 | -- 20 | 21 | local format = '' 22 | local readerlib = dofile(pandoc.path.join{pandoc.path.directory(PANDOC_SCRIPT_FILE), '../lib/readerlib.lua'}) 23 | 24 | Extensions = readerlib.getExtensions(format) 25 | 26 | function Reader(sources, opts) 27 | return readerlib.read(sources, format, opts) 28 | end 29 | ''' 30 | 31 | for format in formats: 32 | file_path = pathlib.Path(f'../readers/{format}.lua') 33 | file_path.write_text(template.replace('', format), encoding='utf8') 34 | -------------------------------------------------------------------------------- /pandoc/filters/show_raw.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2022, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | -- Pandoc Lua filter that converts non-HTML raw nodes into a form that can 9 | -- be displayed in an HTML preview. 10 | -- 11 | 12 | 13 | function RawNode(elem, isInline) 14 | if elem.format:lower() == 'html' then 15 | return 16 | end 17 | local attr = pandoc.Attr("", {'pandoc-raw'}, {{'pandoc-raw-attr', '{=' .. elem.format .. '}'}}) 18 | if isInline then 19 | return pandoc.Span(pandoc.Code(elem.text), attr) 20 | else 21 | return pandoc.Div(pandoc.CodeBlock(elem.text), attr) 22 | end 23 | end 24 | 25 | function RawInline(elem) 26 | return RawNode(elem, true) 27 | end 28 | 29 | function RawBlock(elem) 30 | return RawNode(elem, false) 31 | end 32 | 33 | 34 | return { 35 | { 36 | RawInline = RawInline, 37 | RawBlock = RawBlock, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | export const resourceRoots: Array = [ 10 | 'media', 11 | 'scripts', 12 | 'node_modules/katex/dist', 13 | 'node_modules/@vscode/codicons/dist', 14 | ]; 15 | 16 | 17 | export const webviewResources: {[key: string]: string} = { 18 | katex: 'node_modules/katex/dist', 19 | vscodeCodiconCss: 'node_modules/@vscode/codicons/dist/codicon.css', 20 | vscodeCss: 'media/vscode-markdown.css', 21 | codebraidCss: 'media/codebraid-preview.css', 22 | codebraidPreviewJs: 'scripts/codebraid-preview.js', 23 | }; 24 | 25 | 26 | export const pandocResources: {[key: string]: string} = { 27 | sourceposSyncFilter: 'pandoc/filters/sourcepos_sync.lua', 28 | showRawFilter: 'pandoc/filters/show_raw.lua', 29 | codebraidOutputFilter: 'pandoc/filters/codebraid_output.lua', 30 | readersRoot: 'pandoc/readers', 31 | }; 32 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import type * as vscode from 'vscode'; 10 | import type { PandocBuildConfigCollections } from './pandoc_build_configs'; 11 | import type { PandocInfo } from './pandoc_info'; 12 | 13 | export type ExtensionState = { 14 | 'isWindows': boolean, 15 | 'context': vscode.ExtensionContext, 16 | 'config': vscode.WorkspaceConfiguration, 17 | 'pandocInfo': PandocInfo, 18 | 'pandocBuildConfigCollections': PandocBuildConfigCollections, 19 | 'normalizedExtraLocalResourceRoots': Array, 20 | 'resourceRootUris': Array, 21 | 'log': (message: string) => void, 22 | 'statusBarItems': { 23 | 'openPreview': vscode.StatusBarItem 24 | 'runCodebraid': vscode.StatusBarItem, 25 | 'scrollSyncMode': vscode.StatusBarItem, 26 | 'exportDocument': vscode.StatusBarItem, 27 | }, 28 | 'statusBarConfig': { 29 | scrollPreviewWithEditor: boolean | undefined, 30 | scrollEditorWithPreview: boolean | undefined, 31 | setCodebraidRunningExecute: () => void, 32 | setCodebraidRunningNoExecute: () => void, 33 | setCodebraidWaiting: () => void, 34 | setDocumentExportRunning: () => void, 35 | setDocumentExportWaiting: () => void, 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/notebook.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import type * as vscode from 'vscode'; 10 | 11 | 12 | export class NotebookTextEditorDocument { 13 | private notebook: vscode.NotebookDocument; 14 | 15 | constructor(notebook: vscode.NotebookDocument) { 16 | this.notebook = notebook; 17 | } 18 | 19 | get fileName() { 20 | return this.notebook.uri.fsPath; 21 | } 22 | 23 | get isUntitled() { 24 | return this.notebook.isUntitled; 25 | } 26 | 27 | get uri() { 28 | return this.notebook.uri; 29 | } 30 | 31 | get isDirty() { 32 | return this.notebook.isDirty; 33 | } 34 | } 35 | 36 | export class NotebookTextEditor { 37 | // Wrapper around `vscode.NotebookEditor` to make it more like 38 | // `vscode.TextEditor` 39 | readonly isNotebook = true; 40 | private notebookEditor: vscode.NotebookEditor; 41 | document: NotebookTextEditorDocument; 42 | isIpynb: boolean; 43 | 44 | constructor(notebookEditor: vscode.NotebookEditor) { 45 | this.notebookEditor = notebookEditor; 46 | this.document = new NotebookTextEditorDocument(notebookEditor.notebook); 47 | this.isIpynb = this.document.fileName.endsWith('.ipynb'); 48 | } 49 | 50 | get viewColumn() { 51 | return this.notebookEditor.viewColumn; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Unless otherwise noted in individual source files, all code is licensed under 2 | the BSD 3-Clause License: 3 | 4 | 5 | BSD 3-Clause License 6 | 7 | Copyright (c) 2022, Geoffrey M. Poore 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | 13 | * Redistributions of source code must retain the above copyright notice, this 14 | list of conditions and the following disclaimer. 15 | 16 | * Redistributions in binary form must reproduce the above copyright notice, 17 | this list of conditions and the following disclaimer in the documentation 18 | and/or other materials provided with the distribution. 19 | 20 | * Neither the name of the copyright holder nor the names of its 21 | contributors may be used to endorse or promote products derived from 22 | this software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 25 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 26 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 27 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 28 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 29 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 30 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 31 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 32 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /src/check_codebraid.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as child_process from 'child_process'; 10 | 11 | import { VersionArray, versionIsAtLeast, versionToString } from './util'; 12 | 13 | 14 | const minCodebraidVersion: VersionArray = [0, 10, 3]; 15 | export const minCodebraidVersionString = versionToString(minCodebraidVersion); 16 | 17 | const compatibleCodebraidPaths: Set = new Set(); 18 | 19 | 20 | 21 | 22 | export async function checkCodebraidVersion(codebraidCommand: Array) : Promise { 23 | const codebraidPath = codebraidCommand.join(' '); 24 | if (compatibleCodebraidPaths.has(codebraidPath)) { 25 | return true; 26 | } 27 | const executable = codebraidCommand[0]; 28 | const args = codebraidCommand.slice(1); 29 | args.push('--version'); 30 | const status = await new Promise((resolve) => { 31 | child_process.execFile(executable, args, {shell: true, encoding: 'utf8'}, (error, stdout, stderr) => { 32 | if (error) { 33 | resolve(undefined); 34 | } else { 35 | let match = /(? {return undefined;}); 48 | if (status) { 49 | compatibleCodebraidPaths.add(codebraidPath); 50 | } 51 | return status; 52 | } 53 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | //@ts-check 8 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 9 | 10 | /** @type WebpackConfig */ 11 | const extensionConfig = { 12 | target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 14 | 15 | entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 16 | output: { 17 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 18 | path: path.resolve(__dirname, 'dist'), 19 | filename: 'extension.js', 20 | libraryTarget: 'commonjs2' 21 | }, 22 | externals: { 23 | vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 24 | // modules added here also need to be added in the .vscodeignore file 25 | }, 26 | resolve: { 27 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 28 | extensions: ['.ts', '.js'] 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ts$/, 34 | exclude: /node_modules/, 35 | use: [ 36 | { 37 | loader: 'ts-loader' 38 | } 39 | ] 40 | } 41 | ] 42 | }, 43 | devtool: 'nosources-source-map', 44 | infrastructureLogging: { 45 | level: "log", // enables logging required for problem matchers 46 | }, 47 | }; 48 | module.exports = [ extensionConfig ]; 49 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | export function countNewlines(text: string) { 10 | let newlines: number = 0; 11 | for (const c of text) { 12 | if (c === '\n') { 13 | newlines += 1; 14 | } 15 | } 16 | return newlines; 17 | } 18 | 19 | 20 | export class FileExtension { 21 | fullExtension: string; 22 | outerExtension: string; 23 | innerExtension: string; 24 | isDoubleExtension: boolean; 25 | 26 | constructor(fileName: string) { 27 | const match = fileName.match(this.fileNameExtensionRegex) || ['', '', '', '']; 28 | this.fullExtension = match[0]; 29 | this.outerExtension = match[2]; 30 | this.innerExtension = match[1]; 31 | this.isDoubleExtension = Boolean(match[1]); 32 | } 33 | 34 | private fileNameExtensionRegex = /(\.[0-9a-z_]+(?:[+-][0-9a-z_]+)*)?(\.[0-9a-z_]+)$/; 35 | 36 | toString() : string { 37 | return `*${this.fullExtension}`; 38 | } 39 | } 40 | 41 | 42 | export type VersionArray = [number, number, number] | [number, number, number, number]; 43 | 44 | export function versionToString(version: VersionArray) : string { 45 | const major: number = version[0]; 46 | const minor: number = version[1]; 47 | const patch: number = version[2]; 48 | let build: number | undefined; 49 | if (version.length === 4) { 50 | build = version[3]; 51 | } 52 | let versionString: string = `${major}.${minor}.${patch}`; 53 | if (build) { 54 | versionString += `.${build}`; 55 | } 56 | return versionString; 57 | } 58 | 59 | export function versionIsAtLeast(version: VersionArray, minVersion: VersionArray) : boolean { 60 | let normalizedVersion: VersionArray; 61 | let normalizedMinVersion: VersionArray; 62 | if (version.length === minVersion.length) { 63 | normalizedVersion = version; 64 | normalizedMinVersion = minVersion; 65 | } else { 66 | normalizedVersion = [...version]; 67 | while (normalizedVersion.length < 4) { 68 | normalizedVersion.push(0); 69 | } 70 | normalizedMinVersion = [...minVersion]; 71 | while (normalizedMinVersion.length < 4) { 72 | normalizedMinVersion.push(0); 73 | } 74 | } 75 | for (let n = 0; n < normalizedVersion.length; n++) { 76 | if (normalizedVersion[n] < normalizedMinVersion[n]) { 77 | return false; 78 | } 79 | if (normalizedVersion[n] > normalizedMinVersion[n] || n === normalizedMinVersion.length - 1) { 80 | return true; 81 | } 82 | } 83 | return false; 84 | } 85 | -------------------------------------------------------------------------------- /src/pandoc_info.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as vscode from 'vscode'; 10 | import * as child_process from 'child_process'; 11 | import { VersionArray, versionToString, versionIsAtLeast } from './util'; 12 | 13 | 14 | const minPandocVersionRecommended: VersionArray = [3, 1, 1]; 15 | const minPandocVersionCodebraidWrappers: VersionArray = [3, 1, 1]; 16 | 17 | export type PandocInfo = { 18 | executable: string, 19 | extraEnv: {[key: string]: string}, 20 | defaultDataDir: string | undefined; 21 | version: VersionArray, 22 | versionString: string, 23 | major: number, 24 | minor: number, 25 | patch: number, 26 | build: number | undefined, 27 | minVersionRecommended: VersionArray, 28 | minVersionRecommendedString: string, 29 | isMinVersionRecommended: boolean, 30 | supportsCodebraidWrappers: boolean, 31 | } | null | undefined; 32 | 33 | export async function getPandocInfo(config: vscode.WorkspaceConfiguration) : Promise { 34 | // Any quoting of executable is supplied by user in config 35 | const executable = config.pandoc.executable; 36 | const extraEnv = config.pandoc.extraEnv; 37 | const args = ['--version']; 38 | const maybeVersionAndDataDir = await new Promise<[VersionArray, string | undefined] | null | undefined>((resolve) => { 39 | child_process.execFile(executable, args, {shell: true, encoding: 'utf8'}, (error, stdout, stderr) => { 40 | if (error) { 41 | resolve(undefined); 42 | } else { 43 | let versionMatch = /(? 0) { 53 | version.push(build); 54 | } 55 | let dataDirMatch = /User data directory:\s+(\S[^\r\n]*)\r?\n/.exec(stdout); 56 | if (!dataDirMatch) { 57 | resolve([version, undefined]); 58 | } else { 59 | let dataDir: string | undefined = dataDirMatch[1].replaceAll('\\', '/'); 60 | if (dataDir.match(/[\\`^$%"']/)) { 61 | dataDir = undefined; 62 | } 63 | resolve([version, dataDir]); 64 | } 65 | } 66 | } 67 | }); 68 | }).catch((reason: any) => {return undefined;}); 69 | if (!maybeVersionAndDataDir) { 70 | return maybeVersionAndDataDir; 71 | } 72 | const version = maybeVersionAndDataDir[0]; 73 | const defaultDataDir = maybeVersionAndDataDir[1]; 74 | const pandocInfo = { 75 | executable: executable, 76 | extraEnv: extraEnv, 77 | defaultDataDir: defaultDataDir, 78 | version: version, 79 | versionString: versionToString(version), 80 | major: version[0], 81 | minor: version[1], 82 | patch: version[2], 83 | build: version.length < 4 ? undefined : version[3], 84 | minVersionRecommended: minPandocVersionRecommended, 85 | minVersionRecommendedString: versionToString(minPandocVersionRecommended), 86 | isMinVersionRecommended: versionIsAtLeast(version, minPandocVersionRecommended), 87 | supportsCodebraidWrappers: versionIsAtLeast(version, minPandocVersionCodebraidWrappers), 88 | }; 89 | return pandocInfo; 90 | } 91 | -------------------------------------------------------------------------------- /src/pandoc_settings.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | 10 | 11 | // Relative to extension directory 12 | export const pandocReaderWrapperPath = 'pandoc/readers'; 13 | // Relative to document directory (cwd) 14 | export const processedDefaultsRelativeFileName = '_codebraid/temp_defaults/_codebraid_preview.yaml'; 15 | export const extractedMediaDirectory = '_codebraid/extracted_media'; 16 | 17 | 18 | export const fallbackFileExtensionToReaderMap: Map = new Map([ 19 | ['.cbmd', 'commonmark_x'], 20 | ['.markdown','commonmark_x'], 21 | ['.md', 'commonmark_x'], 22 | ['.org', 'org'], 23 | ['.rst', 'rst'], 24 | ['.tex', 'latex'], 25 | ['.textile', 'textile'], 26 | ]); 27 | 28 | 29 | export const builtinToFileExtensionMap: Map = new Map([ 30 | ['beamer', '.tex'], 31 | ['commonmark', '.md'], 32 | ['commonmark_x', '.md'], 33 | ['context', '.tex'], 34 | ['docbook', '.dbk'], 35 | ['docbook4', '.dbk'], 36 | ['docbook5', '.dbk'], 37 | ['docx', '.docx'], 38 | ['epub', '.epub'], 39 | ['epub2', '.epub'], 40 | ['epub3', '.epub'], 41 | ['gfm', '.md'], 42 | ['html', '.html'], 43 | ['html4', '.html'], 44 | ['html5', '.html'], 45 | ['ipynb', '.ipynb'], 46 | ['json', '.json'], 47 | ['latex', '.tex'], 48 | ['markdown_mmd', '.md'], 49 | ['markdown_phpextra', '.md'], 50 | ['markdown_strict', '.md'], 51 | ['odt', '.odt'], 52 | ['org', '.org'], 53 | ['pdf', '.pdf'], 54 | ['plain', '.txt'], 55 | ['pptx', '.pptx'], 56 | ['revealjs', '.html'], 57 | ['rst', '.rst'], 58 | ['rtf', '.rtf'], 59 | ['s5', '.html'], 60 | ['slideous', '.html'], 61 | ['slidy', '.html'], 62 | ['textile', '.textile'], 63 | ]); 64 | 65 | 66 | const exportFileExtensions: Array = Array.from(new Set(builtinToFileExtensionMap.values())).sort(); 67 | export const defaultSaveDialogFilter: {[key: string]: [] | [string]} = {}; 68 | export const defaultSaveDialogFileExtensionToFilterKeyMap: Map = new Map(); 69 | defaultSaveDialogFilter['*.*'] = []; 70 | for (const ext of exportFileExtensions) { 71 | const key = `*${ext}`; 72 | const value = ext.slice(1); 73 | defaultSaveDialogFilter[key] = [value]; 74 | defaultSaveDialogFileExtensionToFilterKeyMap.set(ext, key); 75 | } 76 | 77 | 78 | export const commonmarkReaders: Set = new Set([ 79 | 'commonmark', 80 | 'commonmark_x', 81 | 'gfm', 82 | ]); 83 | 84 | export const markdownReaders: Set = new Set([ 85 | ...commonmarkReaders, 86 | 'markdown', 'markdown_mmd', 'markdown_phpextra', 'markdown_strict', 87 | ]); 88 | 89 | export const readersWithCodebraid = markdownReaders; 90 | 91 | export const readersWithWrapper: Set = new Set([ 92 | ...markdownReaders, 93 | 'latex', 94 | 'org', 95 | 'rst', 96 | 'textile' 97 | ]); 98 | 99 | export const readersWithPandocSourcepos: Set = new Set([ 100 | ...commonmarkReaders 101 | ]); 102 | export const readersWithWrapperSourcepos: Set = readersWithWrapper; 103 | export const readersWithSourcepos: Set = new Set([ 104 | ...readersWithPandocSourcepos, 105 | ...readersWithWrapperSourcepos, 106 | ]); 107 | -------------------------------------------------------------------------------- /src/pandoc_util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as vscode from 'vscode'; 10 | 11 | import { homedir, isWindows } from './constants'; 12 | import CodebraidPreviewError from './err'; 13 | import { pandocReaderWrapperPath, readersWithWrapper, readersWithCodebraid, markdownReaders, commonmarkReaders } from './pandoc_settings'; 14 | 15 | 16 | 17 | 18 | export const pandocBuiltinReaderWriterRegex = /^([0-9a-z_]+)((?:[+-][0-9a-z_]+)*)$/; 19 | const customLuaRegex = /^(.+?\.lua)((?:[+-][0-9a-z_]+)*)$/; 20 | 21 | class PandocIOProcessor { 22 | name: string; 23 | asPandocString: string; 24 | isBuiltin: boolean; 25 | base: string; 26 | builtinBase: string | undefined; 27 | protected customExpanded: string | undefined; 28 | protected extensions: string; 29 | protected isLua: boolean; 30 | 31 | constructor(format: string, name?: string) { 32 | let processedFormat: string; 33 | const formatWithoutQuotes = format.replaceAll(`'`, ``).replaceAll(`"`, ``); 34 | const builtinMatch = formatWithoutQuotes.match(pandocBuiltinReaderWriterRegex); 35 | if (builtinMatch) { 36 | this.isBuiltin = true; 37 | processedFormat = formatWithoutQuotes; 38 | this.base = builtinMatch[1]; 39 | this.builtinBase = this.base; 40 | this.extensions = builtinMatch[2]; 41 | this.isLua = false; 42 | } else { 43 | this.isBuiltin = false; 44 | processedFormat = format; 45 | const customLuaMatch = formatWithoutQuotes.match(customLuaRegex); 46 | if (!customLuaMatch) { 47 | if (name) { 48 | throw new CodebraidPreviewError( 49 | `Unrecognized builtin reader/writer name, or invalid Lua reader/writer name: "${format}"` 50 | ); 51 | } else { 52 | throw new CodebraidPreviewError([ 53 | `Unrecognized builtin reader/writer name, or invalid Lua reader/writer name: "${format}"`, 54 | `(for settings, this can be caused by creating a preview/export with a descriptive name and forgetting to define a "writer" field)`, 55 | ].join(' ')); 56 | } 57 | } 58 | this.base = customLuaMatch[1]; 59 | this.extensions = customLuaMatch[2]; 60 | this.isLua = true; 61 | if (isWindows && (format.startsWith('~/') || format.startsWith('~\\'))) { 62 | this.customExpanded = `"${homedir}"${format.slice(1)}`; 63 | } 64 | } 65 | this.asPandocString = processedFormat; 66 | this.name = name ? name : processedFormat; 67 | } 68 | 69 | toString() : string { 70 | return this.name; 71 | } 72 | }; 73 | 74 | export class PandocReader extends PandocIOProcessor { 75 | isMarkdown: boolean; 76 | isCommonmark: boolean; 77 | canSourcepos: boolean; 78 | canFileScope: boolean; 79 | canCodebraid: boolean; 80 | hasExtensionsSourcepos: boolean; 81 | hasExtensionsFileScope: boolean; 82 | asArg: string; 83 | asArgNoWrapper: string; 84 | asCodebraidArg: string; 85 | hasWrapper: boolean; 86 | 87 | constructor(format: string, context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration) { 88 | super(format); 89 | 90 | this.hasExtensionsSourcepos = this.extensions.indexOf('+sourcepos') > this.extensions.indexOf('-sourcepos'); 91 | this.hasExtensionsFileScope = this.extensions.indexOf('+file_scope') > this.extensions.indexOf('-file_scope'); 92 | this.isMarkdown = this.builtinBase !== undefined && markdownReaders.has(this.builtinBase); 93 | this.isCommonmark = this.builtinBase !== undefined && commonmarkReaders.has(this.builtinBase); 94 | this.canSourcepos = (this.builtinBase !== undefined && readersWithWrapper.has(this.builtinBase)) || this.hasExtensionsSourcepos; 95 | this.canFileScope = (this.builtinBase !== undefined && readersWithWrapper.has(this.builtinBase)) || this.hasExtensionsFileScope; 96 | this.canCodebraid = this.builtinBase !== undefined && readersWithCodebraid.has(this.builtinBase); 97 | this.hasWrapper = this.builtinBase !== undefined && readersWithWrapper.has(this.builtinBase); 98 | 99 | if (this.hasExtensionsSourcepos) { 100 | if (!this.isCommonmark) { 101 | throw new CodebraidPreviewError( 102 | `Pandoc reader "${this.base}" is not based on CommonMark and does not support the "sourcepos" extension` 103 | ); 104 | } else if (!config.pandoc.preferPandocSourcepos) { 105 | throw new CodebraidPreviewError( 106 | `Pandoc reader "${this.base}" uses the "sourcepos" extension, but setting "codebraid.preview.pandoc.preferPandocSourcepos" is "false"` 107 | ); 108 | } 109 | } 110 | 111 | if (this.hasWrapper) { 112 | let readerWithWrapperAndExtensions = `${this.builtinBase}.lua${this.extensions}`; 113 | if (this.canSourcepos) { 114 | if (!this.hasExtensionsSourcepos) { 115 | readerWithWrapperAndExtensions += '+sourcepos'; 116 | } 117 | if (!config.pandoc.preferPandocSourcepos) { 118 | readerWithWrapperAndExtensions += '-prefer_pandoc_sourcepos'; 119 | } 120 | } 121 | const asArg = context.asAbsolutePath(`${pandocReaderWrapperPath}/${readerWithWrapperAndExtensions}`); 122 | // Built-in readers will be unquoted 123 | this.asArg = `"${asArg}"`; 124 | this.asArgNoWrapper = this.asPandocString; 125 | this.asCodebraidArg = this.asArgNoWrapper; 126 | } else { 127 | // A built-in reader without a wrapper needs no quoting. A custom 128 | // reader is from settings "pandoc.build..reader", which 129 | // is required to have any quoting already included. A reader set 130 | // in the Codebraid Preview defaults file is only extracted if it 131 | // is a built-in reader. Thus, there is never a case where a 132 | // user-supplied reader is not required to be quoted by the user. 133 | this.asArg = this.customExpanded ? this.customExpanded : this.asPandocString; 134 | this.asArgNoWrapper = this.asArg; 135 | this.asCodebraidArg = this.asArgNoWrapper; 136 | } 137 | } 138 | } 139 | 140 | export class PandocWriter extends PandocIOProcessor { 141 | asArg: string; 142 | asCodebraidArg: string; 143 | 144 | constructor(format: string, alias?: string) { 145 | super(format, alias); 146 | 147 | this.asArg = this.customExpanded ? this.customExpanded : this.asPandocString; 148 | this.asCodebraidArg = this.asArg; 149 | } 150 | } 151 | 152 | export const fallbackHtmlWriter = new PandocWriter('html'); 153 | -------------------------------------------------------------------------------- /media/vscode-markdown.css: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------* 2 | This is the CSS used by VS Code's built-in Markdown preview. 3 | 4 | The VS Code repository is located at https://github.com/microsoft/vscode. This file is from 5 | vscode/extensions/markdown-language-features/media/markdown.css, with latest commit 8943ea4 on 6 | Nov 15, 2023. 7 | 8 | This file is a verbatim copy of the VS Code markdown.css, except for this opening comment. The 9 | original file begins with a Microsoft copyright statement and references the VS Code 10 | License.txt. The License.txt is copied verbatim below: 11 | 12 | 13 | MIT License 14 | 15 | Copyright (c) 2015 - present Microsoft Corporation 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | *--------------------------------------------------------------------------------------------*/ 35 | 36 | 37 | 38 | 39 | /*--------------------------------------------------------------------------------------------- 40 | * Copyright (c) Microsoft Corporation. All rights reserved. 41 | * Licensed under the MIT License. See License.txt in the project root for license information. 42 | *--------------------------------------------------------------------------------------------*/ 43 | 44 | html, body { 45 | font-family: var(--markdown-font-family, -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif); 46 | font-size: var(--markdown-font-size, 14px); 47 | padding: 0 26px; 48 | line-height: var(--markdown-line-height, 22px); 49 | word-wrap: break-word; 50 | } 51 | 52 | body { 53 | padding-top: 1em; 54 | } 55 | 56 | /* Reset margin top for elements */ 57 | h1, h2, h3, h4, h5, h6, 58 | p, ol, ul, pre { 59 | margin-top: 0; 60 | } 61 | 62 | h1, h2, h3, h4, h5, h6 { 63 | font-weight: 600; 64 | margin-top: 24px; 65 | margin-bottom: 16px; 66 | line-height: 1.25; 67 | } 68 | 69 | #code-csp-warning { 70 | position: fixed; 71 | top: 0; 72 | right: 0; 73 | color: white; 74 | margin: 16px; 75 | text-align: center; 76 | font-size: 12px; 77 | font-family: sans-serif; 78 | background-color:#444444; 79 | cursor: pointer; 80 | padding: 6px; 81 | box-shadow: 1px 1px 1px rgba(0,0,0,.25); 82 | } 83 | 84 | #code-csp-warning:hover { 85 | text-decoration: none; 86 | background-color:#007acc; 87 | box-shadow: 2px 2px 2px rgba(0,0,0,.25); 88 | } 89 | 90 | body.scrollBeyondLastLine { 91 | margin-bottom: calc(100vh - 22px); 92 | } 93 | 94 | body.showEditorSelection .code-line { 95 | position: relative; 96 | } 97 | 98 | body.showEditorSelection :not(tr,ul,ol).code-active-line:before, 99 | body.showEditorSelection :not(tr,ul,ol).code-line:hover:before { 100 | content: ""; 101 | display: block; 102 | position: absolute; 103 | top: 0; 104 | left: -12px; 105 | height: 100%; 106 | } 107 | 108 | .vscode-high-contrast.showEditorSelection :not(tr,ul,ol).code-line .code-line:hover:before { 109 | border-left: none; 110 | } 111 | 112 | body.showEditorSelection li.code-active-line:before, 113 | body.showEditorSelection li.code-line:hover:before { 114 | left: -30px; 115 | } 116 | 117 | .vscode-light.showEditorSelection .code-active-line:before { 118 | border-left: 3px solid rgba(0, 0, 0, 0.15); 119 | } 120 | 121 | .vscode-light.showEditorSelection .code-line:hover:before { 122 | border-left: 3px solid rgba(0, 0, 0, 0.40); 123 | } 124 | 125 | .vscode-dark.showEditorSelection .code-active-line:before { 126 | border-left: 3px solid rgba(255, 255, 255, 0.4); 127 | } 128 | 129 | .vscode-dark.showEditorSelection .code-line:hover:before { 130 | border-left: 3px solid rgba(255, 255, 255, 0.60); 131 | } 132 | 133 | .vscode-high-contrast.showEditorSelection .code-active-line:before { 134 | border-left: 3px solid rgba(255, 160, 0, 0.7); 135 | } 136 | 137 | .vscode-high-contrast.showEditorSelection .code-line:hover:before { 138 | border-left: 3px solid rgba(255, 160, 0, 1); 139 | } 140 | 141 | /* Prevent `sub` and `sup` elements from affecting line height */ 142 | sub, 143 | sup { 144 | line-height: 0; 145 | } 146 | 147 | ul ul:first-child, 148 | ul ol:first-child, 149 | ol ul:first-child, 150 | ol ol:first-child { 151 | margin-bottom: 0; 152 | } 153 | 154 | img, video { 155 | max-width: 100%; 156 | max-height: 100%; 157 | } 158 | 159 | a { 160 | text-decoration: none; 161 | } 162 | 163 | a:hover { 164 | text-decoration: underline; 165 | } 166 | 167 | a:focus, 168 | input:focus, 169 | select:focus, 170 | textarea:focus { 171 | outline: 1px solid -webkit-focus-ring-color; 172 | outline-offset: -1px; 173 | } 174 | 175 | p { 176 | margin-bottom: 16px; 177 | } 178 | 179 | li p { 180 | margin-bottom: 0.7em; 181 | } 182 | 183 | ul, 184 | ol { 185 | margin-bottom: 0.7em; 186 | } 187 | 188 | hr { 189 | border: 0; 190 | height: 1px; 191 | border-bottom: 1px solid; 192 | } 193 | 194 | h1 { 195 | font-size: 2em; 196 | margin-top: 0; 197 | padding-bottom: 0.3em; 198 | border-bottom-width: 1px; 199 | border-bottom-style: solid; 200 | } 201 | 202 | h2 { 203 | font-size: 1.5em; 204 | padding-bottom: 0.3em; 205 | border-bottom-width: 1px; 206 | border-bottom-style: solid; 207 | } 208 | 209 | h3 { 210 | font-size: 1.25em; 211 | } 212 | 213 | h4 { 214 | font-size: 1em; 215 | } 216 | 217 | h5 { 218 | font-size: 0.875em; 219 | } 220 | 221 | h6 { 222 | font-size: 0.85em; 223 | } 224 | 225 | table { 226 | border-collapse: collapse; 227 | margin-bottom: 0.7em; 228 | } 229 | 230 | th { 231 | text-align: left; 232 | border-bottom: 1px solid; 233 | } 234 | 235 | th, 236 | td { 237 | padding: 5px 10px; 238 | } 239 | 240 | table > tbody > tr + tr > td { 241 | border-top: 1px solid; 242 | } 243 | 244 | blockquote { 245 | margin: 0; 246 | padding: 2px 16px 0 10px; 247 | border-left-width: 5px; 248 | border-left-style: solid; 249 | border-radius: 2px; 250 | } 251 | 252 | code { 253 | font-family: var(--vscode-editor-font-family, "SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace); 254 | font-size: 1em; 255 | line-height: 1.357em; 256 | } 257 | 258 | body.wordWrap pre { 259 | white-space: pre-wrap; 260 | } 261 | 262 | pre:not(.hljs), 263 | pre.hljs code > div { 264 | padding: 16px; 265 | border-radius: 3px; 266 | overflow: auto; 267 | } 268 | 269 | pre code { 270 | display: inline-block; 271 | color: var(--vscode-editor-foreground); 272 | tab-size: 4; 273 | background: none; 274 | } 275 | 276 | /** Theming */ 277 | 278 | pre { 279 | background-color: var(--vscode-textCodeBlock-background); 280 | border: 1px solid var(--vscode-widget-border); 281 | } 282 | 283 | .vscode-high-contrast h1 { 284 | border-color: rgb(0, 0, 0); 285 | } 286 | 287 | .vscode-light th { 288 | border-color: rgba(0, 0, 0, 0.69); 289 | } 290 | 291 | .vscode-dark th { 292 | border-color: rgba(255, 255, 255, 0.69); 293 | } 294 | 295 | .vscode-light h1, 296 | .vscode-light h2, 297 | .vscode-light hr, 298 | .vscode-light td { 299 | border-color: rgba(0, 0, 0, 0.18); 300 | } 301 | 302 | .vscode-dark h1, 303 | .vscode-dark h2, 304 | .vscode-dark hr, 305 | .vscode-dark td { 306 | border-color: rgba(255, 255, 255, 0.18); 307 | } 308 | -------------------------------------------------------------------------------- /pandoc/filters/sourcepos_sync.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2022, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | -- Pandoc Lua filter for Codebraid Preview scroll sync. 9 | -- 10 | -- Take AST from commonmark_x+sourcepos, and remove all "data-pos" divs and 11 | -- spans except for the first one that occurs on a given input line. For the 12 | -- first div/span that occurs on a given input line, modify its attrs as 13 | -- needed for Codebraid Preview. Insert empty divs at the end of the document 14 | -- with attrs that contain additional information. 15 | -- 16 | 17 | 18 | local minLineNum = 0 19 | local maxLineNum = 0 20 | local trackedLines = {} 21 | local referenceLinesToIds = {} 22 | local nodesWithTrackerSpans = { 23 | Str=true, 24 | Code=true, 25 | } 26 | local nodesWithAttributes = { 27 | CodeBlock=true, 28 | Div=true, 29 | Header=true, 30 | Table=true, 31 | Code=true, 32 | Image=true, 33 | Link=true, 34 | Span=true, 35 | Cell=true, 36 | TableFoot=true, 37 | TableHead=true, 38 | } 39 | local sourceIsStdin = nil 40 | if PANDOC_STATE.input_files[1] == '-' then 41 | sourceIsStdin = true 42 | else 43 | sourceIsStdin = false 44 | end 45 | 46 | 47 | function parseStartEndLineNum(elem, dataPos) 48 | -- Extract start and end line from 'data-pos' attribute value 49 | local startLineStr = nil 50 | local endLineStr = nil 51 | if sourceIsStdin then 52 | if string.find(dataPos, ';', nil, true) == nil then 53 | startLineStr, endLineStr = dataPos:match('^(%d+):%d+%-(%d+):%d+$') 54 | else 55 | startLineStr, endLineStr = dataPos:match('^(%d+):.+;%d+:%d+%-(%d+):%d+$') 56 | end 57 | else 58 | if string.find(dataPos, ';', nil, true) == nil then 59 | startLineStr, endLineStr = dataPos:match('^.*@(%d+):%d+%-(%d+):%d+$') 60 | else 61 | startLineStr, endLineStr = dataPos:match('^.*@(%d+):.+;%d+:%d+%-(%d+):%d+$') 62 | end 63 | end 64 | if startLineStr == nil or endLineStr == nil then 65 | error('Failed to parse sourcepos data. Received "data-pos" = "' .. dataPos .. '"\n') 66 | end 67 | if elem.t ~= 'CodeBlock' then 68 | return tonumber(startLineStr), tonumber(endLineStr) 69 | end 70 | return tonumber(startLineStr), tonumber(endLineStr) - 1 71 | end 72 | 73 | function setCodebraidAttr(elem, startLineNum, endLineNum) 74 | elem.attributes['data-pos'] = nil 75 | if elem.identifier ~= '' then 76 | referenceLinesToIds[startLineNum] = elem.identifier 77 | else 78 | elem.identifier = 'codebraid-sourcepos-' .. tostring(startLineNum) 79 | end 80 | elem.classes:insert('codebraid-sourcepos') 81 | elem.attributes['codebraid-sourcepos-start'] = startLineNum 82 | if minLineNum == 0 or startLineNum < minLineNum then 83 | minLineNum = startLineNum 84 | end 85 | if elem.t == 'CodeBlock' then 86 | if trackedLines[endLineNum] ~= nil then 87 | -- Ending sourcepos may refer to the start of the line following 88 | -- a closing code block fence 89 | endLineNum = endLineNum - 1 90 | end 91 | -- math.floor() -> int 92 | elem.attributes['codebraid-sourcepos-lines'] = math.floor(endLineNum - startLineNum + 1) 93 | end 94 | trackedLines[startLineNum] = true 95 | end 96 | 97 | 98 | function Code(elem) 99 | local dataPos = elem.attributes['data-pos'] 100 | if dataPos == nil then 101 | return nil 102 | end 103 | local startLineNum, endLineNum = parseStartEndLineNum(elem, dataPos) 104 | if trackedLines[startLineNum] ~= nil then 105 | elem.attributes['data-pos'] = nil 106 | return elem 107 | end 108 | setCodebraidAttr(elem, startLineNum, endLineNum) 109 | return elem 110 | end 111 | 112 | 113 | Image = Code 114 | 115 | 116 | function Span(elem) 117 | local dataPos = elem.attributes['data-pos'] 118 | if dataPos == nil then 119 | return nil 120 | end 121 | if #(elem.content) ~= 1 or nodesWithTrackerSpans[elem.content[1].t] == nil then 122 | if elem.identifier == '' and #(elem.classes) == 0 and #(elem.attributes) == 1 then 123 | return elem.content 124 | end 125 | elem.attributes['data-pos'] = nil 126 | return elem 127 | end 128 | local startLineNum, endLineNum = parseStartEndLineNum(elem, dataPos) 129 | if endLineNum > maxLineNum then 130 | maxLineNum = endLineNum 131 | end 132 | if trackedLines[startLineNum] ~= nil then 133 | if elem.identifier == '' and #(elem.classes) == 0 and #(elem.attributes) == 1 then 134 | return elem.content 135 | end 136 | elem.attributes['data-pos'] = nil 137 | return elem 138 | end 139 | setCodebraidAttr(elem, startLineNum, endLineNum) 140 | return elem 141 | end 142 | 143 | 144 | function Inlines(elem) 145 | for _, subElem in pairs(elem) do 146 | if nodesWithAttributes[subElem.t] ~= nil then 147 | local dataPos = subElem.attributes['data-pos'] 148 | if dataPos ~= nil then 149 | subElem.attributes['data-pos'] = nil 150 | end 151 | end 152 | end 153 | return elem 154 | end 155 | 156 | 157 | function CodeBlock(elem) 158 | local dataPos = elem.attributes['data-pos'] 159 | if dataPos == nil then 160 | return nil 161 | end 162 | local startLineNum, endLineNum = parseStartEndLineNum(elem, dataPos) 163 | if endLineNum > maxLineNum then 164 | maxLineNum = endLineNum 165 | end 166 | if trackedLines[startLineNum] ~= nil then 167 | elem.attributes['data-pos'] = nil 168 | return elem 169 | end 170 | setCodebraidAttr(elem, startLineNum, endLineNum) 171 | return elem 172 | end 173 | 174 | 175 | Header = CodeBlock 176 | 177 | 178 | function Div(elem) 179 | local dataPos = elem.attributes['data-pos'] 180 | if dataPos == nil then 181 | return nil 182 | end 183 | local _, endLineNum = parseStartEndLineNum(elem, dataPos) 184 | if endLineNum > maxLineNum then 185 | maxLineNum = endLineNum 186 | end 187 | if elem.identifier == '' and #(elem.classes) == 0 and #(elem.attributes) == 1 then 188 | return elem.content 189 | else 190 | elem.attributes['data-pos'] = nil 191 | return elem 192 | end 193 | end 194 | 195 | 196 | function Blocks(elem) 197 | for _, subElem in pairs(elem) do 198 | if nodesWithAttributes[subElem.t] ~= nil then 199 | local dataPos = subElem.attributes['data-pos'] 200 | if dataPos ~= nil then 201 | subElem.attributes['data-pos'] = nil 202 | local _, endLineNum = parseStartEndLineNum(subElem, dataPos) 203 | if endLineNum > maxLineNum then 204 | maxLineNum = endLineNum 205 | end 206 | end 207 | end 208 | end 209 | return elem 210 | end 211 | 212 | 213 | function Pandoc(doc) 214 | -- `trackedLines` is not a sequence, so `#trackedLines == 0` isn't valid 215 | if maxLineNum == 0 then 216 | return nil 217 | end 218 | for lineNumber, identifier in pairs(referenceLinesToIds) do 219 | doc.blocks:insert( 220 | pandoc.Div(pandoc.Blocks{}, { 221 | id='codebraid-sourcepos-' .. tostring(lineNumber), 222 | class='codebraid-sourcepos-ref', 223 | ['codebraid-sourcepos-ref']=identifier 224 | }) 225 | ) 226 | end 227 | doc.blocks:insert( 228 | pandoc.Div(pandoc.Blocks{}, { 229 | id='codebraid-sourcepos-meta', 230 | class='codebraid-sourcepos-meta', 231 | ['codebraid-sourcepos-min']=minLineNum, 232 | ['codebraid-sourcepos-max']=maxLineNum, 233 | }) 234 | ) 235 | return doc 236 | end 237 | 238 | 239 | -- Some elements need processing first. For example, if there is both an 240 | -- image and text on a line, the image should be used for sync. 241 | return { 242 | { 243 | Image = Image, 244 | Header = Header 245 | }, 246 | { 247 | Code = Code, 248 | Span = Span, 249 | Inlines = Inlines, 250 | CodeBlock = CodeBlock, 251 | Div = Div, 252 | Blocks = Blocks, 253 | Pandoc = Pandoc 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /media/codebraid-preview.css: -------------------------------------------------------------------------------- 1 | /*---------------------------------------------------------------------------* 2 | Copyright (c) 2022-2024, Geoffrey M. Poore 3 | All rights reserved. 4 | 5 | Licensed under the BSD 3-Clause License: 6 | http://opensource.org/licenses/BSD-3-Clause 7 | *--------------------------------------------------------------------------*/ 8 | 9 | 10 | 11 | 12 | /* Pandoc patch ------------------------------------------------------------*/ 13 | 14 | /* Redefine styling for line numbering */ 15 | pre.numberSource code > span { 16 | left: inherit; 17 | } 18 | pre.numberSource { 19 | margin-left: inherit; 20 | border: 1px solid var(--vscode-widget-border); 21 | padding-left: inherit; 22 | } 23 | pre.numberSource code > span > a:first-child::before { 24 | border-right: 1px solid #aaaaaa; 25 | line-height: 1.357em; 26 | } 27 | 28 | /* Use VS Code background color */ 29 | pre.sourceCode { 30 | background-color: var(--vscode-textCodeBlock-background); 31 | } 32 | 33 | 34 | 35 | 36 | /* Style output ------------------------------------------------------------*/ 37 | 38 | /* Guarantee that images with transparent background are legible */ 39 | .vscode-dark img.richOutput { 40 | background-color: gainsboro; 41 | } 42 | 43 | /* Style stderr */ 44 | .vscode-light pre.stderr code { 45 | color: darkred; 46 | } 47 | .vscode-light code.stderr { 48 | color: darkred; 49 | } 50 | .vscode-dark pre.stderr code { 51 | color: lightpink; 52 | } 53 | .vscode-dark code.stderr { 54 | color: lightpink; 55 | } 56 | 57 | /* Style errors */ 58 | .vscode-light pre.error code { 59 | color: red; 60 | } 61 | .vscode-light code.error { 62 | color: red; 63 | } 64 | .vscode-dark pre.error code { 65 | color: tomato; 66 | } 67 | .vscode-dark code.error { 68 | color: tomato; 69 | } 70 | 71 | 72 | 73 | 74 | /* Progress indicators while waiting for initial preview -------------------*/ 75 | @keyframes codebraid-updating { 76 | 0% { color: skyblue;} 77 | 50% { color: cornflowerblue;} 78 | 100% { color: steelblue;} 79 | } 80 | @keyframes codebraid-updating-running { 81 | 0% { color: skyblue; transform: rotate(0deg);} 82 | 50% { color: cornflowerblue; transform: rotate(90deg);} 83 | 100% { color: steelblue; transform: rotate(180deg);} 84 | } 85 | .codebraid-updating-anim { 86 | animation: codebraid-updating 1s infinite; 87 | } 88 | .codebraid-updating { 89 | font-size: x-large; 90 | } 91 | .codebraid-updating::before { 92 | color: cornflowerblue; 93 | font-family: "codicon"; 94 | display: inline-block; 95 | margin-right: 1em; 96 | } 97 | .codebraid-updating-waiting::before { 98 | content: "\ebb5"; 99 | } 100 | .codebraid-updating-running::before { 101 | content: "\ea77"; 102 | animation: codebraid-updating-running 1s infinite; 103 | } 104 | .codebraid-updating-finished::before { 105 | content: "\eba4"; 106 | } 107 | 108 | 109 | 110 | 111 | /* Indicate output status --------------------------------------------------*/ 112 | 113 | .codebraid-output-missing::before, 114 | .codebraid-output-placeholder::before, 115 | .codebraid-output-modified::before, 116 | .codebraid-output-old::before, 117 | .codebraid-output-stale::before, 118 | .codebraid-output-prepping::before, 119 | .codebraid-output-processing::before { 120 | color: cornflowerblue; 121 | font-family: "codicon"; 122 | font-size: calc(var(--markdown-font-size, 14px)*1.5); 123 | content: "\ea77"; 124 | float: right; 125 | margin-right: -1.5em; 126 | } 127 | @keyframes codebraid-prepping { 128 | 0% { color: skyblue;} 129 | 50% { color: cornflowerblue;} 130 | 100% { color: steelblue;} 131 | } 132 | .codebraid-output-prepping::before { 133 | animation: codebraid-prepping 1s infinite; 134 | } 135 | @keyframes codebraid-processing { 136 | 0% { color: skyblue; transform: rotate(0deg);} 137 | 50% { color: cornflowerblue; transform: rotate(90deg);} 138 | 100% { color: steelblue; transform: rotate(180deg);} 139 | } 140 | .codebraid-output-processing::before { 141 | animation: codebraid-processing 1s infinite; 142 | } 143 | 144 | div:is( 145 | .codebraid-output-missing, .codebraid-output-placeholder, 146 | .codebraid-output-modified, 147 | .codebraid-output-old, .codebraid-output-stale 148 | ), span:is( 149 | .codebraid-output-missing, .codebraid-output-placeholder, 150 | .codebraid-output-modified, 151 | .codebraid-output-old, .codebraid-output-stale 152 | ) > span { 153 | border-color: cornflowerblue; 154 | } 155 | 156 | div:is( 157 | .codebraid-output-missing, .codebraid-output-placeholder, 158 | .codebraid-output-modified, 159 | .codebraid-output-old, .codebraid-output-stale, 160 | ) { 161 | width: 100%; 162 | border-width: 0 4px 0 0; 163 | padding-right: 0.5ex; 164 | } 165 | div:is(.codebraid-output-old, .codebraid-output-stale) { 166 | border-style: none dotted none none; 167 | } 168 | div:is( 169 | .codebraid-output-missing, .codebraid-output-placeholder, 170 | .codebraid-output-modified, 171 | .codebraid-output-invalid-display 172 | ) { 173 | border-style: none solid none none; 174 | } 175 | div:is( 176 | .codebraid-output-missing, .codebraid-output-placeholder, 177 | .codebraid-output-invalid-display 178 | ), div.codebraid-output-none:is( 179 | .codebraid-output-modified, 180 | .codebraid-output-old, .codebraid-output-stale 181 | ) { 182 | height: 2em; 183 | margin-top: 4px; 184 | margin-bottom: 4px; 185 | } 186 | 187 | span:is( 188 | .codebraid-output-missing, .codebraid-output-placeholder, 189 | .codebraid-output-modified, 190 | .codebraid-output-old, .codebraid-output-stale 191 | ) > span { 192 | display: inline-block; 193 | margin: 1px; 194 | border-width: 2px; 195 | padding-inline: 0.5ex; 196 | } 197 | span:is(.codebraid-output-old, .codebraid-output-stale) > span { 198 | border-style: dotted; 199 | } 200 | span:is( 201 | .codebraid-output-missing, .codebraid-output-placeholder, 202 | .codebraid-output-modified, 203 | .codebraid-output-invalid-display 204 | ) > span { 205 | border-style: solid; 206 | } 207 | span:is( 208 | .codebraid-output-missing, .codebraid-output-placeholder, 209 | .codebraid-output-invalid-display 210 | ) > span, span.codebraid-output-none:is( 211 | .codebraid-output-modified, 212 | .codebraid-output-old, .codebraid-output-stale 213 | ) > span { 214 | display: inline-block; 215 | width: 2em; 216 | height: 1em; 217 | vertical-align: text-bottom; 218 | } 219 | 220 | div:is( 221 | .codebraid-output-missing, .codebraid-output-placeholder, 222 | .codebraid-output-invalid-display 223 | ), span:is( 224 | .codebraid-output-missing, .codebraid-output-placeholder, 225 | .codebraid-output-invalid-display 226 | ) > span { 227 | background: rgb(100, 149, 237, 0.3); 228 | } 229 | div.codebraid-output-none:is( 230 | .codebraid-output-modified, 231 | .codebraid-output-old, .codebraid-output-stale 232 | ), span.codebraid-output-none:is( 233 | .codebraid-output-modified, 234 | .codebraid-output-old, .codebraid-output-stale 235 | ) > span { 236 | background: linear-gradient( 237 | -45deg, 238 | transparent 0% 40%, 239 | rgb(100, 149, 237, 0.6) 40% 60%, 240 | transparent 60% 100% 241 | ); 242 | background-size: 4px 4px; 243 | background-repeat: repeat; 244 | } 245 | 246 | 247 | 248 | 249 | /* Style non-html raw output that is displayed verbatim --------------------*/ 250 | 251 | .pandoc-raw > pre, span.pandoc-raw { 252 | border-style: solid; 253 | border-color: darkkhaki; 254 | border-width: 2px; 255 | border-radius: 0px; 256 | box-shadow: 2px 2px 2px rgb(184, 134, 11, 0.5); 257 | } 258 | span.pandoc-raw { 259 | display: inline-block; 260 | margin-left: 2px; 261 | margin-right: 2px; 262 | } 263 | span.pandoc-raw > code { 264 | padding-inline: 0.5ex; 265 | } 266 | .pandoc-raw::before { 267 | content: attr(data-pandoc-raw-attr); 268 | color: black; 269 | background-color: darkkhaki; 270 | display: inline-block; 271 | font-size: large; 272 | } 273 | div.pandoc-raw::before { 274 | padding: 4px; 275 | } 276 | span.pandoc-raw::before { 277 | padding-inline: 4px; 278 | padding-top: 2px; 279 | padding-bottom: 2px; 280 | } 281 | 282 | 283 | 284 | 285 | /* Short-term alert messages, such as syntax errors ------------------------*/ 286 | div:is(.codebraid-temp-alert, .codebraid-alert-icon) { 287 | position: fixed; 288 | z-index:100; 289 | bottom: 1em; 290 | right: 1em; 291 | border: 1px solid red; 292 | max-width: calc(100% - 4em); 293 | max-height: 50%; 294 | } 295 | div.codebraid-temp-alert { 296 | padding: 1em; 297 | } 298 | .vscode-light div:is(.codebraid-temp-alert, .codebraid-alert-icon) { 299 | background-color: rgba(220, 220, 220, 1); 300 | outline: 3px solid rgba(220, 220, 220, 0.4); 301 | } 302 | .vscode-dark div:is(.codebraid-temp-alert, .codebraid-alert-icon) { 303 | background-color: rgba(10, 10, 10, 1); 304 | outline: 3px solid rgba(10, 10, 10, 0.4); 305 | } 306 | .vscode-high-contrast div:is(.codebraid-temp-alert, .codebraid-alert-icon) { 307 | background-color: var(--vscode-textCodeBlock-background); 308 | outline: 3px solid var(--vscode-textCodeBlock-background); 309 | } 310 | div.codebraid-temp-alert pre { 311 | overflow: auto; 312 | padding: 0; 313 | margin: 0; 314 | } 315 | div.codebraid-temp-alert pre::before { 316 | content: attr(data-codebraid-title); 317 | color: red; 318 | display: block; 319 | font-size: large; 320 | font-weight: 800; 321 | font-family: sans-serif; 322 | border-bottom: 1px solid red; 323 | margin-bottom: 1em; 324 | } 325 | div.codebraid-temp-alert-parseError { 326 | visibility: visible; 327 | } 328 | div.codebraid-temp-alert-stderr { 329 | visibility: hidden; 330 | } 331 | div.codebraid-alert-icon { 332 | font-size: x-large; 333 | } 334 | div.codebraid-alert-icon::before { 335 | position: relative; 336 | color: red; 337 | font-family: "codicon"; 338 | font-size: x-large; 339 | font-weight: 500; 340 | content: "\EB26"; 341 | padding: 0.25em; 342 | } 343 | div.codebraid-alert-icon.codebraid-alert-icon-warning::before { 344 | content: "\EA6C"; 345 | } 346 | div.codebraid-alert-icon:hover + div.codebraid-temp-alert-stderr { 347 | visibility: visible; 348 | } 349 | div.codebraid-temp-alert-stderr:hover { 350 | visibility: visible; 351 | } 352 | 353 | 354 | 355 | 356 | /* Toolbar -----------------------------------------------------------------*/ 357 | div.codebraid-toolbar { 358 | cursor: pointer; 359 | position: fixed; 360 | z-index:100; 361 | top: 0; 362 | right: 0; 363 | background-color: var(--vscode-editorWidget-background); 364 | font-family: "codicon"; 365 | font-size: large; 366 | font-weight: 500; 367 | border-left: 1px solid var(--vscode-editorWidget-border); 368 | border-bottom: 1px solid var(--vscode-editorWidget-border); 369 | border-radius: 0px 0px 4px 4px; 370 | box-shadow: 0 0 3px 1px var(--vscode-widget-shadow); 371 | } 372 | div.codebraid-toolbar-button { 373 | color: var(--vscode-editorWidget-foreground); 374 | text-align: center; 375 | line-height: 1.2em; 376 | width: 1.2em; 377 | height: 1.2em; 378 | } 379 | -------------------------------------------------------------------------------- /pandoc/filters/codebraid_output.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2022, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | -- Pandoc Lua filter that takes Codebraid output stored in document metadata 9 | -- and inserts the output into the document, overwriting the code nodes that 10 | -- generated it. 11 | -- 12 | 13 | 14 | local fromFormatIsCommonmark = false 15 | local codebraidIsRunning = false 16 | -- Chunk output obtained from metadata. {code_collection_key: [{chunk_attr: value}]} 17 | local codebraidOutput = {} 18 | -- Current location in output for each code collection. {code_collection_key: int} 19 | local codebraidKeyCurrentIndex = {} 20 | -- Whether each code collection has stale output. {code_collection_key: bool} 21 | local codebraidKeyIsStale = {} 22 | -- Whether code collection is currently being processed/executed. nil | {code_collection_key: bool} 23 | local codebraidKeyIsProcessing 24 | -- Map placehold langs to actual langs for cases where lang is inherited. {key: value} 25 | local codebraidPlaceholderLangs = {} 26 | -- Counter for assigning placeholder langs for cases where lang is inherited. 27 | local placeholderLangNum = 0 28 | 29 | 30 | --[[ 31 | Classes attached to output based on its status. 32 | * `missing`: No output data exists. This applies to a new code chunk that 33 | has never been processed by Codebraid. 34 | * `placeholder`: This is only possible while Codebraid is running (or if 35 | Codebraid fails to complete). Output data has not been received but is 36 | expected. There is no old cached output, so a placeholder is displayed. 37 | This applies to a code chunk in a session/source currently being processed 38 | by Codebraid, when the chunk itself has not yet been processed. 39 | * `old`: This is only possible while Codebraid is running (or if Codebraid 40 | fails to complete). It is the same as `placeholder`, except old cached 41 | output data exists so it can be displayed instead of a placeholder. 42 | * `modified`: Output data exists but it may be outdated because the code 43 | chunk attributes or code has been modified. When both `old` and 44 | `modified` apply to a chunk, `modified` is used. 45 | * `stale`: Output data exists and can be displayed, but a prior code chunk 46 | has been modified so the output may be outdated. When both `old` and 47 | `stale` apply to a chunk, `stale` is used. 48 | 49 | The `invalid-display` class applies in cases where output exists but there is 50 | an inline-block mismatch that makes the output impossible to display. The 51 | `output-none` class applies when there is outdated data and it contains no 52 | output. 53 | --]] 54 | local classCategories = {'missing', 'placeholder', 'old', 'modified', 'stale'} 55 | local classes = { 56 | ['output'] = 'codebraid-output', 57 | ['outputNoOutput'] = 'codebraid-output codebraid-output-none', 58 | } 59 | for _, k in pairs(classCategories) do 60 | classes[k] = 'codebraid-output codebraid-output-' .. k 61 | if k ~= 'missing' and k ~= 'placeholder' then 62 | classes[k .. 'InvalidDisplay'] = classes[k] .. ' codebraid-output-invalid-display' 63 | classes[k .. 'NoOutput'] = classes[k] .. ' codebraid-output-none' 64 | end 65 | end 66 | local preppingClass = ' codebraid-output-prepping' 67 | local processingClass = ' codebraid-output-processing' 68 | 69 | 70 | -- Dict of Codebraid classes that cause code execution. {key: bool} 71 | local codebraidExecuteClasses = { 72 | ['cb-expr'] = true, 73 | ['cb-nb'] = true, 74 | ['cb-run'] = true, 75 | ['cb-repl'] = true 76 | } 77 | for k, v in pairs(codebraidExecuteClasses) do 78 | local altKey, _ = k:gsub('%-', '.') 79 | codebraidExecuteClasses[altKey] = v 80 | end 81 | 82 | 83 | 84 | 85 | function Meta(metaTable) 86 | local metaConfig = metaTable['codebraid_meta'] 87 | local metaOutput = metaTable['codebraid_output'] 88 | if metaConfig == nil or metaOutput == nil then 89 | return 90 | end 91 | fromFormatIsCommonmark = metaConfig['commonmark'] 92 | codebraidIsRunning = metaConfig['running'] 93 | if metaConfig['placeholder_langs'] ~= nil then 94 | for key, elem in pairs(metaConfig['placeholder_langs']) do 95 | codebraidPlaceholderLangs[key] = elem[1].text 96 | end 97 | end 98 | codebraidKeyIsProcessing = metaConfig['collection_processing'] 99 | for key, rawOutputList in pairs(metaOutput) do 100 | local processedOutputList = {} 101 | codebraidOutput[key] = processedOutputList 102 | for _, rawOutput in pairs(rawOutputList) do 103 | if rawOutput['placeholder'] ~= nil then 104 | table.insert(processedOutputList, {['placeholder']=true}) 105 | else 106 | local nodeOutput = { 107 | ['placeholder'] = false, 108 | ['inline'] = rawOutput['inline'], 109 | ['attr_hash'] = rawOutput['attr_hash'][1].text, 110 | ['code_hash'] = rawOutput['code_hash'][1].text, 111 | } 112 | if rawOutput['old'] == nil then 113 | nodeOutput['old'] = false 114 | else 115 | nodeOutput['old'] = true 116 | end 117 | if rawOutput['output'] ~= nil then 118 | if nodeOutput['inline'] then 119 | local inlineOutputElems = pandoc.Inlines{} 120 | for _, output in pairs(rawOutput['output']) do 121 | for _, blockElem in pairs(output) do 122 | for inlineIndex, inlineElem in pairs(blockElem.content) do 123 | -- The first element is a placeholder span 124 | -- to prevent text from being parsed as if 125 | -- present at the start of a new line 126 | if inlineIndex > 1 then 127 | inlineOutputElems:insert(inlineElem) 128 | end 129 | end 130 | end 131 | end 132 | nodeOutput['output'] = inlineOutputElems 133 | else 134 | local blockOutputElems = pandoc.Blocks{} 135 | for _, output in pairs(rawOutput['output']) do 136 | for _, blockElem in pairs(output) do 137 | blockOutputElems:insert(blockElem) 138 | end 139 | end 140 | nodeOutput['output'] = blockOutputElems 141 | end 142 | end 143 | table.insert(processedOutputList, nodeOutput) 144 | end 145 | end 146 | end 147 | metaTable['codebraid_meta'] = nil 148 | metaTable['codebraid_output'] = nil 149 | return metaTable 150 | end 151 | 152 | 153 | 154 | 155 | function getCodebraidLangAndCommandClass(classes) 156 | for index, class in pairs(classes) do 157 | if class:sub(1, 3) == 'cb-' or (not fromFormatIsCommonmark and class:sub(1, 3) == 'cb.') then 158 | local lang = '' 159 | if index > 1 then 160 | lang = classes[1] 161 | end 162 | if lang == '' then 163 | actualLang = codebraidPlaceholderLangs[tostring(placeholderLangNum)] 164 | if actualLang ~= nil then 165 | lang = actualLang 166 | placeholderLangNum = placeholderLangNum + 1 167 | end 168 | end 169 | return lang, class 170 | end 171 | end 172 | return nil, nil 173 | end 174 | 175 | function getCodebraidCodeCollectionType(cbClass) 176 | if codebraidExecuteClasses[cbClass] == nil then 177 | return 'source' 178 | end 179 | return 'session' 180 | end 181 | 182 | function getCodebraidCodeCollectionName(cbCollectionType, attributes) 183 | for k, v in pairs(attributes) do 184 | if k == cbCollectionType then 185 | return v 186 | end 187 | end 188 | return '' 189 | end 190 | 191 | local codebraidSourceposPrefix = 'codebraid-sourcepos' 192 | local codebraidSourceposPrefixLen = codebraidSourceposPrefix:len() 193 | function isCodebraidSourcepos(s) 194 | if s == nil or s:len() < codebraidSourceposPrefixLen then 195 | return false 196 | end 197 | if s:sub(1, codebraidSourceposPrefixLen) == codebraidSourceposPrefix then 198 | return true 199 | end 200 | return false 201 | end 202 | 203 | function getCodebraidAttrHash(id, classes, attributes) 204 | local attrList = {} 205 | table.insert(attrList, '{') 206 | if not isCodebraidSourcepos(id) and id ~= nil and id ~= '' then 207 | table.insert(attrList, '#' .. id) 208 | end 209 | for _, class in pairs(classes) do 210 | if not isCodebraidSourcepos(class) then 211 | table.insert(attrList, '.' .. class) 212 | end 213 | end 214 | for k, v in pairs(attributes) do 215 | if not isCodebraidSourcepos(k) then 216 | local vEsc, _ = v:gsub('\\', '\\\\') 217 | vEsc, _ = vEsc:gsub('"', '\\"') 218 | table.insert(attrList, k .. '=' .. '"' .. vEsc .. '"') 219 | end 220 | end 221 | table.insert(attrList, '}') 222 | attrString = table.concat(attrList, ' ') 223 | return pandoc.sha1(attrString) 224 | end 225 | 226 | 227 | function codeChunk(elem, isInline) 228 | local cbLang, cbClass = getCodebraidLangAndCommandClass(elem.classes) 229 | if cbClass == nil then 230 | return 231 | end 232 | local cbCollectionType = getCodebraidCodeCollectionType(cbClass) 233 | local cbCollectionName = getCodebraidCodeCollectionName(cbCollectionType, elem.attributes) 234 | local key = cbCollectionType .. '.' .. cbLang .. '.' .. cbCollectionName 235 | 236 | local chunkStageClass = '' 237 | if codebraidIsRunning and codebraidKeyIsProcessing == nil then 238 | chunkStageClass = preppingClass 239 | end 240 | 241 | local collectionData = codebraidOutput[key] 242 | if collectionData == nil then 243 | if isInline then 244 | return pandoc.Span(pandoc.Span(pandoc.Inlines{}), {class=classes['missing'] .. chunkStageClass}) 245 | else 246 | return pandoc.Div(pandoc.Blocks{}, {class=classes['missing'] .. chunkStageClass}) 247 | end 248 | end 249 | local nodeIndex = codebraidKeyCurrentIndex[key] 250 | if nodeIndex == nil then 251 | nodeIndex = 1 252 | end 253 | codebraidKeyCurrentIndex[key] = nodeIndex + 1 254 | local nodeData = collectionData[nodeIndex] 255 | if nodeData == nil then 256 | if isInline then 257 | return pandoc.Span(pandoc.Span(pandoc.Inlines{}), {class=classes['missing'] .. chunkStageClass}) 258 | else 259 | return pandoc.Div(pandoc.Blocks{}, {class=classes['missing'] .. chunkStageClass}) 260 | end 261 | end 262 | if codebraidIsRunning and codebraidKeyIsProcessing ~= nil and codebraidKeyIsProcessing[key] then 263 | if nodeData['placeholder'] or nodeData['old'] then 264 | chunkStageClass = processingClass 265 | end 266 | end 267 | if nodeData['placeholder'] then 268 | if isInline then 269 | return pandoc.Span(pandoc.Span(pandoc.Inlines{}), {class=classes['placeholder'] .. chunkStageClass}) 270 | else 271 | return pandoc.Div(pandoc.Blocks{}, {class=classes['placeholder'] .. chunkStageClass}) 272 | end 273 | end 274 | local isModified = false 275 | local isStale = codebraidKeyIsStale[key] 276 | if isStale == nil then 277 | isStale = false 278 | end 279 | local attrHash = getCodebraidAttrHash(elem.id, elem.classes, elem.attributes) 280 | local codeHash = pandoc.sha1(elem.text) 281 | if attrHash ~= nodeData['attr_hash'] or codeHash ~= nodeData['code_hash'] or isInline ~= nodeData['inline'] then 282 | isModified = true 283 | isStale = true 284 | end 285 | codebraidKeyIsStale[key] = isStale 286 | 287 | local output 288 | if nodeData['output'] ~= nil and isInline == nodeData['inline'] then 289 | if isInline then 290 | output = pandoc.Span(nodeData['output']) 291 | else 292 | output = nodeData['output'] 293 | end 294 | else 295 | if isInline then 296 | output = pandoc.Span(pandoc.Inlines{}) 297 | else 298 | output = pandoc.Blocks{} 299 | end 300 | end 301 | local baseClass 302 | if isModified then 303 | baseClass = 'modified' 304 | elseif isStale then 305 | baseClass = 'stale' 306 | elseif nodeData['old'] then 307 | baseClass = 'old' 308 | else 309 | baseClass = 'output' 310 | end 311 | local displayClass 312 | if nodeData['output'] ~= nil then 313 | if isInline == nodeData['inline'] then 314 | displayClass = '' 315 | else 316 | displayClass = 'InvalidDisplay' 317 | end 318 | else 319 | displayClass = 'NoOutput' 320 | end 321 | if isInline then 322 | return pandoc.Span(output, {class=classes[baseClass .. displayClass] .. chunkStageClass}) 323 | else 324 | return pandoc.Div(output, {class=classes[baseClass .. displayClass] .. chunkStageClass}) 325 | end 326 | end 327 | 328 | function Code(elem) 329 | return codeChunk(elem, true) 330 | end 331 | 332 | function CodeBlock(elem) 333 | return codeChunk(elem, false) 334 | end 335 | 336 | 337 | 338 | 339 | return { 340 | { 341 | Meta = Meta, 342 | }, 343 | { 344 | Pandoc = function (doc) 345 | return doc:walk { 346 | traverse = 'topdown', 347 | Code = Code, 348 | CodeBlock = CodeBlock, 349 | } 350 | end 351 | }, 352 | } 353 | -------------------------------------------------------------------------------- /src/pandoc_defaults_file.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2024, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as vscode from 'vscode'; 10 | 11 | import * as path from 'path'; 12 | import * as yaml from 'js-yaml'; 13 | 14 | import type PreviewPanel from './preview_panel'; 15 | import type { ExtensionState } from './types'; 16 | import CodebraidPreviewError from './err'; 17 | import { processedDefaultsRelativeFileName } from './pandoc_settings'; 18 | import { pandocBuiltinReaderWriterRegex, PandocReader, PandocWriter } from './pandoc_util'; 19 | 20 | 21 | type PandocDefaultsYamlData = { 22 | yaml: {[key: string]: any} | undefined; 23 | inputFiles: Array | undefined; 24 | hasReader: boolean; 25 | extractedReader: PandocReader | undefined; 26 | embeddedReader: PandocReader | null | undefined; 27 | rawReaderString: string | undefined; 28 | fileScope: boolean | undefined; 29 | hasWriter: boolean; 30 | extractedWriter: PandocWriter | undefined; 31 | embeddedWriter: PandocWriter | null | undefined; 32 | rawWriterString: string | undefined; 33 | needsBuildConfig: boolean; 34 | }; 35 | 36 | 37 | export class PandocDefaultsFile implements vscode.Disposable { 38 | private previewPanel: PreviewPanel; 39 | private extension: ExtensionState; 40 | private cwdUri: vscode.Uri; 41 | 42 | isRelevant: boolean; 43 | fileName: string | undefined; 44 | data: PandocDefaultsYamlData | undefined; 45 | private lastReadDefaultsBytes: Uint8Array | undefined; 46 | private lastWrittenDefaultsBytes: Uint8Array | undefined; 47 | processedFileName: string | undefined; 48 | 49 | private isDisposed: boolean; 50 | private isUpdating: boolean; 51 | private scheduledUpdateTimer: NodeJS.Timeout | undefined; 52 | 53 | constructor(previewPanel: PreviewPanel) { 54 | this.previewPanel = previewPanel; 55 | this.extension = previewPanel.extension; 56 | this.cwdUri = vscode.Uri.file(previewPanel.cwd); 57 | 58 | this.isRelevant = false; 59 | 60 | this.isDisposed = false; 61 | this.isUpdating = false; 62 | } 63 | 64 | dispose() { 65 | if (this.scheduledUpdateTimer) { 66 | clearTimeout(this.scheduledUpdateTimer); 67 | this.scheduledUpdateTimer = undefined; 68 | } 69 | this.isDisposed = true; 70 | } 71 | 72 | private extractedKeys: Set = new Set(['input-files', 'input-file', 'file-scope']); 73 | private readerKeys: Set = new Set(['from', 'reader']); 74 | private writerKeys: Set = new Set(['to', 'writer']); 75 | private prohibitedKeys: Set = new Set(['output-file', 'standalone']); 76 | 77 | async update(callback?: () => void) { 78 | if (this.isDisposed) { 79 | return; 80 | } 81 | if (this.isUpdating) { 82 | if (this.scheduledUpdateTimer) { 83 | clearTimeout(this.scheduledUpdateTimer); 84 | } 85 | this.scheduledUpdateTimer = setTimeout( 86 | () => { 87 | this.scheduledUpdateTimer = undefined; 88 | this.update(callback); 89 | }, 90 | 100, 91 | ); 92 | } 93 | 94 | this.isUpdating = true; 95 | 96 | let defaultsBaseName: string; 97 | // Deal with deprecated setting 98 | const deprecatedDefaultsFile = this.extension.config.pandoc.previewDefaultsFile; 99 | const defaultsFile = this.extension.config.pandoc.defaultsFile; 100 | if (defaultsFile === '_codebraid_preview.yaml' && typeof(deprecatedDefaultsFile) === 'string' && deprecatedDefaultsFile && deprecatedDefaultsFile !== defaultsFile) { 101 | defaultsBaseName = deprecatedDefaultsFile; 102 | } else { 103 | defaultsBaseName = defaultsFile; 104 | } 105 | const defaultsUri = vscode.Uri.joinPath(this.cwdUri, defaultsBaseName); 106 | this.fileName = defaultsUri.fsPath; 107 | 108 | let defaultsBytes: Uint8Array | undefined; 109 | try { 110 | defaultsBytes = await vscode.workspace.fs.readFile(defaultsUri); 111 | } catch { 112 | } 113 | if (this.isDisposed) { 114 | this.reset(); 115 | this.isUpdating = false; 116 | return; 117 | } 118 | if (!defaultsBytes) { 119 | this.reset(); 120 | this.isUpdating = false; 121 | if (callback) { 122 | callback(); 123 | } 124 | return; 125 | } 126 | if (defaultsBytes === this.lastReadDefaultsBytes) { 127 | this.isUpdating = false; 128 | if (callback) { 129 | callback(); 130 | } 131 | return; 132 | } 133 | 134 | let defaultsString: string | undefined; 135 | try { 136 | defaultsString = Buffer.from(defaultsBytes).toString('utf8'); 137 | } catch (error) { 138 | vscode.window.showErrorMessage( 139 | `Defaults file "${path.basename(this.fileName)}" could not be decoded so it will be ignored:\n${error}` 140 | ); 141 | this.reset(); 142 | this.isUpdating = false; 143 | if (callback) { 144 | callback(); 145 | } 146 | return; 147 | } 148 | 149 | let yamlData: PandocDefaultsYamlData; 150 | try { 151 | yamlData = this.loadPandocDefaultsYamlData(defaultsString); 152 | } catch (error) { 153 | vscode.window.showErrorMessage( 154 | `Defaults file "${path.basename(this.fileName)}" is invalid so it will be ignored:\n${error}` 155 | ); 156 | this.reset(); 157 | this.isUpdating = false; 158 | if (callback) { 159 | callback(); 160 | } 161 | return; 162 | } 163 | 164 | if (yamlData.embeddedWriter !== undefined) { 165 | // Custom writers will need a separate temp file for export that 166 | // omits the writer, and possibly other modifications. 167 | vscode.window.showErrorMessage([ 168 | `Defaults file "${path.basename(this.fileName)}" has custom writer "${yamlData.rawWriterString}".`, 169 | `Custom writers are not yet supported, so this will be ignored.`, 170 | ].join(' ')); 171 | yamlData.hasWriter = false; 172 | yamlData.embeddedWriter = undefined; 173 | yamlData.rawWriterString = undefined; 174 | } 175 | 176 | const processedYamlData: {[key: string]: any} = {...yamlData.yaml}; 177 | let keyCount: number = 0; 178 | Object.keys(processedYamlData).forEach((key) => { 179 | if (this.extractedKeys.has(key)) { 180 | delete processedYamlData[key]; 181 | } else if (this.readerKeys.has(key) && yamlData.extractedReader) { 182 | delete processedYamlData[key]; 183 | } else if (this.writerKeys.has(key) && yamlData.extractedWriter) { 184 | delete processedYamlData[key]; 185 | } else { 186 | keyCount += 1; 187 | } 188 | }); 189 | let processedFileName: string | undefined; 190 | if (keyCount > 0) { 191 | const processedDefaultsUri = vscode.Uri.joinPath(this.cwdUri, processedDefaultsRelativeFileName); 192 | processedFileName = processedDefaultsUri.fsPath; 193 | const dataBytes = Buffer.from(yaml.dump(processedYamlData), 'utf8'); 194 | let oldDataBytes: Uint8Array | undefined; 195 | if (this.lastWrittenDefaultsBytes === undefined) { 196 | try { 197 | oldDataBytes = await vscode.workspace.fs.readFile(processedDefaultsUri); 198 | } catch { 199 | } 200 | if (this.isDisposed) { 201 | this.reset(); 202 | this.isUpdating = false; 203 | return; 204 | } 205 | } else { 206 | oldDataBytes = this.lastWrittenDefaultsBytes; 207 | } 208 | if (oldDataBytes === undefined || dataBytes.compare(oldDataBytes) !== 0) { 209 | try { 210 | await vscode.workspace.fs.writeFile(processedDefaultsUri, dataBytes); 211 | } catch (error) { 212 | vscode.window.showErrorMessage( 213 | `Defaults file "${path.basename(this.fileName)}" could not be converted into a temp file so it will be ignored:\n${error}` 214 | ); 215 | this.reset(); 216 | this.isUpdating = false; 217 | if (callback) { 218 | callback(); 219 | } 220 | return; 221 | } 222 | if (this.isDisposed) { 223 | this.reset(); 224 | this.isUpdating = false; 225 | return; 226 | } 227 | } 228 | this.lastWrittenDefaultsBytes = dataBytes; 229 | } 230 | this.processedFileName = processedFileName; 231 | this.data = yamlData; 232 | 233 | if (!yamlData.inputFiles) { 234 | this.isRelevant = true; 235 | } else if (yamlData.inputFiles.indexOf(this.previewPanel.currentFileName) !== -1) { 236 | this.isRelevant = true; 237 | } 238 | 239 | this.isUpdating = false; 240 | if (callback) { 241 | callback(); 242 | } 243 | } 244 | 245 | private reset() { 246 | this.isRelevant = false; 247 | this.fileName = undefined; 248 | this.data = undefined; 249 | this.lastReadDefaultsBytes = undefined; 250 | this.lastWrittenDefaultsBytes = undefined; 251 | this.processedFileName = undefined; 252 | } 253 | 254 | private loadPandocDefaultsYamlData(dataString: string) : PandocDefaultsYamlData { 255 | if (dataString.charCodeAt(0) === 0xFEFF) { 256 | // Drop BOM 257 | dataString = dataString.slice(1); 258 | } 259 | let maybeData: any; 260 | try { 261 | maybeData = yaml.load(dataString); 262 | } catch (error) { 263 | // yaml.YAMLException 264 | throw new CodebraidPreviewError(`Failed to load YAML:\n${error}`); 265 | } 266 | if (typeof(maybeData) !== 'object' || maybeData === null || Array.isArray(maybeData)) { 267 | throw new CodebraidPreviewError('Top level of YAML must be an associative array (that is, a map/dict/hash)'); 268 | } 269 | Object.keys(maybeData).forEach((key) => { 270 | if (typeof(key) !== 'string') { 271 | throw new CodebraidPreviewError('Top level of YAML must have string keys'); 272 | } 273 | if (this.prohibitedKeys.has(key)) { 274 | throw new CodebraidPreviewError(`Key "${key}" is not supported`); 275 | } 276 | }); 277 | const data: {[key: string]: any} = maybeData; 278 | let maybeInputFiles: any = undefined; 279 | for (const key of ['input-files', 'input-file']) { 280 | if (data.hasOwnProperty(key)) { 281 | if (maybeInputFiles !== undefined) { 282 | throw new CodebraidPreviewError('Cannot have both keys "input-files" and "input-file"'); 283 | } 284 | if (key === 'input-files') { 285 | maybeInputFiles = data[key]; 286 | if (!Array.isArray(maybeInputFiles) || maybeInputFiles.length === 0) { 287 | throw new CodebraidPreviewError('Key "input-files" must map to a list of strings'); 288 | } 289 | } else { 290 | maybeInputFiles = [data[key]]; 291 | } 292 | for (const x of maybeInputFiles) { 293 | if (typeof(x) !== 'string') { 294 | if (key === 'input-files') { 295 | throw new CodebraidPreviewError('Key "input-files" must map to a list of strings'); 296 | } else { 297 | throw new CodebraidPreviewError('Key "input-file" must map to a string'); 298 | } 299 | } 300 | if (!/^[^\\/]+$/.test(x)) { 301 | if (key === 'input-files') { 302 | throw new CodebraidPreviewError('Key "input-files" must map to a list of file names in the document directory'); 303 | } else { 304 | throw new CodebraidPreviewError('Key "input-file" must map to a file name in the document directory'); 305 | } 306 | } 307 | } 308 | } 309 | } 310 | let inputFiles: Array | undefined; 311 | if (maybeInputFiles) { 312 | inputFiles = []; 313 | for (const inputFile of maybeInputFiles) { 314 | inputFiles.push(vscode.Uri.joinPath(this.cwdUri, inputFile).fsPath); 315 | } 316 | } 317 | let maybeReader: any = undefined; 318 | for (const key of this.readerKeys) { 319 | if (data.hasOwnProperty(key)) { 320 | if (maybeReader !== undefined) { 321 | throw new CodebraidPreviewError( 322 | `Cannot define more than one of ${Array.from(this.readerKeys).map(k => `"${k}"`).join(', ')}` 323 | ); 324 | } 325 | maybeReader = data[key]; 326 | if (typeof(maybeReader) !== 'string' || maybeReader === '') { 327 | throw new CodebraidPreviewError(`Key "${key}" must map to a non-empty string`); 328 | } 329 | } 330 | } 331 | const hasReader: boolean = maybeReader !== undefined; 332 | let extractedReader: PandocReader | undefined; 333 | let embeddedReader: PandocReader | null | undefined; 334 | if (hasReader) { 335 | if (pandocBuiltinReaderWriterRegex.test(maybeReader)) { 336 | extractedReader = new PandocReader(maybeReader, this.extension.context, this.extension.config); 337 | } else { 338 | // Custom definitions may not fit expected patterns 339 | try { 340 | embeddedReader = new PandocReader(maybeReader, this.extension.context, this.extension.config); 341 | } catch { 342 | embeddedReader = null; 343 | } 344 | } 345 | } 346 | let maybeFileScope: any = undefined; 347 | if (data.hasOwnProperty('file-scope')) { 348 | maybeFileScope = data['file-scope']; 349 | if (typeof(maybeFileScope) !== 'boolean') { 350 | throw new CodebraidPreviewError('Key "file-scope" must map to a boolean'); 351 | } 352 | } 353 | const fileScope: boolean | undefined = maybeFileScope; 354 | let maybeWriter: any = undefined; 355 | for (const key of this.writerKeys) { 356 | if (data.hasOwnProperty(key)) { 357 | if (maybeWriter !== undefined) { 358 | throw new CodebraidPreviewError( 359 | `Cannot define more than one of ${Array.from(this.writerKeys).map(k => `"${k}"`).join(', ')}` 360 | ); 361 | } 362 | maybeWriter = data[key]; 363 | if (typeof(maybeWriter) !== 'string' || maybeWriter === '') { 364 | throw new CodebraidPreviewError(`Key "${key}" must map to a string`); 365 | } 366 | } 367 | } 368 | const hasWriter: boolean = maybeWriter !== undefined; 369 | let extractedWriter: PandocWriter | undefined; 370 | let embeddedWriter: PandocWriter | null | undefined; 371 | if (hasWriter) { 372 | if (pandocBuiltinReaderWriterRegex.test(maybeWriter)) { 373 | extractedWriter = new PandocWriter(maybeWriter); 374 | } else { 375 | // Custom definitions may not fit expected patterns 376 | try { 377 | embeddedWriter = new PandocWriter(maybeWriter); 378 | } catch { 379 | embeddedWriter = null; 380 | } 381 | } 382 | } 383 | return { 384 | yaml: data, 385 | inputFiles: inputFiles, 386 | hasReader: hasReader, 387 | extractedReader: extractedReader, 388 | embeddedReader: embeddedReader, 389 | rawReaderString: maybeReader, 390 | fileScope: fileScope, 391 | hasWriter: hasWriter, 392 | extractedWriter: extractedWriter, 393 | embeddedWriter: embeddedWriter, 394 | rawWriterString: maybeWriter, 395 | needsBuildConfig: !(hasReader && hasWriter), 396 | }; 397 | } 398 | 399 | }; 400 | -------------------------------------------------------------------------------- /scripts/codebraid-preview.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | const vscode = acquireVsCodeApi(); 10 | 11 | let editorMinLine = 0; 12 | let editorMaxLine = 0; 13 | let codebraidSourceposMetaElement = document.getElementById('codebraid-sourcepos-meta'); 14 | if (codebraidSourceposMetaElement) { 15 | editorMinLine = Number(codebraidSourceposMetaElement.getAttribute('data-codebraid-sourcepos-min')); 16 | editorMaxLine = Number(codebraidSourceposMetaElement.getAttribute('data-codebraid-sourcepos-max')); 17 | } 18 | let codebraidSourceposMaxElement = codebraidSourceposMetaElement; 19 | 20 | 21 | // Start in state that allows editor to sync its scroll location to the 22 | // preview. Otherwise, as soon as the preview loads, it sends its initial 23 | // scroll location of y=0 to the editor. 24 | let isScrollingPreviewWithEditor = true; 25 | let isScrollingPreviewWithEditorTimer = setTimeout( 26 | () => { 27 | isScrollingPreviewWithEditor = false; 28 | isScrollingPreviewWithEditorTimer = undefined; 29 | }, 30 | 100 31 | ); 32 | 33 | 34 | let hasTempAlerts = false; 35 | window.addEventListener('message', (event) => { 36 | const message = event.data; 37 | switch (message.command) { 38 | case 'codebraidPreview.startingCodebraid': { 39 | const outputElems = document.querySelectorAll('.codebraid-output'); 40 | for (const elem of outputElems) { 41 | elem.classList.add('codebraid-output-prepping'); 42 | } 43 | return; 44 | } 45 | case 'codebraidPreview.scrollPreview': { 46 | if (editorMaxLine === 0) { 47 | return; 48 | } 49 | isScrollingPreviewWithEditor = true; 50 | scrollPreviewWithEditor(message.startLine); 51 | if (isScrollingPreviewWithEditorTimer !== undefined) { 52 | clearTimeout(isScrollingPreviewWithEditorTimer); 53 | } 54 | isScrollingPreviewWithEditorTimer = setTimeout( 55 | () => { 56 | isScrollingPreviewWithEditor = false; 57 | isScrollingPreviewWithEditorTimer = undefined; 58 | }, 59 | 50 60 | ); 61 | return; 62 | } 63 | case 'codebraidPreview.tempAlert': { 64 | const alertDiv = document.createElement('div'); 65 | alertDiv.classList.add('codebraid-temp-alert'); 66 | alertDiv.innerHTML = message.tempAlert; 67 | switch (message.alertType) { 68 | case 'parseError': { 69 | if (hasTempAlerts && !message.keepExisting) { 70 | for (const element of document.getElementsByClassName('codebraid-temp-alert')) { 71 | element.parentNode.removeChild(element); 72 | } 73 | } 74 | alertDiv.classList.add('codebraid-temp-alert-parseError'); 75 | const alertPosElems = alertDiv.getElementsByClassName('codebraid-temp-alert-pos'); 76 | for (const alertPosElem of alertPosElems) { 77 | alertPosElem.addEventListener( 78 | 'click', 79 | () => { 80 | const [lineNumber, lineColumn] = alertPosElem.getAttribute('data-codebraid-temp-alert-pos').split(':').map((s) => Number(s)); 81 | vscode.postMessage({ 82 | command: 'codebraidPreview.moveCursor', 83 | startLine: lineNumber, // Editor is zero-indexed, but that's handled on editor side. 84 | startColumn: lineColumn, 85 | }); 86 | }, 87 | false 88 | ); 89 | } 90 | break; 91 | } 92 | case 'stderr': { 93 | alertDiv.classList.add('codebraid-temp-alert-stderr'); 94 | const warningIconDiv = document.createElement('div'); 95 | warningIconDiv.classList.add('codebraid-alert-icon'); 96 | if (message.isWarning) { 97 | warningIconDiv.classList.add('codebraid-alert-icon-warning'); 98 | } 99 | document.body.append(warningIconDiv); 100 | break; 101 | } 102 | } 103 | document.body.appendChild(alertDiv); 104 | hasTempAlerts = true; 105 | return; 106 | } 107 | case 'codebraidPreview.clearTempAlerts': { 108 | if (hasTempAlerts) { 109 | for (const className of ['codebraid-temp-alert', 'codebraid-alert-icon']) { 110 | for (const element of document.getElementsByClassName(className)) { 111 | element.parentNode.removeChild(element); 112 | } 113 | } 114 | } 115 | return; 116 | } 117 | } 118 | }); 119 | 120 | 121 | const baseElement = document.querySelector('base'); 122 | let pandocDefaultDataDir = baseElement.getAttribute('data-pandocdefaultdatadir'); 123 | let pandocDefaultDataDirAsFileUri = baseElement.getAttribute('data-pandocdefaultdatadirasfileuri'); 124 | let pandocDefaultDataDirAsWebviewUri = baseElement.getAttribute('data-pandocdefaultdatadiraswebviewuri'); 125 | for (const elem of document.querySelectorAll('[href]')) { 126 | if (elem.href.startsWith(pandocDefaultDataDir)) { 127 | elem.href = pandocDefaultDataDirAsWebviewUri + elem.href.slice(pandocDefaultDataDir.length); 128 | } else if (elem.href.startsWith(pandocDefaultDataDirAsFileUri)) { 129 | elem.href = pandocDefaultDataDirAsWebviewUri + elem.href.slice(pandocDefaultDataDirAsFileUri.length); 130 | } 131 | } 132 | for (const elem of document.querySelectorAll('[src]')) { 133 | if (elem.src.startsWith(pandocDefaultDataDir)) { 134 | elem.src = pandocDefaultDataDirAsWebviewUri + elem.src.slice(pandocDefaultDataDir.length); 135 | } else if (elem.src.startsWith(pandocDefaultDataDirAsFileUri)) { 136 | elem.src = pandocDefaultDataDirAsWebviewUri + elem.src.slice(pandocDefaultDataDirAsFileUri.length); 137 | } 138 | } 139 | 140 | 141 | function scrollPreviewWithEditor(startLine) { 142 | let searchLine = Math.min(startLine, editorMaxLine); 143 | let element = document.getElementById(`codebraid-sourcepos-${searchLine}`); 144 | if (element) { 145 | if (element.hasAttribute('data-codebraid-sourcepos-ref')) { 146 | element = document.getElementById(element.getAttribute('data-codebraid-sourcepos-ref')); 147 | } 148 | window.scrollBy(0, element.getBoundingClientRect().top); 149 | return; 150 | } 151 | while (!element && searchLine > 1) { 152 | searchLine -= 1; 153 | element = document.getElementById(`codebraid-sourcepos-${searchLine}`); 154 | } 155 | let elementLines = undefined; 156 | if (element) { 157 | if (element.hasAttribute('data-codebraid-sourcepos-ref')) { 158 | element = document.getElementById(element.getAttribute('data-codebraid-sourcepos-ref')); 159 | } 160 | if (element.hasAttribute('data-codebraid-sourcepos-lines')) { 161 | elementLines = Number(element.getAttribute('data-codebraid-sourcepos-lines')); 162 | if (startLine < searchLine + elementLines) { 163 | const subElementLine = startLine - searchLine; // Calculate assuming fenced code blocks. 164 | const subElement = document.getElementById(`codebraid-sourcepos-${searchLine}-${subElementLine}`); 165 | if (subElement) { 166 | window.scrollBy(0, subElement.getBoundingClientRect().top); 167 | return; 168 | } 169 | const rect = element.getBoundingClientRect(); 170 | const offset = (startLine - searchLine) / elementLines * rect.height; 171 | window.scrollBy(0, rect.top + offset); 172 | return; 173 | } 174 | } 175 | if (searchLine === startLine - 1) { 176 | // This must be after the check for an overlapping element with 177 | // a lines attribute (code block). 178 | window.scrollBy(0, element.getBoundingClientRect().bottom); 179 | return; 180 | } 181 | } 182 | let nextSearchLine = startLine + 1; 183 | let nextElement = document.getElementById(`codebraid-sourcepos-${nextSearchLine}`); 184 | if (!nextElement) { 185 | while (!nextElement && nextSearchLine < editorMaxLine) { 186 | nextSearchLine += 1; 187 | nextElement = document.getElementById(`codebraid-sourcepos-${nextSearchLine}`); 188 | } 189 | if (!nextElement) { 190 | nextSearchLine = editorMaxLine; 191 | nextElement = codebraidSourceposMaxElement; 192 | } else { 193 | if (nextElement.hasAttribute('data-codebraid-sourcepos-ref')) { 194 | nextElement = document.getElementById(nextElement.getAttribute('data-codebraid-sourcepos-ref')); 195 | } 196 | } 197 | } 198 | if (!element) { 199 | // Note: Must use absolute coordinates in this case. 200 | let ratio = (startLine - 1) / Math.max(nextSearchLine - 1, 1); 201 | window.scrollTo(0, ratio * (window.scrollY + nextElement.getBoundingClientRect().top)); 202 | return; 203 | } 204 | const rect = element.getBoundingClientRect(); 205 | const nextRect = nextElement.getBoundingClientRect(); 206 | let ratio; 207 | if (elementLines) { 208 | ratio = (startLine - (searchLine + elementLines)) / Math.max(nextSearchLine - (searchLine + elementLines), 1); 209 | } else { 210 | ratio = (startLine - (searchLine + 1)) / Math.max(nextSearchLine - (searchLine + 1), 1); 211 | } 212 | window.scrollBy(0, rect.bottom + Math.max(0, ratio * (nextRect.top - rect.bottom))); 213 | } 214 | 215 | 216 | let visibleElements = new Set(); 217 | function webviewVisibleTracker(entries, observer) { 218 | for (const entry of entries) { 219 | if (entry.isIntersecting) { 220 | visibleElements.add(entry.target); 221 | } else { 222 | visibleElements.delete(entry.target); 223 | } 224 | } 225 | if (!isScrollingPreviewWithEditor && visibleElements.size !== 0) { 226 | scrollEditorWithPreview(); 227 | } 228 | } 229 | function scrollEditorWithPreview() { 230 | let minLine = editorMaxLine + 1; 231 | let topElement = undefined; 232 | let topLine = undefined; 233 | for (const element of visibleElements) { 234 | let startLine = Number(element.getAttribute('data-codebraid-sourcepos-start')); 235 | if (startLine < minLine) { 236 | minLine = startLine; 237 | topElement = element; 238 | } 239 | } 240 | let elementLines = undefined; 241 | if (topElement.hasAttribute('data-codebraid-sourcepos-lines')) { 242 | elementLines = Number(topElement.getAttribute('data-codebraid-sourcepos-lines')); 243 | } 244 | const rect = topElement.getBoundingClientRect(); 245 | if (rect.top >= 0 || !elementLines) { 246 | topLine = minLine; 247 | if (topLine === editorMinLine) { 248 | let scrollY = window.scrollY; 249 | if (scrollY === 0) { 250 | topLine = 1; 251 | } else { 252 | let ratio = scrollY / (scrollY + rect.top); 253 | topLine = Math.max(Math.floor(ratio * topLine), 1); 254 | } 255 | } 256 | } else { 257 | topLine = minLine + Math.floor((-rect.top / rect.height) * elementLines); 258 | } 259 | if (!topLine) { 260 | return; 261 | } 262 | vscode.postMessage( 263 | { 264 | command: 'codebraidPreview.scrollEditor', 265 | startLine: topLine, // Editor is zero-indexed, but that's handled on editor side. 266 | } 267 | ); 268 | } 269 | let webviewVisibleObserver = new IntersectionObserver(webviewVisibleTracker, {threshold: [0, 0.25, 0.5, 0.75, 1]}); 270 | for (const element of document.querySelectorAll('[data-codebraid-sourcepos-start]')) { 271 | if (!element.hasAttribute('data-codebraid-sourcepos-lines')) { 272 | webviewVisibleObserver.observe(element); 273 | continue; 274 | } 275 | let subElementCount = 0; 276 | let startLine = Number(element.getAttribute('data-codebraid-sourcepos-start')); 277 | for (let subLine = 1; subLine <= Number(element.getAttribute('data-codebraid-sourcepos-lines')); subLine++) { 278 | const subElement = document.getElementById(`${element.id}-${subLine}`); 279 | if (subElement) { 280 | // Calculate line number assuming fenced code blocks. 281 | subElement.setAttribute('data-codebraid-sourcepos-start', `${startLine + subLine}`); 282 | webviewVisibleObserver.observe(subElement); 283 | subElementCount += 1; 284 | } 285 | } 286 | if (!subElementCount) { 287 | webviewVisibleObserver.observe(element); 288 | } 289 | } 290 | 291 | 292 | // Disable double-click causing selection, so that it can be used for jumping 293 | // to editor location. 294 | document.addEventListener( 295 | 'mousedown', 296 | (event) => { 297 | if (event.detail === 2) { 298 | event.preventDefault(); 299 | } 300 | }, 301 | false 302 | ); 303 | ondblclick = function(event) { 304 | if (editorMaxLine === 0 || visibleElements.size === 0) { 305 | return; 306 | } 307 | let targetY = event.clientY; 308 | let minLine = 0; 309 | let topElement = undefined; 310 | let topRect = undefined; 311 | let maxLine = editorMaxLine + 1; 312 | let bottomElement = undefined; 313 | let bottomRect = undefined; 314 | for (const element of visibleElements) { 315 | const startLine = Number(element.getAttribute('data-codebraid-sourcepos-start')); 316 | if (startLine < minLine || startLine > maxLine) { 317 | continue; 318 | } 319 | let rect = element.getBoundingClientRect(); 320 | if (rect.top <= targetY) { 321 | if (rect.bottom < targetY) { 322 | topElement = element; 323 | topRect = rect; 324 | minLine = startLine; 325 | } else { 326 | topElement = element; 327 | topRect = rect; 328 | minLine = startLine; 329 | bottomElement = element; 330 | bottomRect = rect; 331 | maxLine = startLine; 332 | break; 333 | } 334 | } else { 335 | bottomElement = element; 336 | bottomRect = rect; 337 | maxLine = startLine; 338 | } 339 | } 340 | let targetLine; 341 | if (topElement === bottomElement) { 342 | targetLine = minLine; 343 | } else { 344 | if (!bottomElement) { 345 | bottomElement = codebraidSourceposMaxElement; 346 | bottomRect = codebraidSourceposMaxElement.getBoundingClientRect(); 347 | maxLine = editorMaxLine; 348 | } 349 | if (!topElement) { 350 | const scrollY = window.scrollY; 351 | const ratio = (scrollY + targetY) / (scrollY + bottomRect.top); 352 | targetLine = Math.max(Math.floor(ratio * (maxLine - 1)), 1); 353 | } else { 354 | const ratio = (targetY - topRect.bottom) / (bottomRect.top - topRect.bottom); 355 | targetLine = minLine + Math.floor(ratio * (maxLine - minLine - 1)); 356 | } 357 | } 358 | vscode.postMessage( 359 | { 360 | command: 'codebraidPreview.moveCursor', 361 | startLine: targetLine, 362 | } 363 | ); 364 | }; 365 | 366 | 367 | const toolbarDiv = document.createElement('div'); 368 | document.body.append(toolbarDiv); 369 | toolbarDiv.classList.add('codebraid-toolbar'); 370 | const toolbarRefreshDiv = document.createElement('div'); 371 | toolbarDiv.appendChild(toolbarRefreshDiv); 372 | toolbarRefreshDiv.classList.add('codebraid-toolbar-button'); 373 | toolbarRefreshDiv.innerHTML = ''; 374 | toolbarRefreshDiv.addEventListener( 375 | 'click', 376 | () => { 377 | vscode.postMessage({command: 'codebraidPreview.refresh'}); 378 | }, 379 | false 380 | ); 381 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | 4 | ## v0.17.0 (2024-02-10) 5 | 6 | * Added refresh button to preview (#24). 7 | 8 | * Enabled the find widget in the preview webview. This allows searching 9 | within the preview using `CTRL+F`. 10 | 11 | * Improved the appearance of code in the preview. Code block text and the 12 | overall code block regions now have the exact same, correct background 13 | color. Fixed CSS interaction that caused the first line in a code block to 14 | be indented by a very small amount. Improved display of line numbers. 15 | 16 | * Updated Markdown CSS from VS Code's built-in Markdown preview. This keeps 17 | the appearance in sync with recent updates to the built-in Markdown preview. 18 | 19 | 20 | 21 | ## v0.16.0 (2024-01-16) 22 | 23 | * Improved preview compatibility with custom Pandoc HTML templates. 24 | Eliminated dependence on the location and format of a `meta` tag with 25 | `charset` attribute. Improved error messages for HTML that does not have 26 | expected format (#20). 27 | 28 | * The preview now sets the Pandoc template variable `codebraid_preview` to 29 | `true`. This makes it possible for custom HTML templates to adapt based on 30 | whether they are being used in the preview. 31 | 32 | * Added setting `codebraid.preview.css.useMarkdownPreviewStyles`. This causes 33 | the preview to inherit custom styles (CSS) from the built-in Markdown 34 | preview (`markdown.styles`), to maintain a similar appearance (#19). 35 | 36 | * Added setting `codebraid.preview.pandoc.preferPandocSourcepos`. This 37 | determines whether Pandoc's `sourcepos` is used (when available) to provide 38 | scroll sync instead of Codebraid Preview's `sourcepos`. Pandoc's 39 | `sourcepos` is used by default (when available) because it is usually more 40 | accurate. Codebraid Preview's `sourcepos` can be convenient when working 41 | with filters, since it makes fewer modifications to the AST. 42 | 43 | * Improved display of stderr. When the preview HTML has an unsupported format 44 | or is invalid, non-error stderr is no longer displayed. When the input 45 | format is `markdown_github`, a deprecation warning is only displayed a 46 | single time when the preview initially starts. 47 | 48 | * Updated KaTeX to v0.16.9. 49 | 50 | 51 | 52 | ## v0.15.0 (2023-04-20) 53 | 54 | * Added setting `codebraid.preview.pandoc.executable` (#17). This allows 55 | customizing the location of the Pandoc executable, or using a wrapper 56 | script. 57 | 58 | * Added setting `codebraid.preview.pandoc.extraEnv` (#17). This allows 59 | setting additional environment variables for the Pandoc subprocess. 60 | 61 | * Added setting `codebraid.preview.pandoc.showStderr` (#17). This allows the 62 | preview to display a notification when Pandoc completes without errors, but 63 | stderr is non-empty. 64 | 65 | * Added setting 66 | `codebraid.preview.security.pandocDefaultDataDirIsResourceRoot` (#17). 67 | This allows the preview to load resources like images and CSS from 68 | the default Pandoc user data directory. 69 | 70 | * The preview now automatically converts local `file:` URIs that point to the 71 | default Pandoc user data directory into VS Code webview URIs 72 | (`webview.asWebviewUri()`) that can be loaded within the webview. This only 73 | works when `codebraid.preview.security.pandocDefaultDataDirIsResourceRoot` 74 | is enabled (default). 75 | 76 | * The Pandoc option `--extract-media` is no longer used to create the preview, 77 | unless the document is a Jupyter notebook. This option was added in v0.14.0 78 | to support Jupyter notebooks, but it creates unnecessary temp image files 79 | for non-notebook documents. 80 | 81 | * The preview now provides partial support for the Pandoc option 82 | `--embed-resources`. As part of this, added new settings 83 | `codebraid.preview.security.allowEmbedded*`. 84 | 85 | * Added settings under `codebraid.preview.security`: `allowEmbeddedFonts`, 86 | `allowEmbeddedImages`, `allowEmbeddedMedia`, `allowEmbeddedScripts`, 87 | `allowEmbeddedStyles`. These determine whether the preview webview's 88 | content security policy allows `data:` URLs. All are `true` by default 89 | except for `allowEmbeddedScripts`. That is, the preview now automatically 90 | loads embedded fonts, images, media, and styles. 91 | 92 | * Added details in README under Security about the implications of the Pandoc 93 | options `--embed-resources` and `--extract-media`. 94 | 95 | * Updated KaTeX to v0.16.6. 96 | 97 | 98 | 99 | ## v0.14.0 (2023-04-08) 100 | 101 | * The preview is now compatible with Jupyter notebooks (`ipynb`) (#16). 102 | Scroll sync is not supported. VS Code opens notebooks with 103 | `vscode.NotebookEditor` rather than `vscode.TextEditor`, and the preview 104 | previously ignored `vscode.NotebookEditor`. 105 | 106 | 107 | 108 | ## v0.13.0 (2023-03-25) 109 | 110 | * Pandoc 3.1.1 is now the minimum recommended version. The Pandoc version is 111 | now checked when the extension loads, and there are warnings for older 112 | Pandoc versions that do not support all features. 113 | 114 | * Added preview support (including scroll sync) for additional Pandoc input 115 | formats: `latex`, `org`, `rst`, and `textile`. The preview displays any 116 | parse errors with a link that jumps to the corresponding source location, 117 | which is particularly useful for LaTeX. 118 | 119 | * Added scroll sync support for Markdown variants that are not based on 120 | CommonMark: `markdown`, `markdown_mmd`, `markdown_phpextra`, and 121 | `markdown_strict`. Previously, scroll sync was restricted to `commonmark`, 122 | `commonmark_x`, and `gfm`. 123 | 124 | * The preview is now compatible with any text-based document format supported 125 | by Pandoc (including custom Lua readers). Scroll sync is now possible for 126 | any text-based format, regardless of whether Pandoc provides a `sourcepos` 127 | extension. Scroll sync is automatically supported for all Markdown variants 128 | plus `latex`, `org`, `rst`, and `textile`. Code execution via Codebraid is 129 | still currently limited to formats based on Markdown. 130 | 131 | For formats not based on CommonMark, scroll sync is enabled with the new 132 | library `sourceposlib.lua`. This uses the AST produced by Pandoc and the 133 | document source to reconstruct a mapping between input and output. It 134 | produces `sourcepos`-style data for arbitrary text-based document formats. 135 | In some cases, scroll sync may be slightly inaccurate due to the complexity 136 | of reconstructing a source map after parsing. Scroll sync functionality 137 | will be degraded for documents primarily consisting of emoji or other code 138 | points outside the Basic Multilingual Plane (BMP), as well as for documents 139 | primarily consisting of punctuation and symbol code points. Tables with 140 | multi-line cells and footnotes can also interfere with scroll sync under 141 | some circumstances. 142 | 143 | Support for additional input formats can be added by defining them in the 144 | new setting `codebraid.preview.pandoc.build`. Scroll sync can be enabled 145 | for additional formats by creating a very short Lua reader that wraps the 146 | existing reader. See `scripts/pandoc/readers` for example Lua wrapper 147 | scripts; see `scripts/pandoc/lib/readerlib.lua` and 148 | `scripts/pandoc/lib/sourceposlib.lua` for additional documentation. 149 | 150 | * Document export now provides several default choices for export formats, 151 | instead of simply allowing Pandoc to guess export format based on file 152 | extension. Additional export formats can be defined under the new 153 | setting `codebraid.preview.pandoc.build`. 154 | 155 | * The preview now supports `--file-scope` for all Markdown variants, plus 156 | `latex`, `org`, `rst`, and `textile`. This is enabled with the new Lua 157 | reader library `readerlib.lua`. Previously, `--file-scope` was ignored in 158 | generating the preview. 159 | 160 | * Reorganized settings to account for input formats that are not based on 161 | Markdown. `codebraid.preview.pandoc.fromFormat` and 162 | `codebraid.preview.pandoc.options` are deprecated. They are replaced by 163 | `codebraid.preview.pandoc.build`, under property `*.md`. 164 | `codebraid.preview.pandoc.build` allows each input format to define multiple 165 | preview formats and multiple export formats. Each preview/export format can 166 | define command-line options and also defaults that are saved to a Pandoc 167 | defaults file. 168 | 169 | * Setting `codebraid.preview.pandoc.previewDefaultsFile` is deprecated and 170 | replaced with `codebraid.preview.pandoc.defaultsFile`. This makes it 171 | clearer that the defaults file is used for both preview and export. 172 | 173 | * Fixed a bug that prevented a preview from starting when a document is open, 174 | but the Panel was clicked more recently than the document. 175 | 176 | * Reimplemented configuration processing and updating. Modifying 177 | configuration during preview update, Codebraid execution, or document export 178 | no longer has the potential to result in inconsistent state. 179 | 180 | * Removed Julia syntax highlighting customization (#4), since it has been 181 | merged upstream 182 | (https://github.com/microsoft/vscode-markdown-tm-grammar/pull/111). 183 | 184 | 185 | 186 | ## v0.12.0 (2023-01-19) 187 | 188 | * Pandoc 3.0 compatibility: Updated Lua filters by replacing `pandoc.Null()` 189 | with `pandoc.Blocks{}`. Minimum supported Codebraid version is now v0.10.3. 190 | 191 | * Added new setting `codebraid.preview.css.useMarkdownPreviewFontSettings`. 192 | This causes the preview to inherit font settings (font family, font size, 193 | line height) from the built-in Markdown preview (settings under 194 | `markdown.preview`), to maintain a similar appearance. 195 | 196 | * Updated preview CSS to include the most recent CSS used by the built-in VS 197 | Code Markdown preview. 198 | 199 | * Fixed bug from v0.11.0 that caused error messages from Pandoc to be 200 | displayed incorrectly. 201 | 202 | 203 | 204 | ## v0.11.0 (2023-01-16) 205 | 206 | * The preview panel now has access to local resources in the workspace 207 | folders, not just access to resources in the document directory. 208 | 209 | * Added new setting `codebraid.preview.security.extraLocalResourceRoots`. 210 | This allows the preview panel to load resources such as images from 211 | locations other than the document directory and the workspace folders (#15). 212 | 213 | * Reimplemented content security policy and added new related settings under 214 | `codebraid.preview.security` (#8, #13). 215 | 216 | - The webview content security policy now includes `media-src`. 217 | 218 | - There are new settings that determine whether fonts, images, media, 219 | styles, and scripts are allowed from local sources and from remote 220 | sources. By default, local sources are enabled for everything except 221 | scripts. Local access is restricted to the document directory, the 222 | workspace folders, and any additional locations specified under 223 | `security.extraLocalResourceRoots`. By default, remote sources are 224 | disabled. Inline scripts are also disabled by default. 225 | 226 | - Scripts are now more restricted by default. `script-src` no longer 227 | includes `unsafe-inline` or the document directory. Only scripts bundled 228 | with the extension are enabled by default, plus inline scripts from 229 | Pandoc's HTML template (which are enabled via hash). To re-enable inline 230 | scripts, use `security.allowInlineScripts`. To re-enable local scripts, 231 | use `security.allowLocalScripts`. 232 | 233 | * All preview customization to the Pandoc HTML output is now inserted after 234 | rather than before the charset meta tag. This includes the base tag, 235 | content security policy meta tag, and Codebraid scripts. 236 | 237 | * Updated KaTeX to v0.16.4. 238 | 239 | 240 | 241 | ## v0.10.0 (2022-12-04) 242 | 243 | * Added new settings `codebraid.preview.css.useDefault` and 244 | `codebraid.preview.css.overrideDefault` for controlling whether the default 245 | preview CSS is loaded and whether it is overridden by document CSS. 246 | Document CSS now has precedence by default (#14). 247 | 248 | * A Codebraid Preview defaults file now has precedence over the extension's 249 | Pandoc settings. 250 | 251 | * Updated KaTeX to v0.16.3. 252 | 253 | 254 | 255 | ## v0.9.0 (2022-07-29) 256 | 257 | * The preview Pandoc AST is now preprocessed before any user filters are 258 | applied. Adjacent `Str` nodes that are wrapped in `data-pos` spans as a 259 | result of the `sourcepos` extension are now merged. `sourcepos` splits text 260 | that normally would have been in a single `Str` node into multiple `Str` 261 | nodes, and then wraps each in a `data-pos` span. The preprocessing makes 262 | user filters behave as closely as possible to the non-`sourcepos` case (#9). 263 | 264 | * Added details about `commonmark_x`, including LaTeX macro expansion, to 265 | README (#10). 266 | 267 | * When the Codebraid process fails and there is stderr output, the full 268 | details are now written to the Output log. 269 | 270 | 271 | 272 | ## v0.8.0 (2022-07-11) 273 | 274 | * `_codebraid_preview.yaml` now supports essentially all 275 | [Pandoc defaults options](https://pandoc.org/MANUAL.html#defaults-files) 276 | and no longer limits the characters allowed in filter file names (#2). 277 | Previously, only `input-files`, `input-file`, `from`, and `filters` were 278 | supported. 279 | 280 | * The "Codebraid Preview" button in the status bar now only appears when a 281 | Markdown document is open and visible, and does not yet have a preview. 282 | Previously, after the extension loaded, the button was visible for 283 | non-Markdown files and was also visible if the info panel was open. 284 | 285 | * Fixed a bug that prevented YAML metadata from working with Codebraid. 286 | 287 | * Fixed a bug that prevented identification of inherited languages (for 288 | example, with `.cb-paste`). 289 | 290 | 291 | 292 | ## v0.7.0 (2022-06-29) 293 | 294 | * Added setting `codebraid.preview.pandoc.showRaw`. This provides a verbatim 295 | representation of non-HTML raw content `{=format}` in the preview. 296 | 297 | * Added more details and progress animations in the webview display that is 298 | shown before Pandoc finishes creating the initial preview. 299 | 300 | 301 | 302 | ## v0.6.0 (2022-06-25) 303 | 304 | * Minimum supported Codebraid is now v0.9.0. The preview now shows correct 305 | file names and line numbers for errors/warnings related to the Markdown 306 | source, rather than using file names like `` and line numbers that 307 | are incorrect when multiple Markdown sources are concatenated. The preview 308 | now shares cache files with normal Codebraid processing, rather than 309 | creating a separate cache entry. All of this is based on the new Codebraid 310 | option `--stdin-json-header`. 311 | 312 | * Fixed a bug that prevented `codebraid` executable from being located in 313 | Python installations with the `python` executable under `bin/` or `Scripts/` 314 | rather than at the root of the environment (#5). 315 | 316 | * Improved and optimized process for finding `codebraid` executable. 317 | 318 | * Updated KaTeX to v0.16.0. 319 | 320 | * Improved display of Codebraid output in the preview. Code chunks that have 321 | not yet been processed/executed by Codebraid and thus do not have output are 322 | indicated by placeholder boxes. Output from modified code chunks or from 323 | stale cache is more clearly indicated. 324 | 325 | * Improved responsiveness of progress animations for "Codebraid" button and 326 | preview. There is no longer a significant, noticeable time delay between 327 | clicking the button and the start of button and preview progress animations. 328 | 329 | * Added logging in VS Code's Output tab, under "Codebraid Preview". 330 | 331 | 332 | 333 | ## v0.5.1 (2022-06-11) 334 | 335 | * Fixed scroll sync bug that could cause the editor to jump to the beginning 336 | of a document when the preview is scrolled to the very end of the 337 | document. 338 | 339 | 340 | 341 | ## v0.5.0 (2022-06-11) 342 | 343 | * Minimum supported Codebraid is now v0.8.0. 344 | 345 | * Improved process for locating `codebraid` executable. If a Python 346 | interpreter is set in VS Code, then that Python installation is now checked 347 | for a `codebraid` executable. If no executable is found, then PATH is 348 | checked for a `codebraid` executable. A warning message is displayed when 349 | the Python installation set in VS Code lacks an executable and the extension 350 | falls back to an executable on PATH. Previously, only PATH was checked 351 | for an executable (#5). 352 | 353 | * If the `codebraid` executable is part of an Anaconda installation, it is now 354 | launched via `conda run` so that the relevant environment will be activated. 355 | 356 | * Fixed a bug that prevented Codebraid output from being displayed for code 357 | chunks with a named `session` or `source`. 358 | 359 | 360 | 361 | ## v0.4.0 (2022-06-04) 362 | 363 | * Added support for `--only-code-output` from Codebraid v0.7.0. The preview 364 | now refreshes automatically for Codebraid documents, displaying all code 365 | output that is currently available. The build process is now nearly as fast 366 | as plain Pandoc. Code execution still requires clicking the "Codebraid" 367 | button or using the "Run code with Codebraid" command. 368 | 369 | * Added basic support for `filters` in `_codebraid_preview.yaml` (#2). Spaces 370 | and some other characters are not currently supported in filter names. 371 | 372 | * When the extension loads, it now checks whether Codebraid is installed and 373 | determines the Codebraid version. Loading a Codebraid-compatible document 374 | now results in an error message if Codebraid is not installed or if the 375 | version available is not compatible with Codebraid Preview. Plain preview 376 | without code execution still works automatically in these cases. 377 | 378 | * Added [Codicons](https://github.com/microsoft/vscode-codicons) for 379 | displaying messages in the preview webview. 380 | 381 | * Fixed a bug that prevented error messages from stderr from being correctly 382 | converted to literal text in HTML. 383 | 384 | * Scroll sync is now supported for all CommonMark-based formats (`commonmark`, 385 | `commonmark_x`, `gfm`), not just `commonmark_x`. 386 | 387 | 388 | 389 | ## v0.3.0 (2022-05-15) 390 | 391 | * Extension setting `codebraid.preview.pandoc.options` now works (#3). 392 | 393 | * Under Windows, Pandoc option values in `codebraid.preview.pandoc.options` 394 | that begin with unquoted `~/` or `~\` have the `~` expanded to the user home 395 | directory via `os.homedir()`. 396 | 397 | * Document export now works with file names containing spaces. 398 | 399 | * Added temporary support for syntax highlighting in Julia code blocks, until 400 | VS Code Markdown grammar is updated to support this (#4). 401 | 402 | * Updated KaTeX to 0.15.3. 403 | 404 | 405 | 406 | ## v0.2.0 (2022-03-05) 407 | 408 | * Fixed packaging of KaTeX so that equations are rendered correctly (#1). 409 | 410 | 411 | 412 | ## v0.1.0 (2022-02-22) 413 | 414 | * Initial release. 415 | -------------------------------------------------------------------------------- /pandoc/lib/readerlib.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | 9 | -- Pandoc Lua reader library for [Codebraid](https://codebraid.org/) and 10 | -- related software. 11 | -- 12 | -- * Only reads from stdin, not the filesystem. The first line of input 13 | -- must be file metadata in JSON format, including filename(s) and 14 | -- length(s). This allows multiple (concatenated) sources to be read from 15 | -- stdin, while still processing them as if they were separate files read 16 | -- from the filesystem. That includes `--file-scope` emulation via a 17 | -- `+file_scope` extension. 18 | -- 19 | -- * For formats with Pandoc's `sourcepos` extension, modifies the 20 | -- `sourcepos` data. Source names are omitted. All line numbers refer to 21 | -- line numbers in the concatenated sources, rather than line numbers 22 | -- within individual sources. (This is regardless of `+file_scope`.) This 23 | -- provides a simple, unambiguous way to handle sources that are included 24 | -- multiple times. 25 | -- 26 | -- * For formats without Pandoc's `sourcepos` extension, defines a 27 | -- `+sourcepos` extension that generates `sourcepos`-style data. Source 28 | -- names are omitted. All line numbers refer to line numbers in the 29 | -- concatenated sources, rather than line numbers within individual 30 | -- sources. (This is regardless of `+file_scope`.) 31 | -- 32 | -- 33 | -- # Usage 34 | -- 35 | -- Create a reader Lua script, for example, `markdown.lua`. The example below 36 | -- assumes that `markdown.lua` and `readerlib.lua` are in the same directory. 37 | -- The loading process for `readerlib.lua` should be adjusted if it is 38 | -- elsewhere. `readerlib.lua` itself requires at least one external library, 39 | -- `sourceposlib.lua'. If this is not in the same directory as 40 | -- `readerlib.lua`, then redefine `readerlib.libPath` accordingly. 41 | -- 42 | -- local format = 'markdown' 43 | -- local scriptDir = pandoc.path.directory(PANDOC_SCRIPT_FILE) 44 | -- local readerlib = dofile(pandoc.path.join{scriptDir, 'readerlib.lua'}) 45 | -- 46 | -- Extensions = readerlib.getExtensions(format) 47 | -- 48 | -- function Reader(sources, opts) 49 | -- return readerlib.read(sources, format, opts) 50 | -- end 51 | -- 52 | -- Then use the custom reader: 53 | -- 54 | -- pandoc -f markdown.lua -t ... 55 | -- 56 | -- Instead of `-f markdown.lua`, the path to `markdown.lua` may need to be 57 | -- specified, depending on where it is located and system configuration. 58 | -- Pandoc looks for readers relative to the working directory, and then checks 59 | -- in the `custom` subdirectory of the user data directory (see Pandoc's 60 | -- `--data-dir`). 61 | -- 62 | -- Instead of using `--file-scope`, use `-f markdown.lua+file_scope`. 63 | -- 64 | -- The `+sourcepos` extension can be used with all text-based formats. (But 65 | -- see the notes below about the `sourcepos` data.) 66 | -- 67 | -- 68 | -- # Input format 69 | -- 70 | -- The first line of input must be file metadata in JSON format, including 71 | -- filename(s) and length(s): 72 | -- 73 | -- {"sources": [{"name": , "lines": }, ...]} 74 | -- 75 | -- This is then followed by the concatenated contents of all sources. The 76 | -- text for each source must have newlines appended until it ends with the 77 | -- sequence `\n\n`. 78 | -- 79 | -- 80 | -- # `sourcepos` support for formats without Pandoc's `sourcepos` extension 81 | -- 82 | -- The reader generates an AST, like normal. Then it walks through the AST, 83 | -- and for each Str/Code/CodeBlock/RawInline/RawBlock node it searches the 84 | -- document sources for the corresponding text. Each search picks up where 85 | -- the last successful search ended. Each successful search results in the 86 | -- current search node being wrapped in a Span/Div node containing source data 87 | -- in the `sourcepos` format: `data-pos` attribute with source info. The 88 | -- `sourcepos` data differs from that provided by Pandoc in a few ways. 89 | -- 90 | -- * Source names are omitted. All line numbers refer to line numbers in 91 | -- the concatenated sources, rather than line numbers within individual 92 | -- sources. This provides a simple, unambiguous way to handle sources 93 | -- that are included multiple times. It does mean that external 94 | -- applications using the `sourcepos` data must maintain a mapping between 95 | -- line numbers in the concatenated sources and line numbers in individual 96 | -- sources. 97 | -- 98 | -- * Only line numbers are calculated. Column numbers are always set to 0 99 | -- (zero). Column numbers cannot be determined accurately unless they are 100 | -- tracked during document parsing. Line numbers themselves will not 101 | -- always be correct, since they are reconstructed after parsing. 102 | -- 103 | 104 | 105 | local VERSION = {0, 2, 0} 106 | local VERSION_DATE = '20240116' 107 | local VERSION_STRING = table.concat(VERSION, '.') 108 | local AUTHOR_NOTE = table.concat({ 109 | 'Pandoc Lua reader library for [Codebraid](https://codebraid.org/) and related software.', 110 | 'Version ' .. VERSION_STRING .. ' from ' .. VERSION_DATE .. '.', 111 | 'Copyright (c) 2023-2024, Geoffrey M. Poore.', 112 | 'All rights reserved.', 113 | 'Licensed under the BSD 3-Clause License: http://opensource.org/licenses/BSD-3-Clause.', 114 | }, '\n') 115 | local readerlib = { 116 | VERSION = VERSION, 117 | VERSION_DATE = VERSION_DATE, 118 | VERSION_STRING = VERSION_STRING, 119 | AUTHOR_NOTE = AUTHOR_NOTE, 120 | } 121 | 122 | -- There is no way for a custom reader to access command-line `--file-scope` 123 | -- status, so a corresponding extension is defined. The `sourcepos` extension 124 | -- defined here is primarily for Pandoc formats that do not already have 125 | -- `sourcepos` support. The `prefer_pandoc_sourcepos` extension determines 126 | -- whether the `sourcepos` extension uses Pandoc's `sourcepos` (when 127 | -- available) or uses `sourceposlib` instead. The `sourceposlib` 128 | -- implementation is usually less accurate, but also inserts fewer block-level 129 | -- nodes into the AST and thus can be more convenient for working with 130 | -- filters. 131 | readerlib.customExtensions = { 132 | file_scope = false, 133 | sourcepos = false, 134 | prefer_pandoc_sourcepos = true, 135 | } 136 | 137 | 138 | local function throwFatalError(message) 139 | io.stderr:write('The Codebraid custom Lua reader for Pandoc failed:\n') 140 | if message:sub(-1) == '\n' then 141 | io.stderr:write(message) 142 | else 143 | io.stderr:write(message .. '\n') 144 | end 145 | os.exit(1) 146 | end 147 | 148 | 149 | -- By default, assume all libraries are in the same directory as this file. 150 | -- Libraries are only loaded when needed, so that `readerlib.libPath` can be 151 | -- redefined if necessary before load time. 152 | readerlib.libPath = pandoc.path.directory(debug.getinfo(1, 'S').source:sub(2)) 153 | local didLoadLibs = false 154 | local sourceposlib 155 | local loadLibs = function () 156 | sourceposlib = dofile(pandoc.path.join{readerlib.libPath, 'sourceposlib.lua'}) 157 | didLoadLibs = true 158 | end 159 | 160 | 161 | local nodesWithAttributes = { 162 | CodeBlock=true, 163 | Div=true, 164 | Figure=true, 165 | Header=true, 166 | Table=true, 167 | Code=true, 168 | Image=true, 169 | Link=true, 170 | Span=true, 171 | Cell=true, 172 | TableFoot=true, 173 | TableHead=true, 174 | } 175 | 176 | local formatHasPandocSourceposMap = {} 177 | readerlib.formatHasPandocSourcepos = function (format) 178 | if formatHasPandocSourceposMap[format] == nil then 179 | readerlib.getExtensions(format) 180 | end 181 | return formatHasPandocSourceposMap[format] 182 | end 183 | 184 | readerlib.getExtensions = function (format) 185 | local extensions = pandoc.format.extensions(format) 186 | formatHasPandocSourceposMap[format] = extensions['sourcepos'] ~= nil 187 | for k, v in pairs(readerlib.customExtensions) do 188 | if extensions[k] == nil then 189 | extensions[k] = v 190 | end 191 | end 192 | -- Format-specific patches 193 | if format == 'textile' then 194 | extensions['raw_html'] = nil 195 | end 196 | return extensions 197 | end 198 | 199 | 200 | local function parseConcatSources(concatSources) 201 | local sources = {} 202 | local concatSourcesWithJsonHeaderText = concatSources[1].text 203 | local concatSourcesFirstNewlineIndex, _ = concatSourcesWithJsonHeaderText:find('\n', 1, true) 204 | if concatSourcesFirstNewlineIndex == nil then 205 | throwFatalError('Missing JSON header containing source metadata') 206 | end 207 | local rawJsonHeader = concatSourcesWithJsonHeaderText:sub(1, concatSourcesFirstNewlineIndex-1) 208 | local concatSourcesText = concatSourcesWithJsonHeaderText:sub(concatSourcesFirstNewlineIndex+1) 209 | local jsonHeader = pandoc.json.decode(rawJsonHeader, false) 210 | if type(jsonHeader) ~= 'table' or type(jsonHeader.sources) ~= 'table' then 211 | throwFatalError('Incomplete or invalid JSON header for source metadata') 212 | end 213 | for k, v in pairs(jsonHeader.sources) do 214 | if type(k) ~= 'number' or type(v) ~= 'table' then 215 | throwFatalError('Incomplete or invalid JSON header for source metadata') 216 | end 217 | if type(v.name) ~= 'string' or type(v.lines) ~= 'number' then 218 | throwFatalError('Incomplete or invalid JSON header for source metadata') 219 | end 220 | end 221 | if #jsonHeader.sources == 1 then 222 | local newSource = { 223 | name = jsonHeader.sources[1].name, 224 | lines = jsonHeader.sources[1].lines, 225 | text = concatSourcesText 226 | } 227 | table.insert(sources, newSource) 228 | else 229 | local concatSourcesIndex = 1 230 | local stringbyte = string.byte 231 | local newlineAsByte = stringbyte('\n') 232 | for _, src in pairs(jsonHeader.sources) do 233 | local newSource = { 234 | name = src.name, 235 | lines = src.lines 236 | } 237 | local lines = src.lines 238 | if concatSourcesIndex > concatSourcesText:len() then 239 | throwFatalError('Did not receive text for source "' .. src.name .. '"') 240 | end 241 | for index = concatSourcesIndex, concatSourcesText:len() do 242 | if stringbyte(concatSourcesText, index) == newlineAsByte then 243 | lines = lines - 1 244 | if lines == 0 then 245 | newSource.text = concatSourcesText:sub(concatSourcesIndex, index) 246 | if newSource.text:sub(-2) ~= '\n\n' then 247 | throwFatalError('Source "' .. src.name .. '" does not end with an empty line (\\n\\n)') 248 | end 249 | concatSourcesIndex = index + 1 250 | break 251 | end 252 | end 253 | end 254 | if lines ~= 0 or newSource.text == nil then 255 | throwFatalError('Did not receive all text for source "' .. src.name .. '"') 256 | end 257 | table.insert(sources, newSource) 258 | end 259 | end 260 | local offset = 0 261 | for _, source in pairs(sources) do 262 | source.sanitizedName = source.name:gsub(':?%.?[\\/]', '__'):gsub(' ', '-'):gsub('%.', '') 263 | source.offset = offset 264 | offset = offset + source.lines 265 | end 266 | return sources, concatSourcesText 267 | end 268 | 269 | local function parseExtensions(format, extensions) 270 | local pandocExtensions = {} 271 | local customExtensions = {} 272 | for _, ext in pairs(extensions) do 273 | if readerlib.customExtensions[ext] == nil then 274 | pandocExtensions[ext] = true 275 | elseif ext == 'sourcepos' and readerlib.formatHasPandocSourcepos(format) then 276 | if extensions:includes('prefer_pandoc_sourcepos') then 277 | pandocExtensions[ext] = true 278 | else 279 | customExtensions[ext] = true 280 | end 281 | else 282 | customExtensions[ext] = true 283 | end 284 | end 285 | return pandocExtensions, customExtensions 286 | end 287 | 288 | -- read( 289 | -- concatSources: , 290 | -- format: , 291 | -- opts: , 292 | -- ) 293 | readerlib.read = function (concatSources, format, opts) 294 | if not didLoadLibs then 295 | loadLibs() 296 | end 297 | if not (concatSources and format and opts) then 298 | throwFatalError('Missing arguments: sources, format, and opts are required') 299 | end 300 | if #concatSources ~= 1 or concatSources[1].name ~= '' then 301 | throwFatalError('Only a single input read from stdin is supported') 302 | end 303 | if not format:match('^[a-z_]+$') then 304 | throwFatalError('Invalid format name (any extensions should be passed via opts.extensions)') 305 | end 306 | -- When reading, always use `pandoc.read(, ...)` rather than 307 | -- `pandoc.read(, ...)`. The `sources` returned by 308 | -- `parseConcatSources()` have the same attributes as those generated by 309 | -- Pandoc itself, but they are not the Pandoc Haskell Sources type and 310 | -- thus are not accepted by `pandoc.read()`. 311 | local sources, concatSourcesText = parseConcatSources(concatSources) 312 | local pandocExtensions, customExtensions = parseExtensions(format, opts.extensions) 313 | local doc 314 | if not customExtensions['file_scope'] or #sources == 1 then 315 | doc = pandoc.read(concatSourcesText, {format=format, extensions=pandocExtensions}, opts) 316 | if pandocExtensions['sourcepos'] then 317 | doc = doc:walk(sourceposlib.strMergeFilter) 318 | elseif customExtensions['sourcepos'] then 319 | doc = sourceposlib.addSourcepos(doc, concatSourcesText, format, pandocExtensions, customExtensions, 0) 320 | end 321 | else 322 | -- Emulate `--file-scope` using the `+file_scope` extension: 323 | -- * Read each source individually. 324 | -- * Merge metadata, with later metadata overwriting earlier. Note 325 | -- that Pandoc automatically updates the document returned by a 326 | -- `Reader()` with any metadata from `--metadata`, so `--metadata` 327 | -- doesn't need to be handled explicitly here. 328 | -- * Change all ids and internal links to include a prefix based on 329 | -- source name. 330 | -- * Wrap each source's blocks in a Div with an id based on source 331 | -- name. Then join the Divs to create the final document. 332 | doc = pandoc.Pandoc({}) 333 | for sourceNum, source in pairs(sources) do 334 | local subDoc = pandoc.read(source.text, {format=format, extensions=pandocExtensions}, opts) 335 | for k, v in pairs(subDoc.meta) do 336 | doc.meta[k] = v 337 | end 338 | local subFilter = {} 339 | local updateIdSourcepos 340 | local addDataPosOffset 341 | if pandocExtensions['sourcepos'] and sourceNum > 1 then 342 | addDataPosOffset = function (dataPos) 343 | local startLine, firstIgnored, endLine, secondIgnored 344 | if dataPos:find(';', nil, true) == nil then 345 | startLine, firstIgnored, endLine, secondIgnored = dataPos:match('^(%d+)(:%d+%-)(%d+)(:%d+)$') 346 | else 347 | startLine, firstIgnored, endLine, secondIgnored = dataPos:match('^(%d+)(:.+;%d+:%d+%-)(%d+)(:%d+)$') 348 | end 349 | startLine = tostring(tonumber(startLine) + source.offset) 350 | endLine = tostring(tonumber(endLine) + source.offset) 351 | return startLine .. firstIgnored .. endLine .. secondIgnored 352 | end 353 | updateIdSourcepos = function (node) 354 | local didModify = false 355 | if node.identifier ~= '' then 356 | node.identifier = source.sanitizedName .. '__' .. node.identifier 357 | didModify = true 358 | end 359 | if node.attributes['data-pos'] then 360 | node.attributes['data-pos'] = addDataPosOffset(node.attributes['data-pos']) 361 | didModify = true 362 | end 363 | if didModify then 364 | return node 365 | end 366 | return nil 367 | end 368 | else 369 | updateIdSourcepos = function (node) 370 | if node.identifier == '' then 371 | return nil 372 | end 373 | node.identifier = source.sanitizedName .. '__' .. node.identifier 374 | return node 375 | end 376 | end 377 | for k, _ in pairs(nodesWithAttributes) do 378 | if k ~= 'Link' then 379 | subFilter[k] = updateIdSourcepos 380 | end 381 | end 382 | subFilter.Link = function (node) 383 | local didModify = false 384 | local maybeNode = updateIdSourcepos(node) 385 | if maybeNode ~= nil then 386 | node = maybeNode 387 | didModify = true 388 | end 389 | if node.target:sub(1, 1) == '#' then 390 | node.target = '#' .. source.sanitizedName .. '__' .. node.target:sub(2) 391 | didModify = true 392 | end 393 | if didModify then 394 | return node 395 | end 396 | return nil 397 | end 398 | if pandocExtensions['sourcepos'] then 399 | for k, v in pairs(sourceposlib.strMergeFilter) do 400 | if subFilter[k] ~= nil then 401 | throwFatalError('Lua filter clash in processing custom extension +file_scope') 402 | end 403 | subFilter[k] = v 404 | end 405 | end 406 | subDoc = subDoc:walk(subFilter) 407 | if customExtensions['sourcepos'] then 408 | subDoc = sourceposlib.addSourcepos(subDoc, source.text, format, pandocExtensions, customExtensions, source.offset) 409 | end 410 | doc.blocks:insert(pandoc.Div(subDoc.blocks, {id=source.sanitizedName})) 411 | end 412 | end 413 | return doc 414 | end 415 | 416 | 417 | return readerlib 418 | -------------------------------------------------------------------------------- /pandoc/lib/sourceposlib.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2023, Geoffrey M. Poore 2 | -- All rights reserved. 3 | -- 4 | -- Licensed under the BSD 3-Clause License: 5 | -- http://opensource.org/licenses/BSD-3-Clause 6 | -- 7 | 8 | 9 | -- Library for manipulating `sourcepos` data generated by Pandoc, and for 10 | -- generating `sourcepos` data for formats that lack Pandoc `sourcepos` 11 | -- support. 12 | -- 13 | -- 14 | -- # Preprocessing filters for Pandoc-generated `sourcepos` data 15 | -- 16 | -- The `sourceposlib.strMergeFilter` merges adjacent `Str` nodes that are 17 | -- wrapped in `sourcepos` spans into a single `Str` node in a single 18 | -- `sourcepos` span, so that the final `Str` node has the same content as 19 | -- would be generated without `sourcepos`. It makes `sourcepos` more 20 | -- compatible with filters operating on `Str` nodes. 21 | -- 22 | -- The `sourceposlib.stripSourceposFilter` removes all `sourcepos` data. It 23 | -- is intended for parts of a document where `sourcepos` data is not needed 24 | -- and should be removed to simplify the AST for subsequent filters. 25 | -- 26 | -- 27 | -- # Generating `sourcepos` data 28 | -- 29 | -- The `sourceposlib.addSourcepos()` function adds `sourcepos` data to an AST. 30 | -- It walks through the AST, and for each 31 | -- Str/Code/CodeBlock/RawInline/RawBlock node it searches the document sources 32 | -- for the corresponding text. Each search picks up where the last successful 33 | -- search ended. Each successful search results in the current search node 34 | -- being wrapped in a Span/Div node containing source data in the `sourcepos` 35 | -- format: `data-pos` attribute with source info. The `sourcepos` data 36 | -- differs from that provided by Pandoc in a few ways. 37 | -- 38 | -- * Source names are omitted. All line numbers refer to line numbers in 39 | -- the concatenated sources, rather than line numbers within individual 40 | -- sources. This provides a simple, unambiguous way to handle sources 41 | -- that are included multiple times. It does mean that external 42 | -- applications using the `sourcepos` data must maintain a mapping between 43 | -- line numbers in the concatenated sources and line numbers in individual 44 | -- sources. 45 | -- 46 | -- * Only line numbers are calculated. Column numbers are always set to 0 47 | -- (zero). Column numbers cannot be determined accurately unless they are 48 | -- tracked during document parsing. Line numbers themselves will not 49 | -- always be correct, since they are reconstructed after parsing. 50 | -- 51 | -- * `sourcepos` data is not guaranteed. If the parallel search between the 52 | -- AST and the sources fails to find matches for part of a document, no 53 | -- `sourcepos` data is added to the AST for that region. 54 | -- 55 | -- The generated `sourcepos` data has limitations compared to Pandoc 56 | -- `sourcepos` support. 57 | -- 58 | -- * Attempting to generate `sourcepos` data after parsing will always be 59 | -- less accurate. 60 | -- 61 | -- * `sourcepos` data is only generated for code points in the Basic 62 | -- Multilingual Plane (BMP). Punctuation and symbol code points are also 63 | -- ignored. 64 | -- 65 | -- * Tables with multi-line cells can interfere with the current algorithm. 66 | -- The current algorithm proceeds topdown through the AST and linearly 67 | -- through the sources. However, multi-line table cells span multiple 68 | -- source lines, which would require backtracking in the sources. In many 69 | -- cases, the `sourcepos` algorithm will automatically recover. However, 70 | -- it will fail for "long" multi-line cells, and it can become inaccurate 71 | -- when adjacent cells contain similar or identical text. 72 | -- 73 | -- * Footnotes that are not defined inline can introduce inaccuracies. The 74 | -- current algorithm proceeds topdown through the AST and linearly through 75 | -- the sources. When footnotes are not defined inline, backtracking or 76 | -- lookahead would be required to generate precise `sourcepos` data. In 77 | -- many cases, the `sourcepos` algorithm will automatically recover. 78 | -- However, it will fail for "very long" footnotes, and it can become 79 | -- inaccurate for shorter footnotes. 80 | -- 81 | 82 | 83 | local VERSION = {0, 1, 0} 84 | local VERSION_DATE = '20230324' 85 | local VERSION_STRING = table.concat(VERSION, '.') 86 | local AUTHOR_NOTE = table.concat({ 87 | 'Library for manipulating sourcepos data generated by Pandoc, and for generating sourcepos data for formats that lack Pandoc sourcepos support.', 88 | 'Version ' .. VERSION_STRING .. ' from ' .. VERSION_DATE .. '.', 89 | 'Copyright (c) 2023, Geoffrey M. Poore.', 90 | 'All rights reserved.', 91 | 'Licensed under the BSD 3-Clause License: http://opensource.org/licenses/BSD-3-Clause.', 92 | }, '\n') 93 | local sourceposlib = { 94 | VERSION = VERSION, 95 | VERSION_DATE = VERSION_DATE, 96 | VERSION_STRING = VERSION_STRING, 97 | AUTHOR_NOTE = AUTHOR_NOTE, 98 | } 99 | 100 | 101 | 102 | 103 | -- Pandoc Lua filter for preprocessing an AST with `sourcepos` data. Intended 104 | -- to run before all other filters. Merges adjacent `Str` nodes that are 105 | -- wrapped in `sourcepos` spans, so that the final `Str` nodes have the same 106 | -- content as would be generated without `sourcepos`. With `sourcepos`, some 107 | -- adjacent non-whitespace characters are split into multiple `Str` nodes, and 108 | -- then each of these is wrapped in a `sourcepos` span. 109 | sourceposlib.strMergeFilter = { 110 | Inlines = function (nodes) 111 | local didModify = false 112 | local preprocNodes = pandoc.Inlines{} 113 | local lastStrNode = nil 114 | for _, node in pairs(nodes) do 115 | if node.t == 'Span' and node.attributes['data-pos'] ~= nil and #node.c == 1 and node.c[1].t == 'Str' then 116 | if lastStrNode ~= nil then 117 | -- There is no attempt to merge the `data-pos` attributes 118 | -- here, because only the line numbers are used for scroll 119 | -- sync. Adjacent string nodes are always on the same 120 | -- line, and inaccurate column numbers are irrelevant. 121 | lastStrNode.text = lastStrNode.text .. node.c[1].text 122 | didModify = true 123 | else 124 | lastStrNode = node.c[1] 125 | preprocNodes:insert(node) 126 | end 127 | else 128 | lastStrNode = nil 129 | preprocNodes:insert(node) 130 | end 131 | end 132 | if didModify then 133 | return preprocNodes 134 | end 135 | return nil 136 | end 137 | } 138 | 139 | -- Remove all `sourcepos` data. 140 | local stripSourcepos = function (node) 141 | local dataPos = node.attributes['data-pos'] 142 | if dataPos == nil then 143 | return nil 144 | end 145 | if node.identifier == '' and #(node.classes) == 0 and #(node.attributes) == 1 then 146 | return node.content 147 | end 148 | node.attributes['data-pos'] = nil 149 | return node 150 | end 151 | sourceposlib.stripSourceposFilter = { 152 | Span = stripSourcepos, 153 | Div = stripSourcepos, 154 | } 155 | 156 | 157 | 158 | 159 | -- Pattern for matching punctuation, symbols, and similar code points that may 160 | -- appear in the AST differently than in the source. 161 | -- 162 | -- ASCII punctuation and symbols will often be backslash-escaped or otherwise 163 | -- escaped in the document source, since formats like Markdown give them 164 | -- special roles. So a literal ASCII punctuation/symbol character in the AST 165 | -- will often correspond to an escaped version in the source. The Pandoc 166 | -- `smart` extension converts straight ASCII quotation marks into curly 167 | -- quotation marks, hyphen sequences into dashes, three periods into an 168 | -- ellipsis symbol, and some spaces into non-breaking spaces. So curly 169 | -- quotation marks, dashes, ellipsis symbols, and non-breaking spaces in the 170 | -- AST will often correspond to different code points/sequences in the source. 171 | -- Under some circumstances raw HTML in the AST has different spacing than the 172 | -- original source, so spaces should be ignored. Finally, symbols and 173 | -- punctuation beyond the ASCII range will often be generated through some 174 | -- sort of escape or macro in the document source. 175 | -- 176 | -- References: 177 | -- * LPeg.re docs: http://www.inf.puc-rio.br/~roberto/lpeg/re.html 178 | -- * Pandoc smart extension: https://pandoc.org/MANUAL.html#extension-smart 179 | -- * AST raw HTML spacing: https://github.com/jgm/pandoc/issues/5305 180 | local SKIPPED_CODEPOINT_RANGES = { 181 | -- Basic Latin: Control, symbols, punctuation (that is, non-alphanumeric) 182 | -- Note Lua escape of magic character `%\x5B` -> `%[` 183 | '[\x00-\x2F] / [\x3A-\x40] / [%\x5B-\x60] / [\x7B-\x7F]', 184 | -- Latin-1 Supplement: Control, symbols, punctuation 185 | '[\xC2][\x80-\xBF] / [\xC3][\x97\xB7]', 186 | -- General Punctuation through Miscellaneous Symbols and Arrows 187 | '[\xE2][\x80-\xAF][\x80-\xBF]', 188 | -- Above BMP 189 | '[\xF0-\xF4][\x90-\x8F][\x80-\xBF][\x80-\xBF]', 190 | } 191 | local SKIPPED_CODEPOINT_PATTERN = '(' .. table.concat(SKIPPED_CODEPOINT_RANGES, ' / ') .. ')+' 192 | 193 | -- Functions for skipping over initial parts of a document, such as metadata 194 | -- or a preamble 195 | local skipInitial = { 196 | latex = function (doc, text, pandocExtensions, customExtensions) 197 | local _, endIndex = text:find('^[ \t]*\\begin{document} *\n') 198 | if endIndex ~= nil and text:sub(1, endIndex):match('\\documentclass[%[{]') then 199 | return endIndex + 1 200 | end 201 | return nil 202 | end, 203 | markdown = function (doc, text, pandocExtensions, customExtensions) 204 | if not pandocExtensions['yaml_metadata_block'] then 205 | return nil 206 | end 207 | local hasMeta = false 208 | for _, _ in pairs(doc.meta) do 209 | hasMeta = true 210 | break 211 | end 212 | if not hasMeta then 213 | return nil 214 | end 215 | if text:sub(1, 3) ~= '---' then 216 | return nil 217 | end 218 | local _, endIndex = text:find('^%-%-%- *\n[%w_].-\n%-%-%- *\n') 219 | if endIndex == nil then 220 | _, endIndex = text:find('^%-%-%- *\n[%w_].-\n%.%.%. *\n') 221 | end 222 | if endIndex ~= nil then 223 | return endIndex + 1 224 | end 225 | return nil 226 | end, 227 | } 228 | for _, fmt in pairs({'markdown_mmd', 'markdown_phpextra', 'markdown_strict'}) do 229 | skipInitial[fmt] = skipInitial.markdown 230 | end 231 | 232 | 233 | sourceposlib.searchDistanceDefault = 80 234 | sourceposlib.searchDistanceIncrement = 40 235 | sourceposlib.searchDistanceMax = sourceposlib.searchDistanceDefault * 10 236 | 237 | -- addSourcepos( 238 | -- doc: , 239 | -- text: , 240 | -- format: , 241 | -- pandocExtensions: of extensions supported by Pandoc>, 242 | -- customExtensions: of custom extensions>, 243 | -- offset: , 244 | -- ) 245 | sourceposlib.addSourcepos = function (doc, text, format, pandocExtensions, customExtensions, offset) 246 | local searchDistanceDefault = sourceposlib.searchDistanceDefault 247 | local searchDistanceIncrement = sourceposlib.searchDistanceIncrement 248 | local searchDistanceMax = sourceposlib.searchDistanceMax 249 | local searchDistance = searchDistanceDefault 250 | local lineNumber = 1 251 | local textIndex = 1 252 | -- Skip over initial parts of a document, such as metadata or a preamble 253 | if skipInitial[format] ~= nil then 254 | textIndex = skipInitial[format](doc, text, pandocExtensions, customExtensions) or 1 255 | if textIndex > 1 then 256 | local stringbyte = string.byte 257 | local newlineAsByte = stringbyte('\n') 258 | for index = 1, textIndex - 1 do 259 | if stringbyte(text, index) == newlineAsByte then 260 | lineNumber = lineNumber + 1 261 | end 262 | end 263 | end 264 | end 265 | -- Actual searches are performed in a small moving "window" of text that 266 | -- is a few multiples of the max search distance in length. This prevents 267 | -- failed searches from always going to the end of the source text. It 268 | -- also reduces direct usage of the full source text. Both help 269 | -- performance for longer sources. 270 | local subtextDefaultSize = searchDistanceDefault * 10 271 | local subtextIndex = 1 272 | local subtext = text:sub(textIndex, textIndex + subtextDefaultSize - 1) 273 | 274 | local findNode = function (node) 275 | -- In checking the `subtext` size, `subtextIndex` and `searchDistance` 276 | -- overlap by 1, and `searchDistance` and `node.text` must overlap 277 | -- by at least 1. Hence the `- 2`. 278 | if subtextIndex + searchDistance + node.text:len() - 2 > subtext:len() then 279 | textIndex = textIndex + subtextIndex - 1 280 | -- Allow `subtext` to be larger than `subtextDefaultSize` 281 | -- to handle long runs of skipped code points 282 | if searchDistance + node.text:len() - 1 <= subtextDefaultSize then 283 | subtext = text:sub(textIndex, textIndex + subtextDefaultSize - 1) 284 | else 285 | subtext = text:sub(textIndex, textIndex + searchDistance + node.text:len() - 2) 286 | end 287 | subtextIndex = 1 288 | end 289 | local skippedStartIndex, skippedEndIndex = re.find(node.text, SKIPPED_CODEPOINT_PATTERN) 290 | local lastSkippedEndIndex 291 | local searchString 292 | if skippedStartIndex == nil then 293 | searchString = node.text 294 | elseif skippedStartIndex == 1 then 295 | if skippedEndIndex == node.text:len() then 296 | searchDistance = math.min(searchDistance + node.text:len() + searchDistanceIncrement, searchDistanceMax) 297 | return nil, nil 298 | end 299 | lastSkippedEndIndex = skippedEndIndex 300 | skippedStartIndex, skippedEndIndex = re.find(node.text, SKIPPED_CODEPOINT_PATTERN, lastSkippedEndIndex + 1) 301 | if skippedStartIndex == nil then 302 | searchString = node.text:sub(lastSkippedEndIndex + 1) 303 | else 304 | searchString = node.text:sub(lastSkippedEndIndex + 1, skippedStartIndex - 1) 305 | end 306 | else 307 | searchString = node.text:sub(1, skippedStartIndex - 1) 308 | end 309 | local lastSubtextIndex = subtextIndex 310 | local subtextFindStartIndex, subtextFindEndIndex = subtext:find(searchString, subtextIndex, true) 311 | local firstSubtextFindStartIndex 312 | local lastSubtextFindEndIndex 313 | if subtextFindStartIndex ~= nil and subtextFindStartIndex < subtextIndex + searchDistance then 314 | subtextIndex = subtextFindEndIndex + 1 315 | firstSubtextFindStartIndex = subtextFindStartIndex 316 | lastSubtextFindEndIndex = subtextFindEndIndex 317 | end 318 | while skippedEndIndex ~= nil and skippedEndIndex < node.text:len() do 319 | lastSkippedEndIndex = skippedEndIndex 320 | skippedStartIndex, skippedEndIndex = re.find(node.text, SKIPPED_CODEPOINT_PATTERN, lastSkippedEndIndex + 1) 321 | if skippedStartIndex == nil then 322 | searchString = node.text:sub(lastSkippedEndIndex + 1) 323 | else 324 | searchString = node.text:sub(lastSkippedEndIndex + 1, skippedStartIndex - 1) 325 | end 326 | subtextFindStartIndex, subtextFindEndIndex = subtext:find(searchString, subtextIndex, true) 327 | if subtextFindStartIndex ~= nil and subtextFindStartIndex < subtextIndex + searchDistance then 328 | subtextIndex = subtextFindEndIndex + 1 329 | if firstSubtextFindStartIndex == nil then 330 | firstSubtextFindStartIndex = subtextFindStartIndex 331 | end 332 | lastSubtextFindEndIndex = subtextFindEndIndex 333 | end 334 | end 335 | if firstSubtextFindStartIndex == nil then 336 | searchDistance = math.min(searchDistance + node.text:len() + searchDistanceIncrement, searchDistanceMax) 337 | return nil, nil 338 | end 339 | local stringbyte = string.byte 340 | local newlineAsByte = stringbyte('\n') 341 | for index = lastSubtextIndex, firstSubtextFindStartIndex - 1 do 342 | if stringbyte(subtext, index) == newlineAsByte then 343 | lineNumber = lineNumber + 1 344 | end 345 | end 346 | local startLineNumber = lineNumber 347 | for index = firstSubtextFindStartIndex, lastSubtextFindEndIndex do 348 | if stringbyte(subtext, index) == newlineAsByte then 349 | lineNumber = lineNumber + 1 350 | end 351 | end 352 | searchDistance = searchDistanceDefault 353 | return startLineNumber, lineNumber 354 | end 355 | local sourceposInline = function (node) 356 | local startLineNumber, endLineNumber = findNode(node) 357 | if startLineNumber == nil then 358 | return nil 359 | end 360 | -- Column numbers are not calculated, since scroll sync only uses line 361 | -- numbers. Column numbers would often be slightly inaccurate anyway, 362 | -- due to skipped code points. 363 | local dataPos = tostring(startLineNumber + offset) .. ':0-' .. tostring(endLineNumber + offset) .. ':0' 364 | -- Return `false` as second value to prevent walking through new node 365 | return pandoc.Span(node, pandoc.Attr("", {}, {{'data-pos', dataPos}})), false 366 | end 367 | local sourceposBlock = function (node) 368 | local startLineNumber, endLineNumber = findNode(node) 369 | if startLineNumber == nil then 370 | return nil 371 | end 372 | -- Column numbers are not calculated, since scroll sync only uses line 373 | -- numbers. Column numbers would often be slightly inaccurate anyway, 374 | -- due to skipped code points. 375 | local dataPos = tostring(startLineNumber + offset) .. ':0-' .. tostring(endLineNumber + offset) .. ':0' 376 | -- Return `false` as second value to prevent walking through new node 377 | return pandoc.Div(node, pandoc.Attr("", {}, {{'data-pos', dataPos}})), false 378 | end 379 | 380 | -- Don't search through footnotes, since many formats allow the footnote 381 | -- text to be defined in a different location than the footnote reference. 382 | -- But do increase the search distance to allow for inline footnote 383 | -- definitions. 384 | local footnoteLength 385 | local footnoteLengthCounter = function (node) 386 | footnoteLength = footnoteLength + node.text:len() 387 | return nil 388 | end 389 | local footnoteLengthFilter = { 390 | Str = footnoteLengthCounter, 391 | Code = footnoteLengthCounter, 392 | RawInline = footnoteLengthCounter, 393 | CodeBlock = footnoteLengthCounter, 394 | RawBlock = footnoteLengthCounter, 395 | } 396 | local sourceposNote = function (node) 397 | footnoteLength = 0 398 | node:walk(footnoteLengthFilter) 399 | -- Increase search distance to help jump over inline footnote 400 | -- definitions with minimal missed/incorrect searches. 401 | -- `searchDistance` is reset to default value at the next successful 402 | -- search, so this won't help with footnotes defined in a separate 403 | -- block elsewhere. Use `2*footnoteLength` to account for markup. 404 | searchDistance = math.min(searchDistance + 2*footnoteLength, searchDistanceMax) 405 | return nil, false 406 | end 407 | doc.blocks = doc.blocks:walk({ 408 | traverse = 'topdown', 409 | Str = sourceposInline, 410 | Code = sourceposInline, 411 | RawInline = sourceposInline, 412 | CodeBlock = sourceposBlock, 413 | RawBlock = sourceposBlock, 414 | Note = sourceposNote, 415 | }) 416 | 417 | -- Get `sourcepos` data for images based on the Str/Code/RawInline nodes 418 | -- they contain. Image location data is valuable for scroll sync purposes 419 | -- since images have variable dimensions. 420 | local startImageDataPos, endImageDataPos 421 | local imageFilter = { 422 | traverse = 'topdown', 423 | Span = function (node) 424 | local dataPos = node.attributes['data-pos'] 425 | if dataPos == nil then 426 | return nil 427 | end 428 | local startNodeDataPos, endNodeDataPos = dataPos:match('^(%d+:%d)-(%d+:%d)$') 429 | if startImageDataPos == nil then 430 | startImageDataPos = startNodeDataPos 431 | end 432 | endImageDataPos = endNodeDataPos 433 | return nil 434 | end 435 | } 436 | local sourceposImage = function (node) 437 | startImageDataPos = nil 438 | endImageDataPos = nil 439 | node:walk(imageFilter) 440 | if startImageDataPos == nil then 441 | return nil 442 | end 443 | node.attributes['data-pos'] = startImageDataPos .. '-' .. endImageDataPos 444 | return node 445 | end 446 | doc.blocks = doc.blocks:walk({ 447 | Image = sourceposImage, 448 | }) 449 | 450 | return doc 451 | end 452 | 453 | return sourceposlib 454 | -------------------------------------------------------------------------------- /src/pandoc_build_configs.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023-2024, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | // Error handling: `PandocBuildConfigCollections` is intended for external 10 | // use. All other classes and functions will throw errors on incorrect 11 | // settings. `PandocBuildConfigCollections` catches errors and displays an 12 | // appropriate error message. 13 | // 14 | // Type checking and data validation: All settings should be appropriately 15 | // validated (to the extent that is possible) by `package.json: pandoc.build`. 16 | // Only basic type checking is done here, plus more in-depth validation for 17 | // any data that must be processed with regex. 18 | 19 | 20 | import * as vscode from 'vscode'; 21 | 22 | import * as crypto from 'crypto'; 23 | import * as path from 'path'; 24 | import * as yaml from 'js-yaml'; 25 | 26 | import CodebraidPreviewError from './err'; 27 | import { isWindows, homedir } from './constants'; 28 | import { FileExtension } from './util'; 29 | import { PandocReader, PandocWriter, fallbackHtmlWriter } from './pandoc_util'; 30 | import { fallbackFileExtensionToReaderMap } from './pandoc_settings'; 31 | 32 | 33 | 34 | 35 | type ConfigPandocDefaults = {[key: string]: any}; 36 | type BuildSettings = { 37 | defaults: {[key: string]: any}, 38 | options: Array, 39 | }; 40 | const getFallbackBuildSettings = function () : BuildSettings { 41 | return {defaults: {}, options: []}; 42 | }; 43 | type ConfigSettings = { 44 | reader: string, 45 | preview: {[key: string]: BuildSettings}, 46 | export: {[key: string]: BuildSettings}, 47 | }; 48 | 49 | 50 | type PandocOptions = Array; 51 | // This is a copy of `package.json: codebraid.preview.pandoc.build` regex for 52 | // options, with capture groups added. 53 | const optionRegex = new RegExp("^((?!(?:-f|--from|-r|--read|-t|--to|-w|--write|-o|--output)(?:[ =]|$))(?:-[a-zA-Z]|--[a-z]+(?:-[a-z]+)*))(?:([ =])((?:(?, writer: PandocWriter) : PandocOptions { 55 | const normalizedOptions: PandocOptions = []; 56 | for (const option of options) { 57 | const optionMatch = option.match(optionRegex); 58 | if (!optionMatch) { 59 | throw new CodebraidPreviewError( 60 | `Writer "${writer}" has invalid options; check for unsupported reader/writer/output settings, and check quoting/escaping for shell` 61 | ); 62 | } 63 | if (isWindows && optionMatch[3] && (optionMatch[3].startsWith('~/') || optionMatch[3].startsWith('~\\'))) { 64 | const opt = optionMatch[1]; 65 | const sep = optionMatch[2]; 66 | const val = `"${homedir}"${optionMatch[3].slice(1)}`; 67 | normalizedOptions.push(`${opt}${sep}${val}`); 68 | } else { 69 | normalizedOptions.push(option); 70 | } 71 | } 72 | return normalizedOptions; 73 | } 74 | 75 | 76 | class PandocBuildConfig { 77 | inputFileExtension: string; 78 | reader: PandocReader; 79 | writer: PandocWriter; 80 | isPredefined: boolean | undefined; 81 | defaults: ConfigPandocDefaults; 82 | defaultsHashName: string | null; 83 | defaultsFileName: string | null; 84 | defaultsAsBytes: Buffer; 85 | defaultsFileScope: boolean | undefined; 86 | options: PandocOptions; 87 | optionsFileScope: boolean | undefined; 88 | 89 | constructor(inputFileExtension: string, reader: PandocReader, writer: PandocWriter, settings: any, isPredefined?: boolean) { 90 | this.inputFileExtension = inputFileExtension; 91 | this.reader = reader; 92 | this.writer = writer; 93 | this.isPredefined = isPredefined; 94 | let maybeDefaults = settings.defaults; 95 | if (typeof(maybeDefaults) !== 'object' || maybeDefaults === null || Array.isArray(maybeDefaults)) { 96 | throw new CodebraidPreviewError(`Writer "${writer}" has missing or invalid value for "defaults"`); 97 | } 98 | this.defaults = maybeDefaults; 99 | let maybeFileScope = this.defaults['file-scope']; 100 | if (typeof(maybeFileScope) !== 'boolean' && maybeFileScope !== undefined) { 101 | throw new CodebraidPreviewError(`Writer "${writer}" has invalid value in "defaults" for "file-scope"`); 102 | } 103 | this.defaultsFileScope = maybeFileScope; 104 | if (Object.keys(this.defaults).length === 0) { 105 | this.defaultsHashName = null; 106 | } else { 107 | const readerHash = crypto.createHash('sha256'); 108 | const hash = crypto.createHash('sha256'); 109 | readerHash.update(reader.name); 110 | hash.update(reader.name); 111 | hash.update(readerHash.digest()); 112 | hash.update(writer.name); 113 | this.defaultsHashName = hash.digest('base64url'); 114 | } 115 | this.defaultsFileName = null; 116 | this.defaultsAsBytes = Buffer.from(`# Reader: ${reader}\n# Writer: ${writer}\n${yaml.dump(this.defaults)}`, 'utf8'); 117 | let maybeOptions = settings.options; 118 | if (!Array.isArray(maybeOptions)) { 119 | throw new CodebraidPreviewError(`Writer "${writer}" has missing or invalid value for "options"`); 120 | } 121 | for (const opt of maybeOptions) { 122 | if (typeof(opt) !== 'string') { 123 | throw new CodebraidPreviewError(`Writer "${writer}" has invalid non-string value in "options"`); 124 | } 125 | } 126 | this.options = normalizeOptions(maybeOptions, this.writer); 127 | this.defaultsFileScope = this.options.indexOf('--file-scope') !== -1; 128 | } 129 | }; 130 | export class PandocPreviewBuildConfig extends PandocBuildConfig { 131 | } 132 | export class PandocExportBuildConfig extends PandocBuildConfig { 133 | } 134 | 135 | const predefinedExportBuildConfigWriters: Map = new Map([ 136 | ['HTML', 'html'], 137 | ['Jupyter Notebook', 'ipynb'], 138 | ['LaTeX', 'latex'], 139 | ['LaTeX (beamer)', 'beamer'], 140 | ['Markdown (Pandoc)', 'markdown'], 141 | ['Markdown (commonmark)', 'commonmark'], 142 | ['Markdown (commonmark_x)', 'commonmark_x'], 143 | ['Org', 'org'], 144 | ['OpenDocument (odt)', 'odt'], 145 | ['PDF', 'pdf'], 146 | ['Plain text (txt)', 'plain'], 147 | ['PowerPoint', 'pptx'], 148 | ['reStructuredText', 'rst'], 149 | ['reveal.js', 'revealjs'], 150 | ['S5', 's5'], 151 | ['Slidy', 'slidy'], 152 | ['Word', 'docx'], 153 | ]); 154 | 155 | export class PandocBuildConfigCollection { 156 | inputFileExtension: string; // `.` or `..` 157 | reader: PandocReader; 158 | preview: Map; 159 | export: Map; 160 | 161 | constructor(inputFileExtension: string, settings: any, context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration) { 162 | this.inputFileExtension = inputFileExtension; 163 | let maybeReader = settings.reader; 164 | if (typeof(maybeReader) !== 'string') { 165 | throw new CodebraidPreviewError('Missing or invalid value for "reader"'); 166 | } 167 | this.reader = new PandocReader(maybeReader, context, config); 168 | let maybePreview = settings.preview; 169 | if (typeof(maybePreview) !== 'object' || maybePreview === null || Array.isArray(maybePreview)) { 170 | throw new CodebraidPreviewError('Missing or invalid value for "preview"'); 171 | } 172 | this.preview = new Map(); 173 | for (const [key, value] of Object.entries(maybePreview)) { 174 | if (typeof(value) !== 'object' || value === null || Array.isArray(value)) { 175 | throw new CodebraidPreviewError(`Invalid value under "preview", "${key}"`); 176 | } 177 | let writer: PandocWriter; 178 | if ('writer' in value) { 179 | if (typeof(value.writer) !== 'string') { 180 | throw new CodebraidPreviewError(`Invalid value under "preview", "${key}", "writer"`); 181 | } 182 | writer = new PandocWriter(value.writer, key); 183 | } else { 184 | writer = new PandocWriter(key); 185 | } 186 | const buildConfig = new PandocPreviewBuildConfig(inputFileExtension, this.reader, writer, value); 187 | this.preview.set(key, buildConfig); 188 | } 189 | // Ensure that default preview settings are defined 190 | if (!this.preview.has('html')) { 191 | const fallbackHtmlBuildConfig = new PandocPreviewBuildConfig(inputFileExtension, this.reader, fallbackHtmlWriter, getFallbackBuildSettings(), true); 192 | this.preview.set('html', fallbackHtmlBuildConfig); 193 | } 194 | let maybeExport = settings.export; 195 | if (typeof(maybeExport) !== 'object' || maybeExport === null || Array.isArray(maybeExport)) { 196 | throw new CodebraidPreviewError('Missing or invalid value for "export"'); 197 | } 198 | this.export = new Map(); 199 | for (const [key, value] of Object.entries(maybeExport)) { 200 | if (typeof(value) !== 'object' || value === null || Array.isArray(value)) { 201 | throw new CodebraidPreviewError(`Invalid value under "export", "${key}"`); 202 | } 203 | let writer: PandocWriter; 204 | if ('writer' in value) { 205 | if (typeof(value.writer) !== 'string') { 206 | throw new CodebraidPreviewError(`Invalid value under "export", "${key}", "writer"`); 207 | } 208 | writer = new PandocWriter(value.writer, key); 209 | } else { 210 | writer = new PandocWriter(key); 211 | } 212 | const buildConfig = new PandocExportBuildConfig(inputFileExtension, this.reader, writer, value); 213 | this.export.set(key, buildConfig); 214 | } 215 | for (let [name, writerString] of predefinedExportBuildConfigWriters) { 216 | if (this.export.has(name)) { 217 | name = `${name} [predefined]`; 218 | } 219 | const writer = new PandocWriter(writerString, name); 220 | const buildConfig = new PandocPreviewBuildConfig(inputFileExtension, this.reader, writer, getFallbackBuildSettings(), true); 221 | this.export.set(name, buildConfig); 222 | } 223 | } 224 | }; 225 | 226 | 227 | export class PandocBuildConfigCollections implements vscode.Disposable { 228 | private context: vscode.ExtensionContext; 229 | private buildConfigCollections: Map; 230 | private fallbackBuildConfigCollections: Map; 231 | private isUpdating: boolean; 232 | private scheduledUpdateTimer: NodeJS.Timeout | undefined; 233 | private isDisposed: boolean; 234 | 235 | constructor(context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration) { 236 | this.context = context; 237 | this.buildConfigCollections = new Map(); 238 | this.fallbackBuildConfigCollections = new Map(); 239 | for (const [ext, reader] of fallbackFileExtensionToReaderMap) { 240 | const fallbackConfigSettings = this.getFallbackConfigSettings(reader); 241 | const configCollection = new PandocBuildConfigCollection(ext, fallbackConfigSettings, context, config); 242 | this.fallbackBuildConfigCollections.set(ext, configCollection); 243 | } 244 | 245 | this.isDisposed = false; 246 | this.isUpdating = false; 247 | } 248 | 249 | dispose() { 250 | if (this.scheduledUpdateTimer) { 251 | clearTimeout(this.scheduledUpdateTimer); 252 | } 253 | this.isDisposed = true; 254 | } 255 | 256 | getFallbackConfigSettings(reader: string) : ConfigSettings { 257 | return { 258 | reader: reader, 259 | preview: {html: getFallbackBuildSettings()}, 260 | export: {}, 261 | }; 262 | } 263 | 264 | async update(config: vscode.WorkspaceConfiguration, callback?: () => void) { 265 | if (this.isDisposed) { 266 | return; 267 | } 268 | if (this.isUpdating) { 269 | if (this.scheduledUpdateTimer) { 270 | clearTimeout(this.scheduledUpdateTimer); 271 | } 272 | this.scheduledUpdateTimer = setTimeout( 273 | () => { 274 | this.scheduledUpdateTimer = undefined; 275 | this.update(config, callback); 276 | }, 277 | 100, 278 | ); 279 | return; 280 | } 281 | 282 | this.isUpdating = true; 283 | 284 | // Remove settings for file extensions that are no longer defined. 285 | // For those that are defined, update the settings if they are valid 286 | // and otherwise continue with the last valid state. 287 | const oldKeys = new Set(this.buildConfigCollections.keys()); 288 | const errorMessages: Array = []; 289 | for (const [key, value] of Object.entries(config.pandoc.build)) { 290 | const inputFileExtension = key.slice(1); // trim `*` from `*.` 291 | oldKeys.delete(inputFileExtension); 292 | let buildConfigCollection: PandocBuildConfigCollection; 293 | try { 294 | buildConfigCollection = new PandocBuildConfigCollection(inputFileExtension, value, this.context, config); 295 | } catch (error) { 296 | if (error instanceof CodebraidPreviewError) { 297 | errorMessages.push(`Failed to process settings for ${key}: ${error.message}.`); 298 | continue; 299 | } else { 300 | throw error; 301 | } 302 | } 303 | this.buildConfigCollections.set(inputFileExtension, buildConfigCollection); 304 | } 305 | for (const oldKey of oldKeys) { 306 | this.buildConfigCollections.delete(oldKey); 307 | } 308 | if (errorMessages.length > 0) { 309 | vscode.window.showErrorMessage(`Invalid settings under "codebraid.preview.pandoc.build": ${errorMessages.join(' ')}`); 310 | } 311 | 312 | for (const [ext, configCollection] of this.buildConfigCollections) { 313 | for (const [configType, writerBuildConfigMap] of Object.entries({preview: configCollection.preview, export: configCollection.export})) { 314 | for (const [writerName, buildConfig] of writerBuildConfigMap) { 315 | if (buildConfig.defaultsHashName !== null && !this.isDisposed) { 316 | try { 317 | await this.updateDefaultsFile(buildConfig); 318 | } catch (error) { 319 | writerBuildConfigMap.delete(writerName); 320 | vscode.window.showErrorMessage([ 321 | `Failed to update "codebraid.preview.pandoc.build" settings for *${ext}, "${configType}", "${writerName}", "defaults".`, 322 | `This build configuration will be unavailable until the issue is resolved.`, 323 | `${error}`, 324 | ].join(' ')); 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | // Carry over deprecated settings if possible 332 | if (typeof(config.pandoc.fromFormat) === 'string' && config.pandoc.fromFormat !== '' && config.pandoc.fromFormat !== 'commonmark_x') { 333 | let mdBuildConfigCollection = this.buildConfigCollections.get('.md'); 334 | if (!mdBuildConfigCollection) { 335 | // `try...catch` here and later guard against `PandocReader()` 336 | // being incompatible with `config.pandoc.fromFormat` 337 | try { 338 | const fallbackConfigSettings = this.getFallbackConfigSettings(config.pandoc.fromFormat); 339 | mdBuildConfigCollection = new PandocBuildConfigCollection('.md', fallbackConfigSettings, this.context, config); 340 | this.buildConfigCollections.set('.md', mdBuildConfigCollection); 341 | } catch (error) { 342 | vscode.window.showErrorMessage( 343 | `Invalid deprecated setting "codebraid.preview.pandoc.fromFormat" is ignored: ${error}.` 344 | ); 345 | } 346 | } 347 | if (!mdBuildConfigCollection) { 348 | // Already resulted in error message due to failed fallback 349 | } else if (mdBuildConfigCollection.reader.asPandocString === 'commonmark_x') { 350 | try { 351 | const replacementReader = new PandocReader(config.pandoc.fromFormat, this.context, config); 352 | mdBuildConfigCollection.reader = replacementReader; 353 | for (const buildConfig of mdBuildConfigCollection.preview.values()) { 354 | buildConfig.reader = replacementReader; 355 | } 356 | for (const buildConfig of mdBuildConfigCollection.export.values()) { 357 | buildConfig.reader = replacementReader; 358 | } 359 | } catch (error) { 360 | vscode.window.showErrorMessage( 361 | `Invalid deprecated setting "codebraid.preview.pandoc.fromFormat" is ignored: ${error}.` 362 | ); 363 | } 364 | } else { 365 | vscode.window.showWarningMessage( 366 | 'Deprecated setting "codebraid.preview.pandoc.fromFormat" is ignored' 367 | ); 368 | } 369 | } 370 | if (Array.isArray(config.pandoc.options) && config.pandoc.options.length > 0) { 371 | let mdBuildConfigCollection = this.buildConfigCollections.get('.md'); 372 | if (!mdBuildConfigCollection) { 373 | const fallbackConfigSettings = this.getFallbackConfigSettings('commonmark_x'); 374 | mdBuildConfigCollection = new PandocBuildConfigCollection('.md', fallbackConfigSettings, this.context, config); 375 | this.buildConfigCollections.set('.md', mdBuildConfigCollection); 376 | } 377 | let useOptions: boolean = true; 378 | const buildConfigs = [...mdBuildConfigCollection.preview.values(), ...mdBuildConfigCollection.export.values()]; 379 | for (const buildConfig of buildConfigs) { 380 | if (buildConfig.options.length !== 0) { 381 | useOptions = false; 382 | break; 383 | } 384 | } 385 | if (useOptions) { 386 | for (const buildConfig of buildConfigs) { 387 | buildConfig.options.push(...config.pandoc.options); 388 | } 389 | } else { 390 | vscode.window.showWarningMessage( 391 | 'Deprecated setting "codebraid.preview.pandoc.options" is ignored' 392 | ); 393 | } 394 | } 395 | 396 | this.isUpdating = false; 397 | 398 | if (callback) { 399 | return callback(); 400 | } 401 | } 402 | 403 | private async updateDefaultsFile(buildConfig: PandocBuildConfig) { 404 | if (buildConfig.defaultsHashName === null) { 405 | return; 406 | } 407 | 408 | const defaultsFileUri = vscode.Uri.file(path.join(this.context.asAbsolutePath('pandoc/defaults'), `${buildConfig.defaultsHashName}.yaml`)); 409 | let defaultsBytes: Uint8Array | undefined; 410 | try { 411 | defaultsBytes = await vscode.workspace.fs.readFile(defaultsFileUri); 412 | } catch { 413 | } 414 | if (this.isDisposed) { 415 | return; 416 | } 417 | if (!defaultsBytes || buildConfig.defaultsAsBytes.compare(defaultsBytes) !== 0) { 418 | await Promise.resolve(vscode.workspace.fs.writeFile(defaultsFileUri, buildConfig.defaultsAsBytes)); 419 | } 420 | buildConfig.defaultsFileName = defaultsFileUri.fsPath; 421 | } 422 | 423 | inputFileExtensions() : IterableIterator { 424 | return this.buildConfigCollections.keys(); 425 | } 426 | fallbackInputFileExtensions() : IterableIterator { 427 | return this.fallbackBuildConfigCollections.keys(); 428 | } 429 | allInputFileExtensions() : IterableIterator { 430 | return new Set([...this.buildConfigCollections.keys(), ...this.fallbackBuildConfigCollections.keys()]).keys(); 431 | } 432 | 433 | getConfigCollection(inputFileExtension: FileExtension | string) : PandocBuildConfigCollection | undefined { 434 | if (typeof(inputFileExtension) === 'string') { 435 | return this.buildConfigCollections.get(inputFileExtension); 436 | } 437 | let configCollection = this.buildConfigCollections.get(inputFileExtension.fullExtension); 438 | if (!configCollection && inputFileExtension.isDoubleExtension) { 439 | configCollection = this.buildConfigCollections.get(inputFileExtension.outerExtension); 440 | } 441 | return configCollection; 442 | } 443 | hasConfigCollection(inputFileExtension: FileExtension | string) : boolean { 444 | return this.getConfigCollection(inputFileExtension) !== undefined; 445 | } 446 | getFallbackConfigCollection(inputFileExtension: FileExtension | string) : PandocBuildConfigCollection | undefined { 447 | if (typeof(inputFileExtension) === 'string') { 448 | return this.fallbackBuildConfigCollections.get(inputFileExtension); 449 | } 450 | let configCollection = this.fallbackBuildConfigCollections.get(inputFileExtension.fullExtension); 451 | if (!configCollection && inputFileExtension.isDoubleExtension) { 452 | configCollection = this.fallbackBuildConfigCollections.get(inputFileExtension.outerExtension); 453 | } 454 | return configCollection; 455 | } 456 | hasFallbackConfigCollection(inputFileExtension: FileExtension | string) : boolean { 457 | return this.getFallbackConfigCollection(inputFileExtension) !== undefined; 458 | } 459 | hasAnyConfigCollection(inputFileExtension: FileExtension | string) : boolean { 460 | return this.getConfigCollection(inputFileExtension) !== undefined || this.getFallbackConfigCollection(inputFileExtension) !== undefined; 461 | } 462 | 463 | getPreviewConfig(inputFileExtension: FileExtension | string, writer: PandocWriter | string) : PandocBuildConfig | undefined { 464 | const configCollection = this.getConfigCollection(inputFileExtension); 465 | if (typeof(writer) === 'string') { 466 | return configCollection?.preview.get(writer); 467 | } 468 | return configCollection?.preview.get(writer.name); 469 | } 470 | hasPreviewConfig(inputFileExtension: FileExtension | string, writer: PandocWriter | string) : boolean { 471 | return this.getPreviewConfig(inputFileExtension, writer) !== undefined; 472 | } 473 | 474 | getExportConfig(inputFileExtension: FileExtension | string, writer: PandocWriter | string) : PandocBuildConfig | undefined { 475 | const configCollection = this.getConfigCollection(inputFileExtension); 476 | if (typeof(writer) === 'string') { 477 | return configCollection?.export.get(writer); 478 | } 479 | return configCollection?.export.get(writer.name); 480 | } 481 | hasExportConfig(inputFileExtension: FileExtension | string, writer: PandocWriter | string) : boolean { 482 | return this.getExportConfig(inputFileExtension, writer) !== undefined; 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023, Geoffrey M. Poore 2 | // All rights reserved. 3 | // 4 | // Licensed under the BSD 3-Clause License: 5 | // http://opensource.org/licenses/BSD-3-Clause 6 | // 7 | 8 | 9 | import * as vscode from 'vscode'; 10 | 11 | import type { ExtensionState } from './types'; 12 | import { isWindows, homedir } from './constants'; 13 | import { resourceRoots } from './resources'; 14 | import { FileExtension } from './util'; 15 | import { PandocInfo, getPandocInfo } from './pandoc_info'; 16 | import { PandocBuildConfigCollections } from './pandoc_build_configs'; 17 | import { NotebookTextEditor } from './notebook'; 18 | import PreviewPanel from './preview_panel'; 19 | 20 | 21 | let context: vscode.ExtensionContext; 22 | const previews: Set = new Set(); 23 | function updatePreviewConfigurations() { 24 | for (const preview of previews) { 25 | preview.updateConfiguration(); 26 | } 27 | } 28 | let extensionState: ExtensionState; 29 | const oldExtraLocalResourceRoots: Set = new Set(); 30 | let checkPreviewVisibleInterval: NodeJS.Timeout | undefined; 31 | 32 | 33 | 34 | 35 | export async function activate(extensionContext: vscode.ExtensionContext) { 36 | context = extensionContext; 37 | 38 | // Check Pandoc version here, since async, but don't give any 39 | // errors/warnings about compatibility until the extension is actually 40 | // used. Simply loading the extension in the background won't result in 41 | // errors/warnings. 42 | const config = vscode.workspace.getConfiguration('codebraid.preview'); 43 | const pandocInfo = await getPandocInfo(config); 44 | 45 | const outputChannel = vscode.window.createOutputChannel('Codebraid Preview'); 46 | context.subscriptions.push(outputChannel); 47 | const log = (message: string) => { 48 | const date = new Date(); 49 | outputChannel.appendLine(`[${date.toLocaleString()}]`); 50 | if (message.endsWith('\n')) { 51 | outputChannel.append(message); 52 | } else { 53 | outputChannel.appendLine(message); 54 | } 55 | }; 56 | log('Activating extension'); 57 | 58 | context.subscriptions.push( 59 | vscode.commands.registerCommand( 60 | 'codebraidPreview.startPreview', 61 | startPreview 62 | ), 63 | vscode.commands.registerCommand( 64 | 'codebraidPreview.runCodebraid', 65 | runCodebraid 66 | ), 67 | vscode.commands.registerCommand( 68 | 'codebraidPreview.setScrollSyncMode', 69 | setScrollSyncMode 70 | ), 71 | vscode.commands.registerCommand( 72 | 'codebraidPreview.exportDocument', 73 | exportDocument 74 | ), 75 | ); 76 | 77 | let openPreviewStatusBarItem = vscode.window.createStatusBarItem( 78 | 'codebraidPreview.startPreview', 79 | vscode.StatusBarAlignment.Right, 80 | 14 81 | ); 82 | let runCodebraidStatusBarItem = vscode.window.createStatusBarItem( 83 | 'codebraidPreview.runCodebraid', 84 | vscode.StatusBarAlignment.Right, 85 | 13 86 | ); 87 | let scrollSyncModeStatusBarItem = vscode.window.createStatusBarItem( 88 | 'codebraidPreview.setScrollSyncMode', 89 | vscode.StatusBarAlignment.Right, 90 | 12 91 | ); 92 | let exportDocumentStatusBarItem = vscode.window.createStatusBarItem( 93 | 'codebraidPreview.exportDocument', 94 | vscode.StatusBarAlignment.Right, 95 | 11 96 | ); 97 | 98 | const pandocBuildConfigCollections = new PandocBuildConfigCollections(context, config); 99 | context.subscriptions.push(pandocBuildConfigCollections); 100 | await pandocBuildConfigCollections.update(config); 101 | const resourceRootUris = resourceRoots.map((root) => vscode.Uri.file(context.asAbsolutePath(root))); 102 | if (config.security.pandocDefaultDataDirIsResourceRoot && pandocInfo?.defaultDataDir) { 103 | resourceRootUris.push(vscode.Uri.file(pandocInfo.defaultDataDir)); 104 | } 105 | extensionState = { 106 | isWindows: isWindows, 107 | context: context, 108 | config: config, 109 | pandocInfo: pandocInfo, 110 | pandocBuildConfigCollections: pandocBuildConfigCollections, 111 | normalizedExtraLocalResourceRoots: normalizeExtraLocalResourceRoots(config), 112 | resourceRootUris: resourceRootUris, 113 | log: log, 114 | statusBarItems: { 115 | openPreview: openPreviewStatusBarItem, 116 | runCodebraid: runCodebraidStatusBarItem, 117 | scrollSyncMode: scrollSyncModeStatusBarItem, 118 | exportDocument: exportDocumentStatusBarItem, 119 | }, 120 | statusBarConfig: { 121 | scrollPreviewWithEditor: undefined, 122 | scrollEditorWithPreview: undefined, 123 | setCodebraidRunningExecute: () => {runCodebraidStatusBarItem.text = '$(sync~spin) Codebraid';}, 124 | setCodebraidRunningNoExecute: () => {runCodebraidStatusBarItem.text = '$(loading~spin) Codebraid';}, 125 | setCodebraidWaiting: () => {runCodebraidStatusBarItem.text = '$(run-all) Codebraid';}, 126 | setDocumentExportRunning: () => {exportDocumentStatusBarItem.text = '$(sync~spin) Pandoc';}, 127 | setDocumentExportWaiting: () => {exportDocumentStatusBarItem.text = '$(export) Pandoc';}, 128 | }, 129 | }; 130 | 131 | openPreviewStatusBarItem.name = 'Codebraid Preview: open preview'; 132 | openPreviewStatusBarItem.text = '$(open-preview) Codebraid Preview'; 133 | openPreviewStatusBarItem.tooltip = 'Open Codebraid Preview window'; 134 | openPreviewStatusBarItem.command = 'codebraidPreview.startPreview'; 135 | openPreviewStatusBarItem.show(); 136 | 137 | runCodebraidStatusBarItem.name = 'Codebraid Preview: run Codebraid'; 138 | runCodebraidStatusBarItem.text = '$(run-all) Codebraid'; 139 | runCodebraidStatusBarItem.tooltip = 'Run all Codebraid sessions'; 140 | runCodebraidStatusBarItem.command = 'codebraidPreview.runCodebraid'; 141 | runCodebraidStatusBarItem.hide(); 142 | 143 | scrollSyncModeStatusBarItem.name = 'Codebraid Preview: set scroll sync mode'; 144 | let scrollState: 0|1|2|3; 145 | if (config.scrollPreviewWithEditor && config.scrollEditorWithPreview) { 146 | scrollState = 0; 147 | } else if (config.scrollPreviewWithEditor) { 148 | scrollState = 1; 149 | } else if (config.scrollEditorWithPreview) { 150 | scrollState = 2; 151 | } else { 152 | scrollState = 3; 153 | } 154 | updateScrollSyncStatusBarItemText(scrollStateSymbols[scrollState]); 155 | scrollSyncModeStatusBarItem.text = `$(file) $(arrow-both) $(notebook-render-output) Scroll`; 156 | scrollSyncModeStatusBarItem.tooltip = 'Set Codebraid Preview scroll sync mode'; 157 | scrollSyncModeStatusBarItem.command = 'codebraidPreview.setScrollSyncMode'; 158 | scrollSyncModeStatusBarItem.hide(); 159 | 160 | vscode.workspace.onDidChangeConfiguration( 161 | async () => { 162 | const nextConfig = vscode.workspace.getConfiguration('codebraid.preview'); 163 | 164 | if (nextConfig.pandoc.executable !== extensionState.pandocInfo?.executable) { 165 | extensionState.pandocInfo = await getPandocInfo(nextConfig); 166 | if (!extensionState.pandocInfo) { 167 | showPandocMissingError(); 168 | } else if (!extensionState.pandocInfo.isMinVersionRecommended) { 169 | showPandocVersionMessage(); 170 | } 171 | } else if (extensionState.pandocInfo) { 172 | extensionState.pandocInfo.extraEnv = nextConfig.pandoc.extraEnv; 173 | } 174 | 175 | extensionState.config = nextConfig; 176 | const nextResourceRootUris = resourceRoots.map((root) => vscode.Uri.file(context.asAbsolutePath(root))); 177 | if (extensionState.config.security.pandocDefaultDataDirIsResourceRoot && extensionState.pandocInfo?.defaultDataDir) { 178 | nextResourceRootUris.push(vscode.Uri.file(extensionState.pandocInfo.defaultDataDir)); 179 | } 180 | if (extensionState.resourceRootUris.length !== nextResourceRootUris.length) { 181 | vscode.window.showInformationMessage([ 182 | 'Extension setting "security.pandocDefaultDataDirIsResourceRoot" has changed.', 183 | 'This will not affect preview panels until they are closed and reopened.', 184 | ].join(' ')); 185 | } 186 | extensionState.resourceRootUris = nextResourceRootUris; 187 | extensionState.normalizedExtraLocalResourceRoots = normalizeExtraLocalResourceRoots(extensionState.config); 188 | extensionState.pandocBuildConfigCollections.update(extensionState.config, updatePreviewConfigurations); 189 | }, 190 | null, 191 | context.subscriptions 192 | ); 193 | vscode.workspace.onDidChangeWorkspaceFolders( 194 | () => { 195 | if (previews.size > 0) { 196 | vscode.window.showInformationMessage([ 197 | 'Workspace folder(s) have changed.', 198 | 'This will not affect preview panels until they are closed and reopened.', 199 | ].join(' ')); 200 | } 201 | }, 202 | null, 203 | context.subscriptions 204 | ); 205 | vscode.window.onDidChangeActiveTextEditor( 206 | updateStatusBarItems, 207 | null, 208 | context.subscriptions 209 | ); 210 | vscode.window.onDidChangeVisibleTextEditors( 211 | updateStatusBarItems, 212 | null, 213 | context.subscriptions 214 | ); 215 | vscode.window.onDidChangeActiveNotebookEditor( 216 | updateStatusBarItems, 217 | null, 218 | context.subscriptions 219 | ); 220 | vscode.window.onDidChangeVisibleNotebookEditors( 221 | updateStatusBarItems, 222 | null, 223 | context.subscriptions 224 | ); 225 | // There currently isn't an event for the webview becoming visible 226 | checkPreviewVisibleInterval = setInterval( 227 | () => {updateWithPreviewStatusBarItems();}, 228 | 1000 229 | ); 230 | 231 | exportDocumentStatusBarItem.name = 'Codebraid Preview: export with Pandoc'; 232 | exportDocumentStatusBarItem.text = '$(export) Pandoc'; 233 | exportDocumentStatusBarItem.tooltip = 'Export document with Pandoc'; 234 | exportDocumentStatusBarItem.command = 'codebraidPreview.exportDocument'; 235 | exportDocumentStatusBarItem.hide(); 236 | } 237 | 238 | 239 | export function deactivate() { 240 | if (checkPreviewVisibleInterval) { 241 | clearInterval(checkPreviewVisibleInterval); 242 | } 243 | } 244 | 245 | 246 | function normalizeExtraLocalResourceRoots(config: vscode.WorkspaceConfiguration): Array { 247 | if (previews.size > 0) { 248 | let didChangeExtraRoots: boolean = false; 249 | if (oldExtraLocalResourceRoots.size !== config.security.extraLocalResourceRoots.length) { 250 | didChangeExtraRoots = true; 251 | } else { 252 | for (const root of config.security.extraLocalResourceRoots) { 253 | if (!oldExtraLocalResourceRoots.has(root)) { 254 | didChangeExtraRoots = true; 255 | break; 256 | } 257 | } 258 | } 259 | if (didChangeExtraRoots) { 260 | vscode.window.showInformationMessage([ 261 | 'Extension setting "security.extraLocalResourceRoots" has changed.', 262 | 'This will not affect preview panels until they are closed and reopened.', 263 | ].join(' ')); 264 | } 265 | } 266 | oldExtraLocalResourceRoots.clear(); 267 | const extraRoots: Array = []; 268 | for (const root of config.security.extraLocalResourceRoots) { 269 | oldExtraLocalResourceRoots.add(root); 270 | if (root.startsWith('~/') || root.startsWith('~\\')) { 271 | extraRoots.push(`${homedir}${root.slice(1)}`); 272 | } else { 273 | extraRoots.push(root); 274 | } 275 | } 276 | return extraRoots; 277 | } 278 | 279 | 280 | function showPandocMissingError(checkModifiedSystem?: boolean) { 281 | if (checkModifiedSystem) { 282 | // Update version info for future in case system has been modified 283 | getPandocInfo(extensionState.config).then((result) => { 284 | extensionState.pandocInfo = result; 285 | if (result && !result.isMinVersionRecommended) { 286 | showPandocVersionMessage(); 287 | } 288 | }); 289 | } 290 | let message: string; 291 | if (extensionState.pandocInfo === undefined) { 292 | message = [ 293 | `Could not find Pandoc executable "${extensionState.config.pandoc.executable}".`, 294 | 'Make sure that it is installed and on PATH.', 295 | 'If you have just installed Pandoc, wait a moment and try again.', 296 | 'Or manually reload the extension: restart VS Code, or CTRL+SHIFT+P and then run "Reload Window".', 297 | ].join(' '); 298 | } else { 299 | message = [ 300 | `Failed to identify Pandoc version; possibly invalid or corrupted executable "${extensionState.config.pandoc.executable}".`, 301 | 'Make sure that it is installed and on PATH.', 302 | 'If you have just installed Pandoc, wait a moment and try again.', 303 | 'Or manually reload the extension: restart VS Code, or CTRL+SHIFT+P and then run "Reload Window".', 304 | ].join(' '); 305 | } 306 | vscode.window.showErrorMessage(message); 307 | } 308 | 309 | let didShowPandocVersionMessage: boolean = false; 310 | function showPandocVersionMessage(checkModifiedSystem?: boolean) { 311 | if (checkModifiedSystem) { 312 | // Update version info for future in case system has been modified. 313 | // This assumes that default user data dir is unchanged. 314 | getPandocInfo(extensionState.config).then((result) => { 315 | const oldPandocInfo = extensionState.pandocInfo; 316 | extensionState.pandocInfo = result; 317 | if ((result && result.versionString !== oldPandocInfo?.versionString) || (!result && result !== oldPandocInfo)) { 318 | didShowPandocVersionMessage = false; 319 | if (!result) { 320 | showPandocMissingError(); 321 | } else if (!result.isMinVersionRecommended) { 322 | showPandocVersionMessage(); 323 | } 324 | } 325 | }); 326 | } 327 | if (didShowPandocVersionMessage) { 328 | return; 329 | } 330 | didShowPandocVersionMessage = true; 331 | let messageArray: Array = []; 332 | if (!extensionState.pandocInfo?.isMinVersionRecommended) { 333 | messageArray.push( 334 | `Pandoc ${extensionState.pandocInfo?.versionString} is installed, but ${extensionState.pandocInfo?.minVersionRecommendedString}+ is recommended.`, 335 | ); 336 | if (!extensionState.pandocInfo?.supportsCodebraidWrappers) { 337 | messageArray.push( 338 | `Scroll sync will only work for formats commonmark, commonmark_x, and gfm.`, 339 | `It will not work for other Markdown variants, or for other formats like Org, LaTeX, and reStructuredText.`, 340 | `The file scope option (command-line "--file-scope", or defaults "file-scope") is not supported.`, 341 | ); 342 | } 343 | vscode.window.showWarningMessage(messageArray.join(' ')); 344 | } 345 | } 346 | 347 | 348 | function startPreview() { 349 | if (!extensionState.pandocInfo) { 350 | showPandocMissingError(true); 351 | return; 352 | } 353 | if (!extensionState.pandocInfo.isMinVersionRecommended) { 354 | showPandocVersionMessage(true); 355 | } 356 | 357 | let editor: vscode.TextEditor | NotebookTextEditor | undefined = undefined; 358 | let activeOrVisibleEditors: Array = []; 359 | if (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.uri.scheme === 'file') { 360 | activeOrVisibleEditors.push(vscode.window.activeTextEditor); 361 | } else { 362 | activeOrVisibleEditors.push(...vscode.window.visibleTextEditors); 363 | // `vscode.TextEditor` gets priority, but check for notebooks when 364 | // they are supported 365 | if (extensionState.pandocBuildConfigCollections.hasAnyConfigCollection('.ipynb')) { 366 | let pushedNotebookEditor: boolean = false; 367 | if (vscode.window.activeNotebookEditor) { 368 | const notebookTextEditor = new NotebookTextEditor(vscode.window.activeNotebookEditor); 369 | if (notebookTextEditor.isIpynb) { 370 | activeOrVisibleEditors.push(notebookTextEditor); 371 | pushedNotebookEditor = true; 372 | } 373 | } 374 | if (!pushedNotebookEditor) { 375 | for (const notebookEditor of vscode.window.visibleNotebookEditors) { 376 | const notebookTextEditor = new NotebookTextEditor(notebookEditor); 377 | if (notebookTextEditor.isIpynb) { 378 | activeOrVisibleEditors.push(notebookTextEditor); 379 | } 380 | } 381 | } 382 | } 383 | } 384 | if (activeOrVisibleEditors.length === 0) { 385 | return; 386 | } 387 | for (const possibleEditor of activeOrVisibleEditors) { 388 | if (possibleEditor.document.isUntitled) { 389 | if (activeOrVisibleEditors.length === 1) { 390 | vscode.window.showErrorMessage('Cannot preview unsaved files'); 391 | return; 392 | } 393 | continue; 394 | } 395 | if (possibleEditor.document.uri.scheme !== 'file') { 396 | if (activeOrVisibleEditors.length === 1) { 397 | vscode.window.showErrorMessage([ 398 | `Unsupported URI scheme "${possibleEditor.document.uri.scheme}":`, 399 | `Codebraid Preview only supports file URIs`, 400 | ].join(' ')); 401 | return; 402 | } 403 | continue; 404 | } 405 | const fileExt = new FileExtension(possibleEditor.document.fileName); 406 | if (!extensionState.pandocBuildConfigCollections.hasAnyConfigCollection(fileExt)) { 407 | if (activeOrVisibleEditors.length === 1) { 408 | const fileExtensions = Array.from(extensionState.pandocBuildConfigCollections.allInputFileExtensions()).join(', '); 409 | vscode.window.showErrorMessage( 410 | `Preview currently only supports file extensions ${fileExtensions}. Modify "pandoc.build" in settings to add more.` 411 | ); 412 | return; 413 | } 414 | continue; 415 | } 416 | if (editor) { 417 | vscode.window.showErrorMessage( 418 | 'Multiple visible editors support preview. Select an editor, then start preview.' 419 | ); 420 | return; 421 | } 422 | editor = possibleEditor; 423 | } 424 | if (!editor) { 425 | vscode.window.showErrorMessage('No open and visible files support preview'); 426 | return; 427 | } 428 | let existingPreview: PreviewPanel | undefined; 429 | for (let p of previews) { 430 | if (p.panel && p.fileNames.indexOf(editor.document.fileName) !== -1) { 431 | existingPreview = p; 432 | break; 433 | } 434 | } 435 | if (existingPreview) { 436 | existingPreview.switchEditor(editor); 437 | } else { 438 | if (previews.size === extensionState.config.maxPreviews) { 439 | vscode.window.showErrorMessage( 440 | 'Too many previews are already open; close one or change "maxPreviews" in settings' 441 | ); 442 | return; 443 | } 444 | const fileExt = new FileExtension(editor.document.fileName); 445 | let configCollection = extensionState.pandocBuildConfigCollections.getConfigCollection(fileExt); 446 | if (!configCollection) { 447 | configCollection = extensionState.pandocBuildConfigCollections.getFallbackConfigCollection(fileExt); 448 | if (configCollection) { 449 | vscode.window.showErrorMessage( 450 | `"pandoc.build" settings for ${fileExt} are missing or invalid; default fallback preview settings will be used`, 451 | ); 452 | } else { 453 | const fileExtensions = Array.from(extensionState.pandocBuildConfigCollections.allInputFileExtensions()).join(', '); 454 | vscode.window.showErrorMessage( 455 | `Preview currently only supports file extensions ${fileExtensions}. Modify "pandoc.build" in settings to add more.` 456 | ); 457 | return; 458 | } 459 | } 460 | let preview = new PreviewPanel(editor, extensionState, fileExt); 461 | context.subscriptions.push(preview); 462 | previews.add(preview); 463 | preview.registerOnDisposeCallback( 464 | () => { 465 | previews.delete(preview); 466 | updateStatusBarItems(); 467 | } 468 | ); 469 | } 470 | extensionState.statusBarItems.openPreview.hide(); 471 | extensionState.statusBarItems.runCodebraid.show(); 472 | extensionState.statusBarItems.scrollSyncMode.show(); 473 | } 474 | 475 | 476 | function runCodebraid() { 477 | if (!extensionState.pandocInfo) { 478 | showPandocMissingError(true); 479 | return; 480 | } 481 | if (!extensionState.pandocInfo.isMinVersionRecommended) { 482 | showPandocVersionMessage(true); 483 | } 484 | 485 | if (previews.size === 0) { 486 | startPreview(); 487 | } 488 | let preview: PreviewPanel | undefined; 489 | for (let p of previews) { 490 | if (p.panel && p.panel.visible) { 491 | if (preview) { 492 | vscode.window.showErrorMessage( 493 | 'Cannot run Codebraid with two previews visible. Close one and try again.' 494 | ); 495 | } 496 | } 497 | preview = p; 498 | } 499 | preview?.runCodebraidExecute(); 500 | } 501 | 502 | 503 | function exportDocument() { 504 | if (!extensionState.pandocInfo) { 505 | showPandocMissingError(true); 506 | return; 507 | } 508 | if (!extensionState.pandocInfo.isMinVersionRecommended) { 509 | showPandocVersionMessage(true); 510 | } 511 | 512 | if (previews.size === 0) { 513 | startPreview(); 514 | } 515 | let preview: PreviewPanel | undefined; 516 | for (let p of previews) { 517 | if (p.panel && p.panel.visible) { 518 | if (preview) { 519 | vscode.window.showErrorMessage( 520 | 'Cannot export document with two previews visible. Close one and try again.' 521 | ); 522 | } 523 | } 524 | preview = p; 525 | } 526 | if (!preview) { 527 | return; 528 | } 529 | preview.export(); 530 | } 531 | 532 | 533 | let scrollState: 0|1|2|3 = 0; 534 | let scrollStateSymbols: Array = [ 535 | 'arrow-both', 536 | 'arrow-right', 537 | 'arrow-left', 538 | 'remove-close', 539 | ]; 540 | function setScrollSyncMode() { 541 | if (scrollState === 3) { 542 | scrollState = 0; 543 | } else { 544 | scrollState += 1; 545 | } 546 | updateScrollSyncStatusBarItemText(scrollStateSymbols[scrollState]); 547 | switch (scrollState) { 548 | case 0: { 549 | extensionState.statusBarConfig.scrollPreviewWithEditor = true; 550 | extensionState.statusBarConfig.scrollEditorWithPreview = true; 551 | break; 552 | } 553 | case 1: { 554 | extensionState.statusBarConfig.scrollPreviewWithEditor = true; 555 | extensionState.statusBarConfig.scrollEditorWithPreview = false; 556 | break; 557 | } 558 | case 2: { 559 | extensionState.statusBarConfig.scrollPreviewWithEditor = false; 560 | extensionState.statusBarConfig.scrollEditorWithPreview = true; 561 | break; 562 | } 563 | case 3: { 564 | extensionState.statusBarConfig.scrollPreviewWithEditor = false; 565 | extensionState.statusBarConfig.scrollEditorWithPreview = false; 566 | break; 567 | } 568 | default: 569 | throw new Error('Invalid scroll sync mode'); 570 | } 571 | } 572 | function updateScrollSyncStatusBarItemText(symbol: string) { 573 | extensionState.statusBarItems.scrollSyncMode.text = `$(file) $(${symbol}) $(notebook-render-output) Scroll`; 574 | } 575 | 576 | 577 | function updateStatusBarItems() { 578 | let showOpenPreviewStatusBarItem = true; 579 | if (vscode.window.visibleTextEditors.length === 0 && vscode.window.visibleNotebookEditors.length === 0) { 580 | showOpenPreviewStatusBarItem = false; 581 | } else { 582 | let visibleEditorsCount = 0; 583 | let previewEditorsCount = 0; 584 | const visibleEditors = [...vscode.window.visibleTextEditors, ...vscode.window.visibleNotebookEditors.map(ed => new NotebookTextEditor(ed))]; 585 | for (const visibleEditor of visibleEditors) { 586 | const document = visibleEditor.document; 587 | if (document.uri.scheme === 'file' && extensionState.pandocBuildConfigCollections.hasAnyConfigCollection(new FileExtension(document.fileName))) { 588 | visibleEditorsCount += 1; 589 | for (const preview of previews) { 590 | if (preview.panel && preview.fileNames.indexOf(document.fileName) !== -1) { 591 | previewEditorsCount += 1; 592 | break; 593 | } 594 | } 595 | } 596 | } 597 | if (visibleEditorsCount === previewEditorsCount) { 598 | showOpenPreviewStatusBarItem = false; 599 | } 600 | } 601 | if (showOpenPreviewStatusBarItem) { 602 | extensionState.statusBarItems.openPreview.show(); 603 | } else { 604 | extensionState.statusBarItems.openPreview.hide(); 605 | } 606 | updateWithPreviewStatusBarItems(); 607 | } 608 | 609 | let isShowingWithPreviewStatusBarItems = false; 610 | function updateWithPreviewStatusBarItems() { 611 | let showWithPreviewStatusBarItems = false; 612 | for (const preview of previews) { 613 | if (preview.panel && preview.panel.visible) { 614 | showWithPreviewStatusBarItems = true; 615 | break; 616 | } 617 | } 618 | if (showWithPreviewStatusBarItems) { 619 | if (!isShowingWithPreviewStatusBarItems) { 620 | extensionState.statusBarItems.runCodebraid.show(); 621 | extensionState.statusBarItems.scrollSyncMode.show(); 622 | extensionState.statusBarItems.exportDocument.show(); 623 | } 624 | isShowingWithPreviewStatusBarItems = true; 625 | } else { 626 | if (isShowingWithPreviewStatusBarItems) { 627 | extensionState.statusBarItems.runCodebraid.hide(); 628 | extensionState.statusBarItems.scrollSyncMode.hide(); 629 | extensionState.statusBarItems.exportDocument.hide(); 630 | } 631 | isShowingWithPreviewStatusBarItems = false; 632 | } 633 | } 634 | --------------------------------------------------------------------------------