├── .gitignore ├── LICENSE ├── README.md ├── obsidian_html ├── Vault.py ├── __init__.py ├── __main__.py ├── format.py └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Knut Magnus Aasrud 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 to HTML converter 2 | 3 | This is a short Python script to convert an [Obsidian](https://obsidian.md/) vault into a vault of HTML files, with the goal of publishing them as static files. It is heavily dependent on the excellent [markdown2](https://github.com/trentm/python-markdown2) by [trentm](https://github.com/trentm), but also deals with some parsing and file handling that makes it compatible with Obsidian's flavor of Markdown. 4 | 5 | ## Installation 6 | 7 | Install `obsidian-html` by running: 8 | 9 | sudo pip install git+https://github.com/kmaasrud/obsidian-html.git 10 | 11 | Or doing the same (without the `sudo`) as an administrator on Windows. 12 | 13 | > Admin privileges is needed to ensure that the script is in the PATH. You can easily clone this repo and install the package locally with `pip install .` or `python setup.py develop`, if you do not want to install as admin. 14 | 15 | ## Usage 16 | 17 | `obsidian-html` will by default convert all the Markdown documents in the folder you're running it in, and place the HTML files in a directory called `html`. You might not want to run it directly in your vault or place the converted files in another directory. This is specified by this syntax: 18 | 19 | obsidian-html -o 20 | 21 | The script will only convert the files located directly in the directory specified and never work recursively. To specify subfolders, these must be supplied to the `-d` flag, like in this example: 22 | 23 | obsidian-html -d "Daily notes" "Zettels" 24 | 25 | ### Templates 26 | 27 | The output is not very exiting from the get-go. It needs some style and structure. This is done by using a HTML template. A template must have the formatters `{title}` and `{content}` present. Their value should be obvious. The template file is supplied to `obsidian-html` by the `-t` flag, like this: 28 | 29 | obsidian-html -t template.html 30 | 31 | Here you can add metadata, link to CSS-files and add unified headers/footers to all the pages. [Here's](https://github.com/kmaasrud/brain/blob/master/template.html) an example of how I use the template function on my own hosted vault. 32 | 33 | ### TeX support via KaTeX 34 | 35 | By loading KaTeX in the HTML template and initializing it with `$` and `$$` as delimiters, you will have TeX support on the exported documents. 36 | 37 |
38 | Add this to the bottom of you template's body 39 | 40 | 41 | 43 | 44 | 45 | 48 | 49 | 50 | 53 | 54 | 55 | 67 | 68 |
69 | 70 | ### Syntax highlighting of code blocks 71 | 72 | Using [highlight.js](https://highlightjs.org/), syntax highlighting is easily achieved. 73 | 74 | 75 |
76 | Just add this to the bottom of you template's body 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
85 | 86 | ## Deploying vault with GitHub Actions 87 | 88 | Make a GitHub Actions workflow using the YAML below, and your vault will be published to GitHub Pages every time you push to the repository. 89 | 90 | 1. Make sure you have GitHub Pages set up in the vault, and that it has `gh-pages` `/root` as its source. 91 | 2. Modify the following YAML job to match your repository. 92 | 93 | ```yaml 94 | name: Deploy to GitHub Pages 95 | 96 | on: 97 | push: 98 | branches: [ master ] 99 | 100 | jobs: 101 | deploy: 102 | runs-on: ubuntu-latest 103 | 104 | steps: 105 | - uses: actions/checkout@v2 106 | 107 | - name: Set up Python 3.8 108 | uses: actions/setup-python@v2 109 | with: 110 | python-version: 3.8 111 | 112 | - name: Install obsidian-html 113 | run: | 114 | python -m pip install --upgrade pip 115 | pip install git+https://github.com/kmaasrud/obsidian-html.git 116 | 117 | - name: Generate HTML through obsidian-html 118 | run: obsidian-html ./vault -o ./out -t ./template.html -d daily 119 | 120 | - name: Deploy 121 | uses: s0/git-publish-subdir-action@develop 122 | env: 123 | REPO: self 124 | BRANCH: gh-pages 125 | FOLDER: out 126 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 127 | ``` 128 | 129 | ## To do 130 | 131 | - [ ] Support local attachments 132 | - [ ] Support the `![[]]` embedding syntax (perhaps using iframe or some similar method) 133 | - [ ] Support extra features added by the user through YAML metadata 134 | 135 | ## Known issues 136 | 137 | - Links in headers lead to weird header ids, and thus malfunctioning header links from other pages. 138 | -------------------------------------------------------------------------------- /obsidian_html/Vault.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .utils import find_files, slug_case, find_backlinks, md_link 3 | from .format import htmlify 4 | 5 | 6 | class Vault: 7 | def __init__(self, vault_root, extra_folders=[], html_template=None): 8 | self.vault_root = vault_root 9 | self.notes = find_files(vault_root, extra_folders, no_extension=True) 10 | self.extra_folders = extra_folders 11 | self._add_backlinks() 12 | 13 | self.html_template = html_template 14 | if html_template: 15 | with open(html_template) as f: 16 | self.html_template = f.read() 17 | 18 | def _add_backlinks(self): 19 | for note in self.notes: 20 | backlinks = find_backlinks(note["filename"], self.notes) 21 | if backlinks: 22 | note["content"] += "\n
\n## Backlinks\n\n" 23 | for backlink in backlinks: 24 | note["content"] += f"- {md_link(backlink['text'], backlink['link'])}\n" 25 | note["content"] += "
" 26 | 27 | def convert_to_html(self): 28 | notes_html = [] 29 | for note in self.notes: 30 | filename_html = slug_case(note["filename"]) + ".html" 31 | content_html = htmlify(note["content"]) 32 | 33 | notes_html.append( 34 | {"filename": filename_html, "content": content_html, "title": note["filename"]}) 35 | 36 | return notes_html 37 | 38 | def export_html(self, out_dir): 39 | # Default location of exported HTML is "html" 40 | if not out_dir: 41 | out_dir = os.path.join(self.vault_root, "html") 42 | # Ensure out_dir exists, as well as its sub-folders. 43 | if not os.path.exists(out_dir): 44 | os.makedirs(out_dir) 45 | for folder in self.extra_folders: 46 | if not os.path.exists(out_dir + "/" + folder): 47 | os.makedirs(out_dir + "/" + folder) 48 | 49 | notes_html = self.convert_to_html() 50 | 51 | for note in notes_html: 52 | if self.html_template: 53 | html = self.html_template.format( 54 | title=note["title"], content=note["content"]) 55 | else: 56 | html = note["content"] 57 | with open(os.path.join(out_dir, note["filename"]), "w") as f: 58 | f.write(html) 59 | -------------------------------------------------------------------------------- /obsidian_html/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | from .Vault import Vault 4 | 5 | def main(): 6 | parser = argparse.ArgumentParser( 7 | prog="obsidian-html", 8 | description="Converts an Obsidian vault into HTML") 9 | 10 | parser.add_argument("Vault", 11 | metavar="vault", 12 | type=str, 13 | default=".", 14 | help="Path to the vault root") 15 | 16 | parser.add_argument("-o", "--output_dir", 17 | default="", 18 | help="Path to place the generated HTML") 19 | 20 | parser.add_argument("-t", "--template", 21 | default=None, 22 | help="Path to HTML template") 23 | 24 | parser.add_argument("-d", "--dirs", 25 | nargs="+", 26 | default=[], 27 | help="Extra sub-directories in vault that you want included") 28 | 29 | args = parser.parse_args() 30 | 31 | vault = Vault(args.Vault, extra_folders=args.dirs, html_template=args.template) 32 | vault.export_html(args.output_dir) 33 | -------------------------------------------------------------------------------- /obsidian_html/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | from .Vault import Vault 4 | 5 | parser = argparse.ArgumentParser( 6 | prog="obsidian-html", 7 | description="Converts an Obsidian vault into HTML") 8 | 9 | parser.add_argument("Vault", 10 | metavar="vault", 11 | type=str, 12 | default=".", 13 | help="Path to the vault root") 14 | 15 | parser.add_argument("-o", "--output_dir", 16 | default=sys.argv[1] + "/html", 17 | help="Path to place the generated HTML") 18 | 19 | parser.add_argument("-t", "--template", 20 | default=None, 21 | help="Path to HTML template") 22 | 23 | parser.add_argument("-d", "--dirs", 24 | nargs="+", 25 | default=[], 26 | help="Extra sub-directories in vault that you want included") 27 | 28 | args = parser.parse_args() 29 | 30 | vault = Vault(args.Vault, extra_folders=args.dirs, html_template=args.template) 31 | vault.export_html(args.output_dir) 32 | -------------------------------------------------------------------------------- /obsidian_html/format.py: -------------------------------------------------------------------------------- 1 | import regex as re 2 | import markdown2 3 | from .utils import slug_case, md_link 4 | 5 | 6 | def format_internal_links(document): 7 | """Formats Obsidian style links that are neither aliased, nor links to headers""" 8 | matches = re.finditer("\\[{2}([^|#]*?)\\]{2}", document) 9 | 10 | return obsidian_to_commonmark_links(document, matches, no_groups=1) 11 | 12 | 13 | def format_internal_aliased_links(document): 14 | """Formats Obsidian style aliased links""" 15 | matches = re.finditer("\\[{2}([^|#\\]]*?)\\|(.*?)\\]{2}", document) 16 | 17 | return obsidian_to_commonmark_links(document, matches) 18 | 19 | 20 | def format_internal_header_links(document): 21 | """Formats Obsidian style header links""" 22 | matches = re.finditer("\\[{2}([^|#\\]]*?)#(.*?)\\]{2}", document) 23 | 24 | for match in matches: 25 | text = match.group(2) 26 | link = slug_case(match.group(1)) + "#" + slug_case(match.group(2)) 27 | document = document.replace(match.group(), md_link(text, link)) 28 | 29 | return document 30 | 31 | 32 | def format_tags(document): 33 | """Obsidian style tags. Removes #-icon and adds a span tag.""" 34 | matches = re.finditer(r"\s#([\p{L}_]+)", document) 35 | 36 | for match in matches: 37 | document = document.replace( 38 | match.group(), "" + match.group(1) + "") 39 | 40 | return document 41 | 42 | 43 | def obsidian_to_commonmark_links(document, matches, no_groups=2): 44 | for match in matches: 45 | text = match.group(no_groups) 46 | link = slug_case(match.group(1)) 47 | document = document.replace(match.group(), md_link(text, link)) 48 | 49 | return document 50 | 51 | 52 | def htmlify(document): 53 | # Formatting of Obsidian tags and links. 54 | document = format_tags( 55 | format_internal_header_links( 56 | format_internal_aliased_links( 57 | format_internal_links( 58 | document)))) 59 | 60 | # Escaped curly braces lose their escapes when formatted. I'm suspecting 61 | # this is from markdown2, as I haven't found anyplace which could 62 | # do this among my own formatter functions. Therefore I double escape them. 63 | document = document.replace(r"\{", r"\\{").replace(r"\}", r"\\}") 64 | 65 | markdown2_extras = [ 66 | # Parser should work withouth strict linebreaks. 67 | "break-on-newline", 68 | # Support of ```-codeblocks and syntax highlighting. 69 | "fenced-code-blocks", 70 | # Make slug IDs for each header. Needed for internal header links. 71 | "header-ids", 72 | # Support for strikethrough formatting. 73 | "strike", 74 | # GFM tables. 75 | "tables", 76 | # Support for lists that start without a newline directly above. 77 | "cuddled-lists", 78 | # Have to support Markdown inside html tags 79 | "markdown-in-html", 80 | # Disable formatting via the _ character. Necessary for code an TeX 81 | "code-friendly", 82 | # Support for Obsidian's footnote syntax 83 | "footnotes" 84 | ] 85 | 86 | html = markdown2.markdown(document, extras=markdown2_extras) 87 | 88 | # Wrapping converted markdown in a div for styling 89 | html = f"
{html}
" 90 | 91 | return html 92 | -------------------------------------------------------------------------------- /obsidian_html/utils.py: -------------------------------------------------------------------------------- 1 | import regex as re 2 | import os 3 | 4 | 5 | def slug_case(text): 6 | text = text.replace(".", "dot") 7 | text = text.replace("_", "-") 8 | return re.sub(r'[^\w\-/]+', '-', text).lower() 9 | 10 | 11 | def md_link(text, link): 12 | return "[" + text + "](" + link + ")" 13 | 14 | 15 | def find_files(vault_root, extra_folders, no_extension=False): 16 | # Find all markdown-files in vault root. 17 | md_files = find_md_files(vault_root, no_extension) 18 | 19 | # Find all markdown-files in each extra folder. 20 | for folder in extra_folders: 21 | md_files += find_md_files(os.path.join(vault_root, folder), 22 | no_extension, is_extra_folder=True) 23 | 24 | return md_files 25 | 26 | 27 | def find_md_files(root, no_extension, is_extra_folder=False): 28 | md_files = [] 29 | for md_file in os.listdir(root): 30 | if not md_file.endswith(".md"): 31 | continue 32 | 33 | with open(os.path.join(root, md_file)) as f: 34 | content = f.read() 35 | 36 | if no_extension: 37 | md_file = md_file.replace(".md", "") 38 | 39 | if is_extra_folder: 40 | md_file = os.path.join(os.path.split(root)[-1], md_file) 41 | 42 | md_files.append({"filename": md_file, "content": content}) 43 | 44 | return md_files 45 | 46 | 47 | def extract_links_from_file(document): 48 | matches = re.finditer(r"\[{2}([^\]]*?)[|#\]]([^\]]*?)\]+", document) 49 | 50 | links = [] 51 | for match in matches: 52 | link = match.group(1) 53 | links.append(link) 54 | 55 | return links 56 | 57 | 58 | def find_backlinks(target_note_name, all_notes): 59 | backlinks = [] 60 | for note in all_notes: 61 | links = extract_links_from_file(note["content"]) 62 | if target_note_name in links: 63 | backlinks.append({"text": note["filename"].replace(".md", ""), 64 | "link": slug_case(note["filename"].replace(".md", ""))}) 65 | 66 | backlinks = sorted(backlinks, key=lambda x: x['text']) 67 | 68 | return backlinks 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='obsidian-html', 5 | version='0.1', 6 | description='Converts an Obsidian vault into HTML', 7 | url='https://github.com/kmaasrud/obsidian-hugo', 8 | author='kmaasrud', 9 | author_email='kmaasrud@outlook.com', 10 | license='MIT', 11 | packages=['obsidian_html'], 12 | install_requires=[ 13 | 'markdown2', 14 | 'regex' 15 | ], 16 | zip_safe=False, 17 | entry_points={ 18 | 'console_scripts': [ 19 | 'obsidian-html=obsidian_html:main' 20 | ] 21 | } 22 | ) 23 | --------------------------------------------------------------------------------