├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── main.ts ├── manifest.json ├── package.json ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chris Basham 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alias from heading 2 | 3 | Aliases in [Obsidian](https://obsidian.md) make it convenient to provide display names to note links. However, there are a few pain points: 4 | 5 | - [Aliases are managed in YAML](https://help.obsidian.md/Linking+notes+and+files/Aliases), which may feel clumsy to use. 6 | - Display names of links do not stay in sync with changes to aliases. 7 | 8 | This plugin resolves these problems in the following ways: 9 | 10 | - An alias is implicitly added to a note, matching the first heading in that note, regardless of heading level. 11 | - This heading alias is used wherever YAML aliases are used, such as when [linking to a note using an alias](https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+file) or [finding unlinked mentions for an alias](https://help.obsidian.md/Linking+notes+and+files/Aliases#Find+unlinked+mentions+for+an+alias). 12 | - Updating the first heading in a note will only update links to that note with a display name matching the heading. This makes it so the link display name can be customized for a particular context, but by default, the link display name will stay in sync with the heading. 13 | - Both the [Wikilink and Markdown formats](https://help.obsidian.md/Linking+notes+and+files/Internal+links#Supported+formats+for+internal+links) are supported. 14 | - Any aliases defined in YAML continue to behave in their standard way and do not affect the behavior of this plugin. Unlike the heading alias, updating aliases in YAML will not update the display name of any of their associated links. 15 | 16 | ## Example 17 | 18 | Without this plugin, an alias would need to be explicitly defined in YAML. It is a manual process to keep the alias in sync with the first heading in the note. 19 | 20 | ```md 21 | 22 | 23 | --- 24 | aliases: "🍅 Build a garden" 25 | --- 26 | 27 | # 🍅 Build a garden 28 | 29 | - Survey the yard 30 | - Choose a design 31 | - Purchase materials 32 | - Build the frame 33 | - Prepare the ground 34 | - Fill the bed 35 | ``` 36 | 37 | With this plugin, the alias front matter is no longer needed. 38 | 39 | ```md 40 | 41 | 42 | # 🍅 Build a garden 43 | 44 | - Survey the yard 45 | - Choose a design 46 | - Purchase materials 47 | - Build the frame 48 | - Prepare the ground 49 | - Fill the bed 50 | ``` 51 | 52 | This second note links to the first note with only the file name. 53 | 54 | ```md 55 | 56 | 57 | # 🥬 Gardening projects 58 | 59 | - [[2022-06-08-1030]] 60 | - Germinate seeds 61 | - ... 62 | ``` 63 | 64 | However, it is often more readable to link to the note with a friendly display name. Type `[[`, search for the note by its heading, and select it to insert it. 65 | 66 | ```md 67 | 68 | 69 | # 🥬 Gardening projects 70 | 71 | - [[2022-06-08-1030|🍅 Build a garden]] 72 | - Germinate seeds 73 | - ... 74 | ``` 75 | 76 | Now that the display name matches the first heading of the note it links to, they stay in sync. Update the heading in the first note from `🍅 Build a garden` to `🥕 Build a raised garden bed`. Now the second note displays the change. 77 | 78 | ```md 79 | 80 | 81 | # 🥬 Gardening projects 82 | 83 | - [[2022-06-08-1030|🥕 Build a raised garden bed]] 84 | - Germinate seeds 85 | - ... 86 | ``` 87 | 88 | If all headings are removed from the first note, any links that were kept in sync are updated so that their display name matches the file name. This behavior makes it easy to later insert a new heading while keeping any link display names in sync. It also makes the preview of the link still meaningful, in the meantime. 89 | 90 | ```md 91 | 92 | 93 | # 🥬 Gardening projects 94 | 95 | - [[2022-06-08-1030|2022-06-08-1030]] 96 | - Germinate seeds 97 | - ... 98 | ``` 99 | 100 | If a custom display name is wanted or none at all, just manually change it inline. It will not be kept in sync with the heading, unless it is manually changed back to match the heading. 101 | 102 | ```md 103 | 104 | 105 | # 🥬 Gardening projects 106 | 107 | - [[2022-06-08-1030|🌽 Garden bed]] 108 | - Germinate seeds 109 | - ... 110 | ``` 111 | 112 | ## Development 113 | 114 | See: [Obsidian Developer Docs](https://docs.obsidian.md) 115 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins], 45 | format: 'cjs', 46 | // watch: !prod, 47 | target: 'es2016', 48 | logLevel: "info", 49 | sourcemap: prod ? false : 'inline', 50 | treeShaking: true, 51 | outfile: 'main.js', 52 | }).catch(() => process.exit(1)); 53 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { CachedMetadata, debounce, Notice, Plugin, ReferenceCache, TFile } from 'obsidian'; 2 | 3 | export default class AliasFromHeadingPlugin extends Plugin { 4 | removeMetadataCachePatch: () => void; 5 | 6 | onload () { 7 | const { metadataCache, vault, workspace } = this.app; 8 | const headingByPath = new Map(); 9 | 10 | function getHeading (file:TFile) { 11 | const { headings } = metadataCache.getFileCache(file); 12 | if (!Array.isArray(headings) || !headings.length) { 13 | return; 14 | } 15 | const { heading } = headings[0]; 16 | return heading; 17 | } 18 | 19 | // Once a file opens, clear out the old data after 20 | // a debounced 10 seconds. This gives plenty of time for 21 | // any links to be updated, if the user updates the heading 22 | // and quickly opens another file. 23 | const clearHeadings = debounce((path) => { 24 | if (!headingByPath.has(path)) { 25 | return; 26 | } 27 | const heading = headingByPath.get(path); 28 | headingByPath.clear(); 29 | headingByPath.set(path, heading); 30 | }, 10000, true); 31 | 32 | function loadFile (file:TFile) { 33 | if (!file) { 34 | return; 35 | } 36 | const { path } = file; 37 | const heading = getHeading(file); 38 | headingByPath.set(path, heading); 39 | clearHeadings(path); 40 | } 41 | 42 | workspace.onLayoutReady(() => { 43 | const activeFile = workspace.getActiveFile(); 44 | loadFile(activeFile); 45 | }); 46 | 47 | this.registerEvent(workspace.on('file-open', loadFile)); 48 | 49 | this.registerEvent(vault.on('rename', (file, oldPath) => { 50 | if (!(file instanceof TFile)) { 51 | return; 52 | } 53 | const { path } = file; 54 | const heading = headingByPath.get(oldPath); 55 | headingByPath.set(path, heading); 56 | })); 57 | 58 | this.registerEvent(metadataCache.on('changed', async (file) => { 59 | const { path } = file; 60 | 61 | if (!headingByPath.has(path)) { 62 | return; 63 | } 64 | 65 | const prevHeading = headingByPath.get(path); 66 | const heading = getHeading(file); 67 | headingByPath.set(path, heading); 68 | 69 | if (prevHeading === heading) { 70 | return; 71 | } 72 | 73 | const modifiedFiles = Object.entries(metadataCache.resolvedLinks) 74 | .reduce((paths, [toPath, links]) => { 75 | const hasRef = Object.keys(links).includes(path); 76 | return hasRef ? [...paths, toPath] : paths; 77 | }, []) 78 | .map((p:string) => { 79 | const { links = [] } = metadataCache.getCache(p); 80 | const linksToReplace = links 81 | .filter((rc:ReferenceCache) => { 82 | const [link] = rc.link.split('#'); 83 | return metadataCache.getFirstLinkpathDest(link, '')?.path === path; 84 | }) 85 | // Make pairs of links to be found and replaced. 86 | // Some of these pairs may be redundant or result in no matches 87 | // for any given path, but that's okay. 88 | // The `rc.original` and `rc.displayText` values are not used, 89 | // because it could be inaccurate if the heading includes brackets `[]`. 90 | // The Obsidian algorithm for detecting links is correct, 91 | // but this extra work is needed to match to the user intent. 92 | .map((rc:ReferenceCache) => { 93 | const { original } = rc; 94 | 95 | const mdLinkRE = /^\[(.*)\]\((?.*)\)$/; 96 | const mdLink = original.match(mdLinkRE)?.groups?.link; 97 | if (mdLink) { 98 | return [ 99 | `[${escapeMDLinkName(prevHeading)}](${mdLink})`, 100 | `[${escapeMDLinkName(heading)}](${mdLink})` 101 | ]; 102 | } 103 | 104 | const wikiLinkRE = /^\[\[(?.*?)\|.*\]\]$/; 105 | const wikiLink = original.match(wikiLinkRE)?.groups?.link; 106 | if (wikiLink) { 107 | return [ 108 | `[[${wikiLink}|${escapeWikiLinkName(prevHeading)}]]`, 109 | `[[${wikiLink}|${escapeWikiLinkName(heading)}]]`, 110 | ]; 111 | } 112 | }) 113 | .filter((i) => i); 114 | return [p, linksToReplace]; 115 | }) 116 | .filter(([, linksToReplace]:[string, []]) => linksToReplace.length) 117 | .map(async ([p, linksToReplace]:[string, []]) => { 118 | const f = vault.getAbstractFileByPath(p); 119 | let matches = 0; 120 | await vault.process(f, (data) => 121 | linksToReplace.reduce( 122 | (source, [find, replace]:string[]) => { 123 | // The heading must be a regular expression and not a string. 124 | // This solves two problems with the use of `String.replace()`. 125 | // 1. This allows replacement patterns (`$$, `$&`, etc.) 126 | // to be included in the heading without causing mismatches, 127 | // similar to the aforementioned `]` problem. 128 | // 2. This allows the second parameter to be a function, 129 | // so the number of matches can be counted as a side effect. 130 | const re = new RegExp(escapeRegExp(find), 'g'); 131 | return source.replace(re, () => { 132 | matches++; 133 | return replace; 134 | }); 135 | }, 136 | data 137 | ) 138 | ); 139 | return matches; 140 | }); 141 | 142 | const linkMatches = (await Promise.all(modifiedFiles)) 143 | .filter((m) => m); 144 | const fileCount = linkMatches.length; 145 | const linkCount = linkMatches 146 | .reduce((sum:number, value:number) => sum + value, 0); 147 | 148 | if (!fileCount || !linkCount) { 149 | return; 150 | } 151 | 152 | new Notice(`Updated ${linkCount} ${pluralize(linkCount, 'link')} in ${fileCount} ${pluralize(fileCount, 'file')}.`); 153 | })); 154 | 155 | // Extend the `getCache` method to include aliases 156 | // derived from headings. 157 | this.removeMetadataCachePatch = patch(metadataCache, { 158 | getCache (originalMethod: (path:string) => CachedMetadata|null) { 159 | return function (path:string) { 160 | const cache = originalMethod(path); 161 | const _cache = cache || {}; 162 | const { headings = [] } = _cache; 163 | 164 | if (!Array.isArray(headings) || !headings.length) { 165 | return cache; 166 | } 167 | 168 | const { frontmatter = {} } = _cache; 169 | const { aliases: _aliases = [] } = frontmatter; 170 | const aliases = Array.isArray(_aliases) ? _aliases : [_aliases]; 171 | const { heading } = headings[0]; 172 | 173 | if (aliases.includes(heading)) { 174 | return cache; 175 | } 176 | 177 | return { 178 | ..._cache, 179 | frontmatter: { 180 | ...frontmatter, 181 | aliases: [heading, ...aliases] 182 | } 183 | }; 184 | } 185 | } 186 | }); 187 | } 188 | 189 | onunload () { 190 | this.removeMetadataCachePatch(); 191 | } 192 | } 193 | 194 | // Escape all brackets (`[]`). 195 | function escapeMDLinkName (source:string):string { 196 | return source.replace(/[\[\]]/g, '\\$&'); 197 | } 198 | 199 | // Escape all sets of brackets (`[[` or `]]`). 200 | function escapeWikiLinkName (source:string):string { 201 | return source.replace( 202 | /(\[{2,}|\]{2,})/g, 203 | (match) => match.split('').map((m) => `\\${m}`).join('') 204 | ) 205 | } 206 | 207 | function escapeRegExp (source:string):string { 208 | return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 209 | } 210 | 211 | // Inspired by: 212 | // https://github.com/pjeby/monkey-around 213 | function patch (source:any, methods:any) { 214 | const removals = Object.entries(methods).map(([key, createMethod]) => { 215 | const hadOwn = source.hasOwnProperty(key); 216 | const method = source[key]; 217 | // @ts-ignore 218 | source[key] = createMethod(method.bind(source)); 219 | 220 | return function remove () { 221 | if (hadOwn) { 222 | source[key] = method; 223 | } else { 224 | delete source[key]; 225 | } 226 | } 227 | }) 228 | return () => removals.forEach((r) => r()) 229 | } 230 | 231 | function pluralize (count:number, singular:string, plural:string = `${singular}s`):string { 232 | return count === 1 ? singular : plural; 233 | } 234 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-alias-from-heading", 3 | "name": "Alias from heading", 4 | "version": "1.1.2", 5 | "minAppVersion": "0.12.0", 6 | "description": "Implicitly add an alias matching the first heading in a document.", 7 | "author": "Chris Basham", 8 | "authorUrl": "https://bash.am", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-alias-from-heading", 3 | "version": "1.1.2", 4 | "description": "Obsidian plugin: Implicitly add an alias matching the first heading in a document.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "lint": "eslint main.ts", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "author": "Chris Basham ", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "20.14.11", 17 | "@typescript-eslint/eslint-plugin": "7.16.1", 18 | "@typescript-eslint/parser": "7.16.1", 19 | "builtin-modules": "4.0.0", 20 | "esbuild": "0.23.0", 21 | "obsidian": "1.6.6", 22 | "tslib": "2.6.3", 23 | "typescript": "5.5.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES2022", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true 13 | }, 14 | "include": [ 15 | "**/*.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.12.0", 3 | "1.0.1": "0.12.0", 4 | "1.0.2": "0.12.0", 5 | "1.0.3": "0.12.0", 6 | "1.0.4": "0.12.0", 7 | "1.0.5": "0.12.0", 8 | "1.0.6": "0.12.0", 9 | "1.0.7": "0.12.0", 10 | "1.0.8": "0.12.0", 11 | "1.0.9": "0.12.0", 12 | "1.0.10": "0.12.0", 13 | "1.0.11": "0.12.0", 14 | "1.0.12": "0.12.0", 15 | "1.0.13": "0.12.0", 16 | "1.0.14": "0.12.0", 17 | "1.1.0": "0.12.0", 18 | "1.1.1": "0.12.0", 19 | "1.1.2": "0.12.0" 20 | } 21 | --------------------------------------------------------------------------------