├── .npmrc ├── .eslintignore ├── versions.json ├── class-dark.png ├── class-light.png ├── style-dark.png ├── style-light.png ├── .editorconfig ├── manifest.json ├── .github └── workflows │ ├── tests.yml │ └── release.yml ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── esbuild.config.mjs ├── not-a-real-css-parser.ts ├── LICENSE ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.15.0", 3 | "0.0.2": "0.15.0" 4 | } -------------------------------------------------------------------------------- /class-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixth/obsidian-stylist/HEAD/class-dark.png -------------------------------------------------------------------------------- /class-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixth/obsidian-stylist/HEAD/class-light.png -------------------------------------------------------------------------------- /style-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixth/obsidian-stylist/HEAD/style-dark.png -------------------------------------------------------------------------------- /style-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ixth/obsidian-stylist/HEAD/style-light.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-stylist", 3 | "name": "Obsidian Stylist", 4 | "version": "0.0.4", 5 | "minAppVersion": "0.15.0", 6 | "description": "Obsidian plugin that allows to add classes and styles on markdown blocks", 7 | "author": "Mikhail Menshikov ", 8 | "authorUrl": "https://ixth.net/", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | pull_request: 7 | 8 | jobs: 9 | run-eslint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version-file: package.json 17 | - run: npm ci 18 | - run: npm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "dom", 16 | "dom.iterable", 17 | "es5", 18 | "es6", 19 | "es7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const readJSON = (filename) => 4 | JSON.parse(readFileSync(filename, 'utf8')); 5 | 6 | const writeJSON = (filename, obj) => { 7 | writeFileSync(filename, JSON.stringify(obj, null, '\t')); 8 | }; 9 | 10 | const targetVersion = process.env.npm_package_version; 11 | 12 | const manifest = readJSON('manifest.json'); 13 | writeJSON('manifest.json', { ...manifest, version: targetVersion }); 14 | 15 | const versions = readJSON('versions.json'); 16 | writeJSON('versions.json', { ...versions, [targetVersion]: manifest.minAppVersion }); 17 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-stylist", 3 | "engines": { 4 | "node": ">=12.0.0 " 5 | }, 6 | "version": "0.0.2", 7 | "description": "Obsidian plugin that allows to add classes and styles on markdown blocks", 8 | "main": "main.js", 9 | "scripts": { 10 | "dev": "node esbuild.config.mjs", 11 | "test": "eslint main.ts", 12 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 13 | "version": "node version-bump.mjs && git add manifest.json versions.json" 14 | }, 15 | "keywords": [ 16 | "css", 17 | "obsidian", 18 | "style" 19 | ], 20 | "author": "Mikhail Menshikov ", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@types/node": "^16.11.6", 24 | "@typescript-eslint/eslint-plugin": "5.29.0", 25 | "@typescript-eslint/parser": "5.29.0", 26 | "builtin-modules": "3.3.0", 27 | "esbuild": "0.14.47", 28 | "obsidian": "latest", 29 | "tslib": "2.4.0", 30 | "typescript": "4.7.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins], 35 | format: 'cjs', 36 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /not-a-real-css-parser.ts: -------------------------------------------------------------------------------- 1 | // not a real CSS parser, but works in most cases 2 | 3 | interface Ruleset { 4 | selector: string; 5 | declarations: string; 6 | } 7 | 8 | const PSEUDO_ELEMENT_REGEX = /(?=::(?:before|after|first-letter))/; 9 | 10 | export const parseRulesets = (source: string): Ruleset[] => 11 | source.trim().split('}') 12 | .filter((ruleset) => ruleset.contains('{')) 13 | .map((ruleset) => { 14 | const [selector, declarations] = ruleset.split('{'); 15 | const trimmedSelector = selector.trim().replace(/\n/g, ' '); 16 | return { 17 | selector: trimmedSelector, 18 | declarations 19 | }; 20 | }); 21 | 22 | export const parseSelector = (selector: string) => 23 | selector.split(',').map((selector) => selector.trim()); 24 | 25 | export const hasPseudoElements = (selector: string): boolean => 26 | PSEUDO_ELEMENT_REGEX.test(selector); 27 | 28 | export const splitPseudoElements = (selector: string): string[] => 29 | selector.split(PSEUDO_ELEMENT_REGEX); 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-stylist 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version-file: package.json 20 | 21 | - name: Build 22 | id: build 23 | run: | 24 | npm ci 25 | npm run build 26 | 27 | - name: Pack 28 | id: pack 29 | run: | 30 | mkdir ${{ env.PLUGIN_NAME }} 31 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 32 | zip -r "${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip" ${{ env.PLUGIN_NAME }} 33 | 34 | - name: Create Release 35 | id: create_release 36 | uses: softprops/action-gh-release@v1 37 | with: 38 | files: | 39 | ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip 40 | LICENSE 41 | main.js 42 | manifest.json 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mikhail Menshikov 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 | # Obsidian Stylist 2 | 3 | *Stylist* is a plugin for Obsidian that allows to add classes and styles on markdown. 4 | 5 | 6 | > **Note** 7 | > This plugin doesn't work in Live Preview mode. 8 | 9 | 10 | ## Examples 11 | 12 | For example, if you want to add underline for headings on one particular page, you can do it like this: 13 | 14 | ````markdown 15 | ```style 16 | h2 { 17 | text-decoration: underline; 18 | } 19 | ``` 20 | 21 | ## Does wood sink in water? 22 | ```` 23 | 24 | Result: 25 | 26 | ![Style tag render](./style-dark.png#gh-dark-mode-only) 27 | ![Style tag render](./style-light.png#gh-light-mode-only) 28 | 29 | > **Note** 30 | > All styles are processed to prevent messing up the rest of the Obsidian app. 31 | 32 | Or if you want certain blocks on your page to use multi-column layout, you can just add class on containing block: 33 | 34 | ````markdown 35 | ```style 36 | .multicol-3 ul { 37 | columns: 3; 38 | } 39 | ``` 40 | 41 | ## What also floats in water? 42 | 43 | %% Class will be added not to the list element itself, 44 | but to section container, so you should write styles respectively %% 45 | 46 | `classname:multicol-3` 47 | 48 | * Bread 49 | * Apples 50 | * Very small rocks 51 | * Cider 52 | * Grape gravy 53 | * Cherries 54 | * Mum 55 | * Churches 56 | * Lead 57 | * A duck 58 | ```` 59 | 60 | Result: 61 | 62 | ![Class render](./class-dark.png#gh-dark-mode-only) 63 | ![Class render](./class-light.png#gh-light-mode-only) 64 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { hasPseudoElements, parseRulesets, parseSelector, splitPseudoElements } from 'not-a-real-css-parser'; 2 | import { Plugin } from 'obsidian'; 3 | 4 | const CLASSNAME_TAG = 'classname:'; 5 | 6 | const makeScopedStyles = (rootSelector: string, source: string): string => 7 | parseRulesets(source).map(({ selector, declarations }) => { 8 | if (hasPseudoElements(selector)) { 9 | return parseSelector(selector).map((selector) => { 10 | const [selectorHead, pseudoElement] = splitPseudoElements(selector); 11 | return `${rootSelector} :is(${selectorHead})${pseudoElement ?? ''} {${declarations}}`; 12 | }) 13 | .join(','); 14 | } 15 | return `${rootSelector} :is(${selector}) {${declarations}}`; 16 | }) 17 | .join('\n'); 18 | 19 | export default class extends Plugin { 20 | private lastClassName: string | undefined; 21 | 22 | onload() { 23 | this.registerMarkdownCodeBlockProcessor('style', (source, element) => { 24 | element.createEl('style', { 25 | text: makeScopedStyles('.markdown-preview-view', source) 26 | }); 27 | }); 28 | 29 | this.registerMarkdownPostProcessor((element) => { 30 | if (element.hasClass('next-class-skip')) { 31 | return; 32 | } 33 | 34 | if (this.lastClassName !== undefined) { 35 | element.classList.add(...this.lastClassName.split(/\s+/)); 36 | this.lastClassName = undefined; 37 | } 38 | 39 | const codeElements = element.querySelectorAll('code'); 40 | 41 | const classElement = [...codeElements].find( 42 | (element) => element.innerText.trim().startsWith(CLASSNAME_TAG) 43 | ); 44 | 45 | if (classElement) { 46 | this.lastClassName = classElement.innerText.slice(CLASSNAME_TAG.length); 47 | if (classElement.parentElement?.childElementCount === 1) { 48 | classElement.parentElement.remove(); 49 | } 50 | element.classList.add('next-class-skip'); 51 | } 52 | }); 53 | } 54 | } 55 | --------------------------------------------------------------------------------