├── README.md ├── compile-steps ├── remove tasks.js ├── sample script.js ├── linting.js ├── reveal-in-finder.js ├── remove all yaml but first.js ├── re-index footnotes.js ├── image-wikilinks-to-md-links.js ├── insert-filename-as-md-heading.js └── single-scene-pandoc.js └── LICENSE /README.md: -------------------------------------------------------------------------------- 1 | # Longform compile steps 2 | Community Collection of custom compile steps for the [Longform Plugin for Obsidian](https://github.com/kevboh/longform). 3 | 4 | Submissions are welcome. Please add the author name as a comment at the first 5 | line. 6 | -------------------------------------------------------------------------------- /compile-steps/remove tasks.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | /* global module */ 5 | module.exports = { 6 | description: { 7 | name: "Remove Tasks", 8 | description: "open and completed tasks", 9 | availableKinds: ["Manuscript"], 10 | options: [] 11 | }, 12 | 13 | /** @param {{ contents: string }} input */ 14 | compile (input) { 15 | input.contents = input.contents.replace (/\s*- \[[ x]] .*(\n|$)/gm, ""); 16 | return input; 17 | } 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /compile-steps/sample script.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | // DOCS https://github.com/kevboh/longform/blob/main/docs/COMPILE.md#user-script-steps 4 | //────────────────────────────────────────────────────────────────────────────── 5 | 6 | module.exports = { 7 | description: { 8 | name: "Sample Script", 9 | description: "Replaces every 'a' with an explosion emoji.", 10 | availableKinds: ["Manuscript"], 11 | options: [], 12 | }, 13 | 14 | /** @param {{ contents: string }} input */ 15 | compile(input) { 16 | if (!input.contents) return input; 17 | input.contents = input.contents.replaceAll("a", "💥"); 18 | 19 | return input; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /compile-steps/linting.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | module.exports = { 5 | description: { 6 | name: "Linting", 7 | description: 8 | "Performs simple linting, e.g. removing multiple blank lines or multiple spaces, and enforcing blank lines before headings.", 9 | availableKinds: ["Manuscript"], 10 | options: [], 11 | }, 12 | 13 | /** @param {{ contents: string }} input */ 14 | compile(input) { 15 | input.contents = input.contents 16 | .replace(/\n{3,}/g, "\n\n") // multiple blank lines 17 | .replace(/(?!^) {2,}(?!$)/g, " ") // multiple spaces (not at beginning or end) 18 | .replace(/\n+^(?=#+ )/gm, "\n\n"); // ensure blank line above heading 19 | return input; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /compile-steps/reveal-in-finder.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | description: { 3 | name: "Reveal in Finder", 4 | description: "Show the Manuscript in the Windows Explorer / Finder.app", 5 | availableKinds: ["Manuscript"], 6 | options: [], 7 | }, 8 | 9 | compile(_, context) { 10 | const app = context.app; 11 | const projectFolder = app.vault.getFolderByPath(context.projectPath); 12 | const projectFiles = projectFolder.children.filter((file) => file.extension === "md"); 13 | 14 | // HACK longform does not expose the path of the manuscript in its API, so 15 | // we look for the most recently modified file instead 16 | // https://github.com/kevboh/longform/blob/main/docs/COMPILE.md#user-script-steps 17 | const lastModifiedFile = projectFiles.sort((a, b) => b.stat.mtime - a.stat.mtime)[0]; 18 | app.showInFolder(lastModifiedFile.path); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /compile-steps/remove all yaml but first.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | module.exports = { 5 | description: { 6 | name: "Remove all YAML but first", 7 | description: "Removes all YAML frontmatter except for the one from the first scene.", 8 | availableKinds: ["Scene"], 9 | options: [], 10 | }, 11 | 12 | /** @typedef {Object} sceneObj defined at https://github.com/kevboh/longform/blob/main/docs/COMPILE.md#user-script-steps 13 | * @property {string} contents - text contents of scene 14 | * @param {sceneObj[]} scenes 15 | */ 16 | compile(scenes) { 17 | let isFirstFile = true; 18 | const frontmatterRegex = /^\n*---\n.*?\n---\n/s; 19 | 20 | return scenes.map((scene) => { 21 | if (isFirstFile) { 22 | isFirstFile = false; 23 | return scene; 24 | } 25 | scene.contents = scene.contents.replace(frontmatterRegex, ""); 26 | return scene; 27 | }); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Obsidian Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compile-steps/re-index footnotes.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | // NOTE Re-indexing should run after all steps that remove content, e.g. tasks 4 | // or comments, so that potential leftover footnote keys there do not influence 5 | // the re-numbering 6 | //────────────────────────────────────────────────────────────────────────────── 7 | 8 | module.exports = { 9 | description: { 10 | name: "Re-Index Footnotes", 11 | description: "Re-Index Footnote Numbering", 12 | availableKinds: ["Manuscript"], 13 | options: [] 14 | }, 15 | 16 | /** @param {{ contents: string }} input */ 17 | compile (input) { 18 | let text = input.contents; 19 | 20 | // re-index footnote-definitions 21 | let ft_index = 0; 22 | text = text.replace(/^\[\^\S+?]: /gm, function() { 23 | ft_index++; 24 | return ('[^' + String(ft_index) + ']: '); 25 | }); 26 | 27 | // re-index footnote-keys 28 | // regex uses hack to treat lookahead as lookaround https://stackoverflow.com/a/43232659 29 | ft_index = 0; 30 | text = text.replace(/(?!^)\[\^\S+?]/gm, function() { 31 | ft_index++; 32 | return ('[^' + String(ft_index) + ']'); 33 | }); 34 | 35 | input.contents = text; 36 | return input; 37 | } 38 | 39 | }; 40 | -------------------------------------------------------------------------------- /compile-steps/image-wikilinks-to-md-links.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | module.exports = { 5 | description: { 6 | name: "Convert Image Wikilinks to MD Links", 7 | description: "For third-party applications like Pandoc. Step must come before any 'Remove Wikilinks' step.", 8 | availableKinds: ["Manuscript"], 9 | options: [], 10 | }, 11 | 12 | /** 13 | * @param {{ contents: string; }} input 14 | * @param {{ app: { metadataCache: { getFirstLinkpathDest: (arg0: any, arg1: any) => { (): any; new (): any; path: any; }; }; }; projectPath: any; }} context 15 | */ 16 | compile(input, context) { 17 | const imageWikiLinkRegex = /!\[\[(.*?\.(?:png|jpe?g|tiff))(?:\|(.+))?]]/g; // https://regex101.com/r/8Qzbod/1 18 | 19 | input.contents = input.contents.replace(imageWikiLinkRegex, function (_fullmatch, capture1, capture2) { 20 | const innerWikilink = capture1 || ""; 21 | const aliasToInsert = capture2 || ""; 22 | const imageTFile = context.app.metadataCache.getFirstLinkpathDest(innerWikilink, context.projectPath); 23 | if (!imageTFile) return `__⚠️ IMAGE NOT FOUND__ ![${aliasToInsert}](${innerWikilink})`; 24 | return `![${aliasToInsert}](${imageTFile.path})`; 25 | }); 26 | return input; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /compile-steps/insert-filename-as-md-heading.js: -------------------------------------------------------------------------------- 1 | // author: @pseudometa 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | module.exports = { 5 | description: { 6 | name: "Insert Filename as Heading", 7 | description: 8 | "For unindented scenes, the inserted headings are h2. For indented scenes, the inserted heading level is increased by the indentation level.", 9 | availableKinds: ["Scene"], 10 | options: [ 11 | { 12 | id: "ignore-first-file", 13 | name: "Ignore First File", 14 | description: "Useful for example to ignore a metadata file", 15 | type: "Boolean", 16 | default: true, 17 | }, 18 | ], 19 | }, 20 | 21 | /** @typedef {Object} sceneObj defined at https://github.com/kevboh/longform/blob/main/docs/COMPILE.md#user-script-steps 22 | * @property {number=} indentationLevel - The indent level (starting at zero) of the scene 23 | * @property {object} metadata - Obsidian metadata of scene 24 | * @property {string} contents - text contents of scene 25 | * @property {string} path - path to scene 26 | * @property {string} name - file name of scene 27 | * @param {sceneObj[]} scenes 28 | * @param {{ optionValues: { [option: string]: any; }; }} context 29 | */ 30 | compile(scenes, context) { 31 | const ignoreFirstFile = context.optionValues["ignore-first-file"]; 32 | 33 | let isFirstFile = true; 34 | const frontmatterRegex = /^\n*---\n.*?\n---\n/s; 35 | 36 | return scenes.map((scene) => { 37 | if (isFirstFile && ignoreFirstFile) { 38 | isFirstFile = false; 39 | return scene; 40 | } 41 | // determine heading 42 | if (!scene.indentationLevel) scene.indentationLevel = 0; 43 | const headingLevel = scene.indentationLevel + 2; 44 | const headingLine = `${"#".repeat(headingLevel)} ${scene.name}\n`; 45 | 46 | // insert heading 47 | const frontMatterArr = scene.contents.match(frontmatterRegex); 48 | const yamlFrontMatter = frontMatterArr ? frontMatterArr[0] : ""; 49 | const contentWithoutYaml = scene.contents.replace(frontmatterRegex, ""); 50 | scene.contents = yamlFrontMatter + "\n" + headingLine + "\n" + contentWithoutYaml; 51 | 52 | return scene; 53 | }); 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /compile-steps/single-scene-pandoc.js: -------------------------------------------------------------------------------- 1 | const execSync = require('child_process').execSync; 2 | const spawnSync = require('child_process').spawnSync; 3 | 4 | async function compile(input, context) { 5 | // This is undocumented and could break. 6 | let basePath = context.app.vault.adapter.basePath; 7 | if (!basePath.endsWith("/")) { 8 | basePath = basePath + "/"; 9 | } 10 | 11 | const pandocPath = context.optionValues["pandoc-path"].trim(); 12 | const pdfEnginePath = context.optionValues["pdfengine-path"].trim(); 13 | 14 | let outputPathFolder = basePath + context.optionValues["output-path"].trim(); 15 | if (!outputPathFolder.endsWith("/")) { 16 | outputPathFolder = outputPathFolder + "/"; 17 | } 18 | const metadataPath = basePath + context.optionValues["metadata-path"].trim(); 19 | const openAfter = context.optionValues["open-after"]; 20 | 21 | return input.map((sceneInput) => { 22 | const title = sceneInput.metadata.frontmatter.longform.title; 23 | const outputFile = (() => { 24 | const s = sceneInput.path.split("/"); 25 | return s[s.length - 1].split(".")[0]; 26 | })(); 27 | const outputPath = `${outputPathFolder}${outputFile}.pdf`; 28 | const newInput = `# ${title}\n\n` + sceneInput.contents.replace(/\n/g, "\n\n"); 29 | const result = spawnSync(pandocPath, ['--from=markdown', `-o ${outputPath}`, '--pdf-engine=' + pdfEnginePath, '--metadata-file=' + metadataPath], {input: newInput, encoding: "utf-8", shell: true}); 30 | if (result.status !== 0) { 31 | console.log(result.stdout); 32 | console.log(result.stderr); 33 | } 34 | 35 | if (openAfter) { 36 | execSync(`open ${outputPath}`); 37 | } 38 | 39 | return { 40 | ...sceneInput, 41 | contents: newInput, 42 | }; 43 | }); 44 | } 45 | 46 | module.exports = { 47 | description: { 48 | name: "Format Scene as PDF", 49 | description: "Writes out every scene as a PDF via Pandoc. Primarily for single-scene projects.", 50 | availableKinds: ["Scene"], 51 | options: [ 52 | { 53 | id: "pandoc-path", 54 | name: "Pandoc Path", 55 | description: "Absolute path to your pandoc binary.", 56 | type: "Text", 57 | default: "/opt/homebrew/bin/pandoc" 58 | }, 59 | { 60 | id: "pdfengine-path", 61 | name: "PDF Engine Path", 62 | description: "Absolute path to the PDF engine for pandoc.", 63 | type: "Text", 64 | default: "/Library/TeX/texbin/pdflatex" 65 | }, 66 | { 67 | id: "output-path", 68 | name: "Folder to place PDFs in.", 69 | description: "Generated PDFs will be placed here. Relative to vault.", 70 | type: "Text", 71 | default: "compiled/" 72 | }, 73 | { 74 | id: "metadata-path", 75 | name: "LaTeX Metadata", 76 | description: "Path to YAML file of LaTeX metadata. Relative to vault.", 77 | type: "Text", 78 | default: "latex-metadata.yaml" 79 | }, 80 | { 81 | id: "open-after", 82 | name: "Open After?", 83 | description: "If true, open the PDF after creating it.", 84 | type: "Boolean", 85 | default: true 86 | } 87 | ] 88 | }, 89 | compile: compile 90 | }; 91 | --------------------------------------------------------------------------------