├── pelican └── plugins │ ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── external_link.md │ │ ├── internal_link.md │ │ ├── internal_image.md │ │ ├── unknown_internal_link.md │ │ ├── colon_in_prop.md │ │ ├── tags_comma.md │ │ ├── assets │ │ │ └── images │ │ │ │ └── pelican-in-rock.webp │ │ ├── other_list_type.md │ │ └── tags.md │ └── test_obsidian_plugin.py │ └── obsidian │ ├── __init__.py │ └── obsidian.py ├── .gitignore ├── setup.py ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── pyproject.toml ├── LICENSE ├── setup.cfg └── README.md /pelican/plugins/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.egg-info/* 2 | __pycache__ 3 | dist/** 4 | build 5 | -------------------------------------------------------------------------------- /pelican/plugins/obsidian/__init__.py: -------------------------------------------------------------------------------- 1 | from .obsidian import * # noqa 2 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/external_link.md: -------------------------------------------------------------------------------- 1 | [external](https://example.com) 2 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/internal_link.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: internal link 3 | --- 4 | 5 | [[tags]] 6 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/internal_image.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: internal image 3 | --- 4 | 5 | ![[pelican-in-rock.webp]] 6 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/unknown_internal_link.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: unknown internal link 3 | --- 4 | 5 | [[great-article-not-exist]] 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | from setuptools import setup 3 | setup( 4 | use_scm_version=True, 5 | setup_requires=['setuptools_scm'], 6 | ) 7 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/colon_in_prop.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: misc 3 | title: "Hello: There" 4 | Status: published 5 | empty: 6 | --- 7 | 8 | Some text here. 9 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/tags_comma.md: -------------------------------------------------------------------------------- 1 | --- 2 | Status: published 3 | tags: python,code-formatter,black 4 | Summary: Testing tags. 5 | --- 6 | 7 | Some text here. 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jonathan-s] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/assets/images/pelican-in-rock.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathan-s/pelican-obsidian/HEAD/pelican/plugins/tests/fixtures/assets/images/pelican-in-rock.webp -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/other_list_type.md: -------------------------------------------------------------------------------- 1 | --- 2 | Status: published 3 | other: 4 | - python 5 | - code-formatter 6 | - black 7 | Summary: Testing tags. 8 | --- 9 | 10 | Some text here. 11 | -------------------------------------------------------------------------------- /pelican/plugins/tests/fixtures/tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: tags 3 | Status: published 4 | tags: 5 | - python 6 | - code-formatter 7 | - black 8 | Summary: Testing tags. 9 | --- 10 | 11 | Some text here. 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.x' # you can specify a specific version like '3.11' 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pytest 23 | pip install . 24 | 25 | - name: Run tests 26 | run: | 27 | pytest pelican/plugins/tests 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=45", 4 | "wheel", 5 | "setuptools_scm[toml]>=6.0", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.black] 10 | line-length = 88 11 | target_version = ['py36'] 12 | include = '\.pyi?$' 13 | exclude = ''' 14 | ( 15 | /( 16 | \.eggs # exclude a few common directories in the 17 | | \.git # root of the project 18 | | \.hg 19 | | \.mypy_cache 20 | | \.tox 21 | | \.venv 22 | | _build 23 | | buck-out 24 | | build 25 | | dist 26 | )/ 27 | | foo.py # also separately exclude a file named foo.py in 28 | # the root of the project 29 | ) 30 | ''' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021 Jonathan Sundqvist 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pelican-obsidian 3 | description = Makes pelican markdown files more compatible with Obsidian 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown; charset=UTF-8 6 | url = https://github.com/jonathan-s/pelican-obsidian 7 | author = Jonathan Sundqvist 8 | author_email = jonathan@argpar.se, 9 | license = MIT 10 | license_file = LICENSE 11 | classifiers = 12 | Topic :: Software Development 13 | keywords = 14 | pelican 15 | obsidian 16 | plugin 17 | project_urls = 18 | Documentation = https://github.com/jonathan-s/pelican-obsidian 19 | Source = https://github.com/jonathan-s/pelican-obsidian 20 | Tracker = https://github.com/jonathan-s/pelican-obsidian/issues 21 | Funding = https://github.com/sponsors/jonathan-s 22 | 23 | [options] 24 | zip_safe = True 25 | packages = find_namespace: 26 | platforms = any 27 | include_package_data = True 28 | install_requires = 29 | pelican 30 | pelican-yaml-metadata 31 | pyyaml 32 | python_requires = >=3.7 33 | setup_requires = 34 | setuptools_scm 35 | 36 | [bdist_wheel] 37 | universal = 1 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Obsidian: A Plugin for Pelican 2 | ============================ 3 | 4 | 7 | 8 | Obsidian is a pelican plugin that allows you to use the syntax used within Obsidian and when pelican then renders these posts it won't look weird or out of place. 9 | 10 | Phrased differently, if you don't like that `#` is included in the name of the tag when you name it `#my-tag` and you think that internal pelican links are difficult to remember and would like to use `[[ my link ]]` as an internal link instead this plugin would be for you. 11 | 12 | If the article doesn't exist it will return text only. That way, there is a possibility of clearly separating posts that should belong on the blog and linked as such vs posts that should only belong inside Obsidian. 13 | 14 | 15 | Installation 16 | ------------ 17 | 18 | This plugin can be installed via: 19 | 20 | # not yet on pypi, but when it is you can install it with. 21 | pip install pelican-obsidian 22 | 23 | # meanwhile you can install using this repo. 24 | pip install git+git://github.com/jonathan-s/pelican-obsidian@main#egg=pelican-obsidian 25 | 26 | 27 | Add `'obsidian'` to the `PLUGINS` list in your Pelican config: 28 | 29 | ``` 30 | PLUGINS = [ 31 | 'obsidian', 32 | ] 33 | ``` 34 | 35 | Usage 36 | ----- 37 | 38 | In the tags section you will be able to use `#` without that being reflected in the actual name of the tag. In other words. 39 | 40 | ``` 41 | Tags: #my-tag 42 | 43 | # reflects as 44 | my-tag in the html output. 45 | ``` 46 | 47 | Links follow this format: 48 | 49 | ``` 50 | [[note name]] 51 | [[note name | custom link text]] 52 | ``` 53 | 54 | Files are similar: 55 | 56 | ``` 57 | ![[photo.jpg]] 58 | ![[photo.jpg | custom alt text]] 59 | ``` 60 | 61 | They explain more about the syntax in the section on [how to embed files](https://help.obsidian.md/How+to/Embed+files) 62 | 63 | 64 | Future features 65 | --------------- 66 | - Embed files or sections as described [here](https://help.obsidian.md/How+to/Format+your+notes) 67 | - Task list? 68 | - Support .rst? 69 | - don't generate links for drafts 70 | 71 | 72 | Implemented Features 73 | ----------------- 74 | - Apply the same linking for pages. 75 | 76 | 77 | 86 | 87 | License 88 | ------- 89 | 90 | This project is licensed under the MIT license. 91 | -------------------------------------------------------------------------------- /pelican/plugins/tests/test_obsidian_plugin.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pelican.generators import ArticlesGenerator 5 | from pelican.tests.support import get_settings 6 | from pelican.plugins.obsidian import ObsidianMarkdownReader, populate_files_and_articles # noqa 7 | 8 | 9 | @pytest.fixture 10 | def obsidian(path): 11 | settings = get_settings() 12 | settings["DEFAULT_CATEGORY"] = "Default" 13 | settings["DEFAULT_DATE"] = (1970, 1, 1) 14 | settings["READERS"] = {"asc": None} 15 | settings["CACHE_CONTENT"] = False 16 | omr = ObsidianMarkdownReader( 17 | settings=settings 18 | ) 19 | cwd = Path.cwd() 20 | source_path = cwd / "pelican" / "plugins" / "tests" / f"fixtures/{path}.md" 21 | generator = ArticlesGenerator( 22 | context=settings, 23 | settings=settings, 24 | path=cwd / "pelican" / "plugins" / "tests" / "fixtures", 25 | theme=settings["THEME"], 26 | output_path=None, 27 | ) 28 | populate_files_and_articles(generator) 29 | obsidian = omr.read(source_path) 30 | return obsidian 31 | 32 | 33 | @pytest.mark.parametrize('path', ["tags"]) 34 | def test_tags_works_correctly(obsidian): 35 | """Tags formatted as yaml list works properly""" 36 | content, meta = obsidian 37 | tags = meta["tags"] 38 | 39 | assert 'Some text here' in content 40 | assert len(tags) == 3 41 | assert 'python' == tags[0].name 42 | assert 'code-formatter' == tags[1].name 43 | assert 'black' == tags[2].name 44 | 45 | 46 | @pytest.mark.parametrize('path', ["tags_comma"]) 47 | def test_tags_works_pelican_way(obsidian): 48 | """Test normal tags""" 49 | content, meta = obsidian 50 | tags = meta["tags"] 51 | 52 | assert 'Some text here' in content 53 | assert len(meta["tags"]) == 3 54 | assert 'python' == tags[0].name 55 | assert 'code-formatter' == tags[1].name 56 | assert 'black' == tags[2].name 57 | 58 | 59 | @pytest.mark.parametrize('path', ["other_list_type"]) 60 | def test_other_list_property(obsidian): 61 | """List is preserved for other list type property""" 62 | content, meta = obsidian 63 | 64 | assert 'Some text here' in content 65 | assert len(meta["other"]) == 3 66 | 67 | 68 | @pytest.mark.parametrize('path', ["unknown_internal_link"]) 69 | def test_internal_link_not_seen_in_article(obsidian): 70 | """ 71 | If linked article has not been processed earlier 72 | content is not linked. 73 | """ 74 | content, meta = obsidian 75 | assert '

great-article-not-exist

' == content 76 | 77 | @pytest.mark.parametrize('path', ["internal_link"]) 78 | def test_internal_link_in_article(obsidian): 79 | """ 80 | If linked article has internal link, it should be linked 81 | """ 82 | content, meta = obsidian 83 | assert '

tags

' == content 84 | 85 | @pytest.mark.parametrize('path', ["internal_image"]) 86 | def test_internal_image_in_article(obsidian): 87 | """ 88 | If linked article has internal image, it should be linked 89 | """ 90 | content, meta = obsidian 91 | assert '

pelican-in-rock.webp

' == content 92 | 93 | 94 | @pytest.mark.parametrize('path', ["internal_link"]) 95 | def test_external_link(obsidian): 96 | """Able to use normal markdown links which renders properly""" 97 | content, meta = obsidian 98 | assert 'external' 99 | 100 | 101 | @pytest.mark.parametrize('path', ["colon_in_prop"]) 102 | def test_colon_in_prop(obsidian): 103 | """Using a colon in prop should not mess with string formatting""" 104 | content, meta = obsidian 105 | assert meta["title"] == 'Hello: There' 106 | 107 | 108 | def test_with_generator(): 109 | pass 110 | -------------------------------------------------------------------------------- /pelican/plugins/obsidian/obsidian.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | import logging 6 | from itertools import chain 7 | from pathlib import Path 8 | 9 | from pelican import signals 10 | from pelican.utils import pelican_open 11 | from pelican.plugins.yaml_metadata.yaml_metadata import YAMLMetadataReader, HEADER_RE 12 | from pelican.urlwrappers import Tag 13 | 14 | __log__ = logging.getLogger(__name__) 15 | 16 | ARTICLE_PATHS = {} 17 | FILE_PATHS = {} 18 | 19 | link = r'\[\[\s*(?P[^|\]]+)(\|\s*(?P.+))?\]\]' 20 | file_re = re.compile(r'!' + link) 21 | link_re = re.compile(link) 22 | 23 | 24 | """ 25 | # Test cases 26 | [[my link]] 27 | [[ my work ]] 28 | [[ my work | is finished ]] 29 | 30 | ![[ a file.jpg ]] 31 | ![[file.jpg]] 32 | """ 33 | 34 | 35 | def get_file_and_linkname(match): 36 | """ 37 | Get the filename and linkname from the match object 38 | """ 39 | group = match.groupdict() 40 | filename = group['filename'].strip() 41 | linkname = group['linkname'] if group['linkname'] else filename 42 | linkname = linkname.strip() 43 | return filename, linkname 44 | 45 | 46 | class ObsidianMarkdownReader(YAMLMetadataReader): 47 | """ 48 | Change the format of various links to the accepted case of pelican. 49 | """ 50 | enabled = YAMLMetadataReader.enabled 51 | 52 | def __init__(self, *args, **kwargs): 53 | super().__init__(*args, **kwargs) 54 | 55 | def replace_obsidian_links(self, text): 56 | """ 57 | Filters all text and replaces matching links with the correct format for pelican. NOTE - this parses all text. 58 | Args: 59 | text: Text for entire page 60 | Return: text with links replaced if possible 61 | """ 62 | def link_replacement(match): 63 | filename, linkname = get_file_and_linkname(match) 64 | path = ARTICLE_PATHS.get(filename) 65 | if path: 66 | link_structure = '[{linkname}]({{filename}}{path}{filename}.md)'.format( 67 | linkname=linkname, path=path, filename=filename 68 | ) 69 | else: 70 | link_structure = '{linkname}'.format(linkname=linkname) 71 | return link_structure 72 | 73 | def file_replacement(match): 74 | filename, linkname = get_file_and_linkname(match) 75 | path = FILE_PATHS.get(filename) 76 | if path: 77 | link_structure = '![{linkname}]({{static}}{path}{filename})'.format( 78 | linkname=linkname, path=path, filename=filename 79 | ) 80 | else: 81 | # don't show it at all since it will be broken 82 | link_structure = '' 83 | 84 | return link_structure 85 | 86 | text = file_re.sub(file_replacement, text) 87 | text = link_re.sub(link_replacement, text) 88 | return text 89 | 90 | def _load_yaml_metadata(self, text, source_path): 91 | metadata = super()._load_yaml_metadata(text, source_path) 92 | tags = metadata.get("tags", []) 93 | mod_tags = [] 94 | for tag in tags: 95 | if ',' in tag.name: 96 | str_tags = tag.name.split(",") 97 | for str_tag in str_tags: 98 | url_tag = Tag(str_tag, settings=self.settings) 99 | mod_tags.append(url_tag) 100 | else: 101 | mod_tags.append(tag) 102 | 103 | metadata["tags"] = mod_tags 104 | return metadata 105 | 106 | def read(self, source_path): 107 | """ 108 | Parse content and metadata of markdown files. 109 | Also changes the links to the acceptable format for pelican 110 | """ 111 | with pelican_open(source_path) as text: 112 | m = HEADER_RE.fullmatch(text) 113 | 114 | if not m: 115 | return super().read(source_path) 116 | 117 | text = m.group("content") 118 | content = self.replace_obsidian_links(text) 119 | 120 | return ( 121 | self._md.reset().convert(content), 122 | self._load_yaml_metadata(m.group("metadata"), source_path), 123 | ) 124 | 125 | 126 | def populate_files_and_articles(article_generator): 127 | """ 128 | Populates the ARTICLE_PATHS and FILE_PATHS global variables. This is used to find file paths and article paths after 129 | parsing the wililink articles. 130 | ARTICLE_PATHS is a dictionary where the key is the filename and the value is the path to the article. 131 | FILE_PATHS is a dictionary where the key is the file extension and the value is the path to the file 132 | 133 | Args: 134 | article_generator: built in class. 135 | Returns: None - sets the ARTICLE_PATHS and FILE_PATHS global variables. 136 | """ 137 | global ARTICLE_PATHS 138 | global FILE_PATHS 139 | 140 | base_path = Path(article_generator.path) 141 | articles = base_path.glob('**/*.md') 142 | # Get list of all markdown files 143 | for article in articles: 144 | full_path, filename_w_ext = os.path.split(article) 145 | filename, ext = os.path.splitext(filename_w_ext) 146 | path = str(full_path).replace(str(base_path), '') + '/' 147 | # For windows 148 | if os.sep == '\\': 149 | path = path.replace('\\', '/') 150 | 151 | # This work on both pages and posts/articles 152 | ARTICLE_PATHS[filename] = path 153 | 154 | __log__.debug('Found %d articles', len(ARTICLE_PATHS)) 155 | 156 | # Get list of all other relevant files 157 | globs = [base_path.glob('**/*.{}'.format(ext)) for ext in ['png', 'jpg', 'jpeg', 'svg', 'apkg', 'gif', 'webp', 'avif']] 158 | files = chain(*globs) 159 | for _file in files: 160 | full_path, filename_w_ext = os.path.split(_file) 161 | path = str(full_path).replace(str(base_path), '') + '/' 162 | # For windows 163 | if os.sep == '\\': 164 | path = path.replace('\\', '/') 165 | FILE_PATHS[filename_w_ext] = path 166 | 167 | __log__.debug('Found %d files', len(FILE_PATHS)) 168 | 169 | 170 | def modify_generator(generator): 171 | """ 172 | Modify the generator to use the ObsidianMarkdownReader 173 | """ 174 | populate_files_and_articles(generator) 175 | generator.readers.readers['md'] = ObsidianMarkdownReader(generator.settings) 176 | 177 | 178 | def modify_metadata(article_generator, metadata): 179 | """ 180 | Modify the tags so we can define the tags as we are used to in obsidian. 181 | """ 182 | for tag in metadata.get('tags', []): 183 | if '#' in tag.name: 184 | tag.name = tag.name.replace('#', '') 185 | 186 | 187 | def register(): 188 | """ 189 | register with pelican. 190 | """ 191 | signals.article_generator_context.connect(modify_metadata) 192 | signals.article_generator_init.connect(modify_generator) 193 | 194 | signals.page_generator_context.connect(modify_metadata) 195 | signals.page_generator_init.connect(modify_generator) 196 | --------------------------------------------------------------------------------