Compare commits
	
		
			16 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7205bb2aa5 | |||
| 88b678931e | |||
| 8c75947088 | |||
| e75d5c48d2 | |||
| 201cd80804 | |||
| a327c6b89c | |||
| 27d4d16572 | |||
| b69bdb424a | |||
| c46a1c0bae | |||
| 359916e7d9 | |||
| d49b9d48a8 | |||
| 9caf08a277 | |||
| 8aabd93273 | |||
| 6d7987cfae | |||
| abc05ee4e8 | |||
| f623ffdd7c | 
							
								
								
									
										24
									
								
								.reuse/dep5
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								.reuse/dep5
									
									
									
									
									
								
							| @ -1,24 +0,0 @@ | |||||||
| Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ |  | ||||||
| Upstream-Name: incorporeal-cms |  | ||||||
| Upstream-Contact: Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| Source: https://git.incorporeal.org/bss/incorporeal-cms |  | ||||||
| 
 |  | ||||||
| # Trivial files |  | ||||||
| Files: .gitignore .gitattributes |  | ||||||
| Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| License: GPL-3.0-only |  | ||||||
| 
 |  | ||||||
| # High level repo docs |  | ||||||
| Files: *.md |  | ||||||
| Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| License: GPL-3.0-only |  | ||||||
| 
 |  | ||||||
| # Test data |  | ||||||
| Files: tests/instance/* |  | ||||||
| Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| License: GPL-3.0-only |  | ||||||
| 
 |  | ||||||
| # Python packaging, scaffolding, and errata |  | ||||||
| Files: MANIFEST.in pyproject.toml tox.ini requirements/* |  | ||||||
| Copyright: © 2020 Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| License: GPL-3.0-only |  | ||||||
							
								
								
									
										40
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										40
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -2,6 +2,46 @@ | |||||||
| 
 | 
 | ||||||
| Included is a summary of changes to the project, by version. Details can be found in the commit history. | Included is a summary of changes to the project, by version. Details can be found in the commit history. | ||||||
| 
 | 
 | ||||||
|  | ## v2.0.4 | ||||||
|  | 
 | ||||||
|  | ### Bugfixes | ||||||
|  | 
 | ||||||
|  | * With some significant refactoring, files are now handled better with respect to relative paths, which fixes an issue | ||||||
|  |   with symlink pages only properly getting resolved to their target if the symlink was in the `pages/` root rather than | ||||||
|  |   a subdir. | ||||||
|  | 
 | ||||||
|  | ## v2.0.3 | ||||||
|  | 
 | ||||||
|  | ### Bugfixes | ||||||
|  | 
 | ||||||
|  | * Symlinks for a `.md` file that are to be served by the web server also need a `.html` symlink pointed to the generated | ||||||
|  |   file, since the web server is looking for HTML files when serving paths. | ||||||
|  | 
 | ||||||
|  | ### Miscellaneous | ||||||
|  | 
 | ||||||
|  | * The project now comes with the GPLv3 "or any later version" clause. | ||||||
|  | 
 | ||||||
|  | ## v2.0.2 | ||||||
|  | 
 | ||||||
|  | ### Bugfixes | ||||||
|  | 
 | ||||||
|  | * Paths for files in the `pages/` root no longer have an extra `./` in them, which made URLs look ugly and also added an | ||||||
|  |   extra blank breadcrumb in the breadcrumbs. | ||||||
|  | 
 | ||||||
|  | ### Improvements | ||||||
|  | 
 | ||||||
|  | * `custom-static` in the instance dir is now ignored and has no special handling --- put static files in `pages/static/` | ||||||
|  |   like all the other files that get copied. This also fixes a bug where the build errored if the directory didn't exist. | ||||||
|  | * Some README typos fixed. | ||||||
|  | 
 | ||||||
|  | ## v2.0.1 | ||||||
|  | 
 | ||||||
|  | ### Improvements | ||||||
|  | 
 | ||||||
|  | * The `Image` tag in Markdown files no longer requires the full URL to be specified. Now `Config.BASE_HOST` is | ||||||
|  |   prepended to the tag value, which should be the full path to the image. | ||||||
|  | * `.files` are skipped when copying files to the SSG output directory. | ||||||
|  | 
 | ||||||
| ## v2.0.0 | ## v2.0.0 | ||||||
| 
 | 
 | ||||||
| ### Features | ### Features | ||||||
|  | |||||||
							
								
								
									
										28
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								README.md
									
									
									
									
									
								
							| @ -4,8 +4,7 @@ A lightweight static site generator for Markdown-based sites. | |||||||
| 
 | 
 | ||||||
| ## Installation and Usage | ## Installation and Usage | ||||||
| 
 | 
 | ||||||
| I recommend getting a release from <https://git.incorporeal.org/bss/incorporeal-cms/releases> and | Something like the following should suffice: | ||||||
| installing the Python package in a virtualenv. Something like the following should suffice: |  | ||||||
| 
 | 
 | ||||||
| ``` | ``` | ||||||
| % virtualenv --python=python3.9 env-py3.9 | % virtualenv --python=python3.9 env-py3.9 | ||||||
| @ -15,19 +14,19 @@ installing the Python package in a virtualenv. Something like the following shou | |||||||
| % incorporealcms-build ./path/to/instance ./path/to/output/www/root | % incorporealcms-build ./path/to/instance ./path/to/output/www/root | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| This will generate the directory suitable for serving by e.g. Nginx. | This will generate the directory suitable for serving by e.g. nginx. | ||||||
| 
 | 
 | ||||||
| ## Creating a Site | ## Creating a Site | ||||||
| 
 | 
 | ||||||
| Put content, notably Markdown content, inside `./your-instance/pages/` and when you are ready, run the build command | Put content, notably Markdown content, inside `./your-instance/pages/` and when you are ready, run the build command | ||||||
| above. When you run `incorporealcms-build`, the following happens: | above. When you run `incorporealcms-build`, the following happens: | ||||||
| 
 | 
 | ||||||
| * Markdown files (ending in `.md`) are rendered via Python-Markdown as .html files and output to the static site | * Markdown files (ending in `.md`) are rendered via Python-Markdown as `.html` files and output to the static site | ||||||
|   directory. The `.md` files are also copied there, though this behavior may be toggleable in the future. |   directory. The `.md` files are also copied there, though this behavior may be toggleable in the future. | ||||||
|     * Directory paths (e.g. a request to `/dir/`) can be served via a `/dir/index.md` file, which will generate |     * Directory paths (e.g. a request to `/dir/`) can be served via a `/dir/index.md` file, which will generate | ||||||
|       `/dir/index.html`, with the appropriate web server configuration to use `index.html` for directory listings. |       `/dir/index.html`, with the appropriate web server configuration to use `index.html` for directory listings. | ||||||
| * Symlinks to files are retained and mirrored into the output directory, and handled per the web server's configuration, | * Symlinks to files are retained and mirrored into the output directory, and handled per the web server's configuration, | ||||||
|   whanever it is. |   whatever it is. | ||||||
| * All other files are copied directly, so images, text files, etc., can be referenced naturally as URLs. | * All other files are copied directly, so images, text files, etc., can be referenced naturally as URLs. | ||||||
| 
 | 
 | ||||||
| ## Configuration | ## Configuration | ||||||
| @ -53,19 +52,18 @@ out and discuss issues and features and whatnot. | |||||||
| 
 | 
 | ||||||
| ## Author and Licensing | ## Author and Licensing | ||||||
| 
 | 
 | ||||||
| Written by and copyright (C) 2025 Brian S. Stephan <bss@incorporeal.org>. | Written by and copyright (C) 2025 Brian S. Stephan (bss@incorporeal.org). | ||||||
| 
 | 
 | ||||||
| This program is free software: you can redistribute it and/or modify | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General | ||||||
| it under the terms of the GNU General Public License as published by | Public License as published by the Free Software Foundation, either version 3 of the License, or (at your | ||||||
| the Free Software Foundation, version 3 of the License. | option) any later version. | ||||||
| 
 | 
 | ||||||
| This program is distributed in the hope that it will be useful, | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the | ||||||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for | ||||||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | more details. | ||||||
| GNU General Public License for more details. |  | ||||||
| 
 | 
 | ||||||
| You should have received a copy of the GNU General Public License | You should have received a copy of the GNU General Public License along with this program.  If not, see | ||||||
| along with this program.  If not, see <https://www.gnu.org/licenses/>. | <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| ### Content Output | ### Content Output | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										28
									
								
								REUSE.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								REUSE.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | version = 1 | ||||||
|  | SPDX-PackageName = "incorporeal-cms" | ||||||
|  | SPDX-PackageSupplier = "Brian S. Stephan <bss@incorporeal.org>" | ||||||
|  | SPDX-PackageDownloadLocation = "https://git.incorporeal.org/bss/incorporeal-cms" | ||||||
|  | 
 | ||||||
|  | [[annotations]] | ||||||
|  | path = [".gitignore", ".gitattributes"] | ||||||
|  | precedence = "aggregate" | ||||||
|  | SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>" | ||||||
|  | SPDX-License-Identifier = "GPL-3.0-or-later" | ||||||
|  | 
 | ||||||
|  | [[annotations]] | ||||||
|  | path = "**.md" | ||||||
|  | precedence = "aggregate" | ||||||
|  | SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>" | ||||||
|  | SPDX-License-Identifier = "GPL-3.0-or-later" | ||||||
|  | 
 | ||||||
|  | [[annotations]] | ||||||
|  | path = "tests/instance/**" | ||||||
|  | precedence = "aggregate" | ||||||
|  | SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>" | ||||||
|  | SPDX-License-Identifier = "GPL-3.0-or-later" | ||||||
|  | 
 | ||||||
|  | [[annotations]] | ||||||
|  | path = ["MANIFEST.in", "pyproject.toml", "tox.ini", "requirements/**"] | ||||||
|  | precedence = "aggregate" | ||||||
|  | SPDX-FileCopyrightText = "© 2020 Brian S. Stephan <bss@incorporeal.org>" | ||||||
|  | SPDX-License-Identifier = "GPL-3.0-or-later" | ||||||
| @ -1,7 +1,7 @@ | |||||||
| """An application for running my Markdown-based sites. | """An application for running my Markdown-based sites. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import json | import json | ||||||
| import logging | import logging | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Default configuration. | """Default configuration. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Process the error page templates. | """Process the error page templates. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ under pages/ (which may make sense for a blog) if they want, but could just | |||||||
| as well be pages/foo content. | as well be pages/foo content. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| @ -24,6 +24,9 @@ logger = logging.getLogger(__name__) | |||||||
| def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: | def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: | ||||||
|     """Generate the Atom or RSS feed as requested. |     """Generate the Atom or RSS feed as requested. | ||||||
| 
 | 
 | ||||||
|  |     Feed entries should be symlinks to .md files in the pages/ directory, so that they | ||||||
|  |     are also linkable and can be browsed outside of the feed. | ||||||
|  | 
 | ||||||
|     Args: |     Args: | ||||||
|         feed_type: 'atom' or 'rss' feed |         feed_type: 'atom' or 'rss' feed | ||||||
|         instance_dir: the directory for the instance, containing both the feed dir and pages |         instance_dir: the directory for the instance, containing both the feed dir and pages | ||||||
| @ -37,16 +40,18 @@ def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: | |||||||
|     fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate') |     fg.link(href=f'https://{Config.DOMAIN_NAME}', rel='alternate') | ||||||
|     fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}") |     fg.subtitle(f"Blog posts and other interesting materials from {Config.TITLE_SUFFIX}") | ||||||
| 
 | 
 | ||||||
|  |     # feed symlinks should all be within the core content subdirectory | ||||||
|  |     pages_dir = os.path.join(instance_dir, 'pages') | ||||||
|  | 
 | ||||||
|     # get recent feeds |     # get recent feeds | ||||||
|     feed_path = os.path.join(instance_dir, 'feed') |     feed_path = os.path.join(instance_dir, 'feed') | ||||||
|     feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path) |     feed_entry_paths = [os.path.join(dirpath, filename) for dirpath, _, filenames in os.walk(feed_path) | ||||||
|                         for filename in filenames if os.path.islink(os.path.join(dirpath, filename))] |                         for filename in filenames if os.path.islink(os.path.join(dirpath, filename))] | ||||||
|     for feed_entry_path in sorted(feed_entry_paths): |     for feed_entry_path in sorted(feed_entry_paths): | ||||||
|         # get the actual file to parse it |         # get the actual file to parse it | ||||||
|         os.chdir(os.path.abspath(os.path.join(instance_dir, 'pages'))) |         resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir) | ||||||
|         resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), os.path.join(instance_dir, 'pages')) |  | ||||||
|         try: |         try: | ||||||
|             content, md, page_name, page_title, mtime = parse_md(resolved_path) |             content, md, page_name, page_title, mtime = parse_md(os.path.join(pages_dir, resolved_path), pages_dir) | ||||||
|             link = f'https://{Config.DOMAIN_NAME}{instance_resource_path_to_request_path(resolved_path)}' |             link = f'https://{Config.DOMAIN_NAME}{instance_resource_path_to_request_path(resolved_path)}' | ||||||
|         except (OSError, ValueError, TypeError): |         except (OSError, ValueError, TypeError): | ||||||
|             logger.exception("error loading/rendering markdown!") |             logger.exception("error loading/rendering markdown!") | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ as is, but .md files need to be processed with a Markdown parser, so a lot of th | |||||||
| is our tweaks and customizations for pages my way. | is our tweaks and customizations for pages my way. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import datetime | import datetime | ||||||
| import logging | import logging | ||||||
| @ -44,18 +44,21 @@ def instance_resource_path_to_request_path(path): | |||||||
|     return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path)) |     return '/' + re.sub(r'.md$', '', re.sub(r'index.md$', '', path)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def parse_md(path: str): | def parse_md(path: str, pages_root: str): | ||||||
|     """Given a file to parse, return file content and other derived data along with the md object. |     """Given a file to parse, return file content and other derived data along with the md object. | ||||||
| 
 | 
 | ||||||
|     Args: |     Args: | ||||||
|         path: the path to the file to render |         path: the path to the file to render | ||||||
|  |         pages_root: the absolute path to the pages/ dir, which the path should be within. necessary for | ||||||
|  |                     proper resolution of resolving parent pages (which needs to know when to stop) | ||||||
|     """ |     """ | ||||||
|     try: |     try: | ||||||
|         logger.debug("opening path '%s'", path) |         absolute_path = os.path.join(pages_root, path) | ||||||
|         with open(path, 'r') as input_file: |         logger.debug("opening path '%s'", absolute_path) | ||||||
|  |         with open(absolute_path, 'r') as input_file: | ||||||
|             mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc) |             mtime = datetime.datetime.fromtimestamp(os.path.getmtime(input_file.name), tz=datetime.timezone.utc) | ||||||
|             entry = input_file.read() |             entry = input_file.read() | ||||||
|         logger.debug("path '%s' read", path) |         logger.debug("path '%s' read", absolute_path) | ||||||
|         md = init_md() |         md = init_md() | ||||||
|         content = Markup(md.convert(entry))     # nosec B704 |         content = Markup(md.convert(entry))     # nosec B704 | ||||||
|     except (OSError, FileNotFoundError): |     except (OSError, FileNotFoundError): | ||||||
| @ -67,17 +70,25 @@ def parse_md(path: str): | |||||||
| 
 | 
 | ||||||
|     logger.debug("file metadata: %s", md.Meta) |     logger.debug("file metadata: %s", md.Meta) | ||||||
| 
 | 
 | ||||||
|     page_name = get_meta_str(md, 'title') if md.Meta.get('title') else instance_resource_path_to_request_path(path) |     rel_path = os.path.relpath(path, pages_root) | ||||||
|  |     page_name = get_meta_str(md, 'title') if md.Meta.get('title') else instance_resource_path_to_request_path(rel_path) | ||||||
|     page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX |     page_title = f'{page_name} - {Config.TITLE_SUFFIX}' if page_name else Config.TITLE_SUFFIX | ||||||
|     logger.debug("title (potentially derived): %s", page_title) |     logger.debug("title (potentially derived): %s", page_title) | ||||||
| 
 | 
 | ||||||
|     return content, md, page_name, page_title, mtime |     return content, md, page_name, page_title, mtime | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def handle_markdown_file_path(path: str) -> str: | def handle_markdown_file_path(path: str, pages_root: str) -> str: | ||||||
|     """Given a location on disk, attempt to open it and render the markdown within.""" |     """Given a location on disk, attempt to open it and render the markdown within. | ||||||
|     content, md, page_name, page_title, mtime = parse_md(path) | 
 | ||||||
|     parent_navs = generate_parent_navs(path) |     Args: | ||||||
|  |         path: the path to the file to parse and produce metadata for | ||||||
|  |         pages_root: the absolute path to the pages/ dir, which the path should be within. necessary for | ||||||
|  |                     proper resolution of resolving parent pages (which needs to know when to stop) | ||||||
|  |     """ | ||||||
|  |     content, md, page_name, page_title, mtime = parse_md(path, pages_root) | ||||||
|  |     relative_path = os.path.relpath(path, pages_root) | ||||||
|  |     parent_navs = generate_parent_navs(relative_path, pages_root) | ||||||
|     extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None |     extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None | ||||||
|     template_name = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html' |     template_name = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html' | ||||||
| 
 | 
 | ||||||
| @ -90,16 +101,23 @@ def handle_markdown_file_path(path: str) -> str: | |||||||
|     return template.render(title=page_title, |     return template.render(title=page_title, | ||||||
|                            config=Config, |                            config=Config, | ||||||
|                            description=get_meta_str(md, 'description'), |                            description=get_meta_str(md, 'description'), | ||||||
|                            image=get_meta_str(md, 'image'), |                            image=Config.BASE_HOST + get_meta_str(md, 'image'), | ||||||
|                            content=content, |                            content=content, | ||||||
|                            base_url=Config.BASE_HOST + instance_resource_path_to_request_path(path), |                            base_url=Config.BASE_HOST + instance_resource_path_to_request_path(relative_path), | ||||||
|                            navs=parent_navs, |                            navs=parent_navs, | ||||||
|                            mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'), |                            mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'), | ||||||
|                            extra_footer=extra_footer) |                            extra_footer=extra_footer) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def generate_parent_navs(path): | def generate_parent_navs(path, pages_root: str): | ||||||
|     """Create a series of paths/links to navigate up from the given resource path.""" |     """Create a series of paths/links to navigate up from the given resource path. | ||||||
|  | 
 | ||||||
|  |     Args: | ||||||
|  |         path: the path to parse and generate parent metadata nav links for | ||||||
|  |         pages_root: the absolute path to the pages/ dir, which the path should be within. path is relative, | ||||||
|  |                     but opening parents requires the full path | ||||||
|  |     """ | ||||||
|  |     logger.debug("path to generate navs for: %s", path) | ||||||
|     if path == 'index.md': |     if path == 'index.md': | ||||||
|         # bail and return the domain name as a terminal case |         # bail and return the domain name as a terminal case | ||||||
|         return [(Config.DOMAIN_NAME, '/')] |         return [(Config.DOMAIN_NAME, '/')] | ||||||
| @ -124,14 +142,14 @@ def generate_parent_navs(path): | |||||||
| 
 | 
 | ||||||
|         # read the resource |         # read the resource | ||||||
|         try: |         try: | ||||||
|             with open(path, 'r') as entry_file: |             with open(os.path.join(pages_root, path), 'r') as entry_file: | ||||||
|                 entry = entry_file.read() |                 entry = entry_file.read() | ||||||
|             _ = Markup(md.convert(entry))       # nosec B704 |             _ = Markup(md.convert(entry))       # nosec B704 | ||||||
|             page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') |             page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') | ||||||
|                          else request_path_to_breadcrumb_display(request_path)) |                          else request_path_to_breadcrumb_display(request_path)) | ||||||
|             return generate_parent_navs(parent_resource_path) + [(page_name, request_path)] |             return generate_parent_navs(parent_resource_path, pages_root) + [(page_name, request_path)] | ||||||
|         except FileNotFoundError: |         except FileNotFoundError: | ||||||
|             return generate_parent_navs(parent_resource_path) + [(request_path, request_path)] |             return generate_parent_navs(parent_resource_path, pages_root) + [(request_path, request_path)] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def request_path_to_breadcrumb_display(path): | def request_path_to_breadcrumb_display(path): | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """Markdown extensions. | """Markdown extensions. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Create generic figures with captions. | """Create generic figures with captions. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import re | import re | ||||||
| from xml.etree.ElementTree import SubElement  # nosec B405 | from xml.etree.ElementTree import SubElement  # nosec B405 | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Serve dot diagrams inline. | """Serve dot diagrams inline. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import base64 | import base64 | ||||||
| import logging | import logging | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| """Build an instance as a static site suitable for serving via e.g. Nginx. | """Build an instance as a static site suitable for serving via e.g. Nginx. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import argparse | import argparse | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import shutil | import shutil | ||||||
| import stat | import stat | ||||||
| @ -16,6 +17,8 @@ from incorporealcms.error_pages import generate_error_pages | |||||||
| from incorporealcms.feed import generate_feed | from incorporealcms.feed import generate_feed | ||||||
| from incorporealcms.markdown import handle_markdown_file_path | from incorporealcms.markdown import handle_markdown_file_path | ||||||
| 
 | 
 | ||||||
|  | logger = logging.getLogger(__name__) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class StaticSiteGenerator(object): | class StaticSiteGenerator(object): | ||||||
|     """Generate static site output based on the instance's content.""" |     """Generate static site output based on the instance's content.""" | ||||||
| @ -53,10 +56,6 @@ class StaticSiteGenerator(object): | |||||||
|             pass |             pass | ||||||
|         self.build_in_destination(program_static_dir, static_output_dir, convert_markdown=False) |         self.build_in_destination(program_static_dir, static_output_dir, convert_markdown=False) | ||||||
| 
 | 
 | ||||||
|         # copy the instance's static dir --- should I deprecate this since it could just be stuff in pages/static/? |  | ||||||
|         custom_static_dir = os.path.join(self.instance_dir, 'custom-static') |  | ||||||
|         self.build_in_destination(custom_static_dir, static_output_dir, convert_markdown=False) |  | ||||||
| 
 |  | ||||||
|         # generate the feeds |         # generate the feeds | ||||||
|         cprint("generating feeds", 'green') |         cprint("generating feeds", 'green') | ||||||
|         generate_feed('atom', self.instance_dir, tmp_output_dir) |         generate_feed('atom', self.instance_dir, tmp_output_dir) | ||||||
| @ -87,16 +86,20 @@ class StaticSiteGenerator(object): | |||||||
|             convert_markdown: whether or not to convert Markdown files (or simply copy them) |             convert_markdown: whether or not to convert Markdown files (or simply copy them) | ||||||
|         """ |         """ | ||||||
|         cprint(f"copying files from '{source_dir}' to '{dest_dir}'", 'green') |         cprint(f"copying files from '{source_dir}' to '{dest_dir}'", 'green') | ||||||
|         os.chdir(source_dir) |  | ||||||
|         for base_dir, subdirs, files in os.walk(source_dir): |         for base_dir, subdirs, files in os.walk(source_dir): | ||||||
|  |             logger.debug("starting to build against %s || %s || %s", base_dir, subdirs, files) | ||||||
|             # remove the absolute path of the directory from the base_dir |             # remove the absolute path of the directory from the base_dir | ||||||
|             base_dir = os.path.relpath(base_dir, source_dir) |             relpath = os.path.relpath(base_dir, source_dir) | ||||||
|  |             base_dir = relpath if relpath != '.' else '' | ||||||
|             # create subdirs seen here for subsequent depth |             # create subdirs seen here for subsequent depth | ||||||
|             for subdir in subdirs: |             for subdir in subdirs: | ||||||
|                 self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir) |                 self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir) | ||||||
| 
 | 
 | ||||||
|             # process and copy files |             # process and copy files | ||||||
|             for file_ in files: |             for file_ in files: | ||||||
|  |                 if file_[0] == '.': | ||||||
|  |                     cprint(f"skipping {file_}", 'yellow') | ||||||
|  |                     continue | ||||||
|                 self.build_file_in_destination(source_dir, base_dir, file_, dest_dir, convert_markdown) |                 self.build_file_in_destination(source_dir, base_dir, file_, dest_dir, convert_markdown) | ||||||
| 
 | 
 | ||||||
|     def build_subdir_in_destination(self, source_dir: str, base_dir: str, subdir: str, dest_dir: str) -> None: |     def build_subdir_in_destination(self, source_dir: str, base_dir: str, subdir: str, dest_dir: str) -> None: | ||||||
| @ -109,9 +112,12 @@ class StaticSiteGenerator(object): | |||||||
|             dest_dir: the output directory to place the subdir in |             dest_dir: the output directory to place the subdir in | ||||||
|         """ |         """ | ||||||
|         dst = os.path.join(dest_dir, base_dir, subdir) |         dst = os.path.join(dest_dir, base_dir, subdir) | ||||||
|         if os.path.islink(os.path.join(base_dir, subdir)): |         absolute_dir = os.path.join(source_dir, base_dir, subdir) | ||||||
|  |         logger.debug("checking if %s is a symlink or not", absolute_dir) | ||||||
|  |         if os.path.islink(absolute_dir): | ||||||
|  |             logger.debug("symlink; raw destination is %s", os.path.realpath(absolute_dir)) | ||||||
|             # keep the link relative to the output directory |             # keep the link relative to the output directory | ||||||
|             src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, subdir)) |             src = self.symlink_to_relative_dest(source_dir, absolute_dir) | ||||||
|             print(f"creating directory symlink '{dst}' -> '{src}'") |             print(f"creating directory symlink '{dst}' -> '{src}'") | ||||||
|             os.symlink(src, dst, target_is_directory=True) |             os.symlink(src, dst, target_is_directory=True) | ||||||
|         else: |         else: | ||||||
| @ -133,21 +139,33 @@ class StaticSiteGenerator(object): | |||||||
|             dest_dir: the output directory to place the subdir in |             dest_dir: the output directory to place the subdir in | ||||||
|         """ |         """ | ||||||
|         dst = os.path.join(dest_dir, base_dir, file_) |         dst = os.path.join(dest_dir, base_dir, file_) | ||||||
|         if os.path.islink(os.path.join(base_dir, file_)): |         absolute_file = os.path.join(source_dir, base_dir, file_) | ||||||
|  |         logger.debug("checking if %s is a symlink or not", absolute_file) | ||||||
|  |         if os.path.islink(absolute_file): | ||||||
|  |             logger.debug("symlink; raw destination is %s", os.path.realpath(absolute_file)) | ||||||
|             # keep the link relative to the output directory |             # keep the link relative to the output directory | ||||||
|             src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, file_)) |             src = self.symlink_to_relative_dest(source_dir, absolute_file) | ||||||
|             print(f"creating symlink '{dst}' -> '{src}'") |             print(f"creating symlink '{dst}' -> '{src}'") | ||||||
|             os.symlink(src, dst, target_is_directory=False) |             os.symlink(src, dst, target_is_directory=False) | ||||||
|  |             if src.endswith('.md') and convert_markdown: | ||||||
|  |                 # we also need to make a .html symlink so that web server configs | ||||||
|  |                 # pick up the "redirect" | ||||||
|  |                 second_src = src.removesuffix('.md') + '.html' | ||||||
|  |                 second_dst = dst.removesuffix('.md') + '.html' | ||||||
|  |                 print(f"creating symlink '{second_dst}' -> '{second_src}'") | ||||||
|  |                 os.symlink(second_src, second_dst, target_is_directory=False) | ||||||
|  | 
 | ||||||
|         else: |         else: | ||||||
|             src = os.path.join(base_dir, file_) |             src = os.path.join(source_dir, base_dir, file_) | ||||||
|             print(f"copying file '{src}' -> '{dst}'") |             print(f"copying file '{src}' -> '{dst}'") | ||||||
|             shutil.copy2(src, dst) |             shutil.copy2(src, dst) | ||||||
| 
 | 
 | ||||||
|             # render markdown as HTML |             # render markdown as HTML | ||||||
|             if src.endswith('.md') and convert_markdown: |             if src.endswith('.md') and convert_markdown: | ||||||
|                 rendered_file = dst.removesuffix('.md') + '.html' |                 rendered_file = dst.removesuffix('.md') + '.html' | ||||||
|  |                 print(f"rendering file '{src}' -> '{rendered_file}'") | ||||||
|                 try: |                 try: | ||||||
|                     content = handle_markdown_file_path(src) |                     content = handle_markdown_file_path(src, source_dir) | ||||||
|                 except UnicodeDecodeError: |                 except UnicodeDecodeError: | ||||||
|                     # perhaps this isn't a markdown file at all for some reason; we |                     # perhaps this isn't a markdown file at all for some reason; we | ||||||
|                     # copied it above so stick with tha |                     # copied it above so stick with tha | ||||||
| @ -159,6 +177,10 @@ class StaticSiteGenerator(object): | |||||||
|     def symlink_to_relative_dest(self, base_dir: str, source: str) -> str: |     def symlink_to_relative_dest(self, base_dir: str, source: str) -> str: | ||||||
|         """Given a symlink, make sure it points to something inside the instance and provide its real destination. |         """Given a symlink, make sure it points to something inside the instance and provide its real destination. | ||||||
| 
 | 
 | ||||||
|  |         This is made to be relative to the location of the symlink in all | ||||||
|  |         circumstances, in order to avoid breaking out of the instance or output | ||||||
|  |         dirs. | ||||||
|  | 
 | ||||||
|         Args: |         Args: | ||||||
|             base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in. |             base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in. | ||||||
|             source: the symlink to check |             source: the symlink to check | ||||||
| @ -167,8 +189,8 @@ class StaticSiteGenerator(object): | |||||||
|         """ |         """ | ||||||
|         if not os.path.realpath(source).startswith(base_dir): |         if not os.path.realpath(source).startswith(base_dir): | ||||||
|             raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!") |             raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!") | ||||||
|         # this symlink points to realpath inside base_dir, so relative to base_dir, the symlink dest is... |         # this symlink points to realpath inside base_dir, so relative to the source, the symlink dest is... | ||||||
|         return os.path.relpath(os.path.realpath(source), base_dir) |         return os.path.relpath(os.path.realpath(source), os.path.dirname(source)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def build(): | def build(): | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| /* | /* | ||||||
|  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> |  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
|  * SPDX-License-Identifier: GPL-3.0-only |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| html { | html { | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| /* | /* | ||||||
|  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> |  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
|  * SPDX-License-Identifier: GPL-3.0-only |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| /* common styling via the base.css, used in light and dark */ | /* common styling via the base.css, used in light and dark */ | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| /* | /* | ||||||
|  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> |  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
|  * SPDX-License-Identifier: GPL-3.0-only |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| /* common styling via the base.css, used in light and dark */ | /* common styling via the base.css, used in light and dark */ | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| /* | /* | ||||||
|  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> |  * SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
|  * SPDX-License-Identifier: GPL-3.0-only |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| /* specify almost no styling, just fix some image and nav rendering */ | /* specify almost no styling, just fix some image and nav rendering */ | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ | |||||||
|  * of my CMS, so I want to keep it around even in the static site. |  * of my CMS, so I want to keep it around even in the static site. | ||||||
|  * |  * | ||||||
|  * SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> |  * SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
|  * SPDX-License-Identifier: GPL-3.0-only |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| {# | {# | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| #} | #} | ||||||
| {% extends "base.html" %} | {% extends "base.html" %} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| {# | {# | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| #} | #} | ||||||
| {% extends "base.html" %} | {% extends "base.html" %} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| {# | {# | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| #} | #} | ||||||
| {% extends "base.html" %} | {% extends "base.html" %} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| {# | {# | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| #} | #} | ||||||
| {% extends "base.html" %} | {% extends "base.html" %} | ||||||
| {% block site_class %}class="site-wrap site-wrap-double-width"{% endblock %} | {% block site_class %}class="site-wrap site-wrap-double-width"{% endblock %} | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| <!doctype html>{# | <!doctype html>{# | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| #} | #} | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
| <title>{{ title }}</title> | <title>{{ title }}</title> | ||||||
| <meta charset="utf-8"> | <meta charset="utf-8"> | ||||||
|  | <meta property="og:url" content="{{ base_url }}"> | ||||||
| {% if title %}<meta property="og:title" content="{{ title }}">{% endif %} | {% if title %}<meta property="og:title" content="{{ title }}">{% endif %} | ||||||
| {% if description %}<meta property="og:description" content="{{ description }}">{% endif %} | {% if description %}<meta property="og:description" content="{{ description }}">{% endif %} | ||||||
| <meta property="og:url" content="{{ base_url }}"> |  | ||||||
| {% if image %}<meta property="og:image" content="{{ image }}">{% endif %} | {% if image %}<meta property="og:image" content="{{ image }}">{% endif %} | ||||||
| <meta name="twitter:card" content="summary_large_image"> | <meta name="twitter:card" content="summary_large_image"> | ||||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" | |||||||
| name = "incorporeal-cms" | name = "incorporeal-cms" | ||||||
| description = "A lightweight static site generator for Markdown-based sites." | description = "A lightweight static site generator for Markdown-based sites." | ||||||
| readme = "README.md" | readme = "README.md" | ||||||
| license = {text = "GPL-3.0-only"} | license = {text = "GPL-3.0-or-later"} | ||||||
| authors = [ | authors = [ | ||||||
| 	{name = "Brian S. Stephan", email = "bss@incorporeal.org"}, | 	{name = "Brian S. Stephan", email = "bss@incorporeal.org"}, | ||||||
| ] | ] | ||||||
| @ -14,9 +14,8 @@ requires-python = ">=3.9" | |||||||
| dependencies = ["feedgen", "jinja2", "Markdown", "termcolor"] | dependencies = ["feedgen", "jinja2", "Markdown", "termcolor"] | ||||||
| dynamic = ["version"] | dynamic = ["version"] | ||||||
| classifiers = [ | classifiers = [ | ||||||
| 	"Framework :: Flask", |  | ||||||
| 	"Programming Language :: Python :: 3", | 	"Programming Language :: Python :: 3", | ||||||
| 	"License :: OSI Approved :: GNU General Public License v3 (GPLv3)", | 	"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", | ||||||
| 	"Operating System :: OS Independent", | 	"Operating System :: OS Independent", | ||||||
| 	"Topic :: Text Processing :: Markup :: Markdown", | 	"Topic :: Text Processing :: Markup :: Markdown", | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Test graphviz functionality. | """Test graphviz functionality. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2021 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| import tempfile | import tempfile | ||||||
| @ -23,13 +23,11 @@ def test_graphviz_is_rendered(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         ssg = StaticSiteGenerator(src_dir, tmpdir) |         ssg = StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(src_dir, 'pages')) |  | ||||||
| 
 | 
 | ||||||
|         ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'test-graphviz.md', tmpdir, True) |         ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'test-graphviz.md', tmpdir, True) | ||||||
|         with open(os.path.join(tmpdir, 'test-graphviz.html'), 'r') as graphviz_output: |         with open(os.path.join(tmpdir, 'test-graphviz.html'), 'r') as graphviz_output: | ||||||
|             data = graphviz_output.read() |             data = graphviz_output.read() | ||||||
|             assert 'data:image/png;base64' in data |             assert 'data:image/png;base64' in data | ||||||
|     os.chdir(HERE) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_invalid_graphviz_is_not_rendered(): | def test_invalid_graphviz_is_not_rendered(): | ||||||
| @ -37,12 +35,10 @@ def test_invalid_graphviz_is_not_rendered(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         ssg = StaticSiteGenerator(src_dir, tmpdir) |         ssg = StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(src_dir, 'broken')) |  | ||||||
| 
 | 
 | ||||||
|         with pytest.raises(ValueError): |         with pytest.raises(ValueError): | ||||||
|             ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'broken'), '', 'test-invalid-graphviz.md', |             ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'broken'), '', 'test-invalid-graphviz.md', | ||||||
|                                           tmpdir, True) |                                           tmpdir, True) | ||||||
|     os.chdir(HERE) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_figures_are_rendered(): | def test_figures_are_rendered(): | ||||||
| @ -50,7 +46,6 @@ def test_figures_are_rendered(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         ssg = StaticSiteGenerator(src_dir, tmpdir) |         ssg = StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(src_dir, 'pages')) |  | ||||||
| 
 | 
 | ||||||
|         ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'figures.md', tmpdir, True) |         ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'figures.md', tmpdir, True) | ||||||
|         with open(os.path.join(tmpdir, 'figures.html'), 'r') as graphviz_output: |         with open(os.path.join(tmpdir, 'figures.html'), 'r') as graphviz_output: | ||||||
| @ -62,4 +57,29 @@ def test_figures_are_rendered(): | |||||||
|             assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />' |             assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />' | ||||||
|                     '<span></span></figure>') in data |                     '<span></span></figure>') in data | ||||||
|             assert '<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in data |             assert '<figure><img alt="just a logo" src="bss-square-no-bg.png" /></figure>' in data | ||||||
|     os.chdir(HERE) | 
 | ||||||
|  | 
 | ||||||
|  | def test_og_image(): | ||||||
|  |     """Test that the og:image meta tag is present as expected.""" | ||||||
|  |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|  |         src_dir = os.path.join(HERE, 'instance') | ||||||
|  |         ssg = StaticSiteGenerator(src_dir, tmpdir) | ||||||
|  | 
 | ||||||
|  |         ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'pages'), '', 'more-metadata.md', tmpdir, True) | ||||||
|  |         with open(os.path.join(tmpdir, 'more-metadata.html'), 'r') as markdown_output: | ||||||
|  |             data = markdown_output.read() | ||||||
|  |             assert ('<meta property="og:image" content="http://example.org/test.img">') in data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_og_url(): | ||||||
|  |     """Test that the og:url meta tag is present as expected.""" | ||||||
|  |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|  |         src_dir = os.path.join(HERE, 'instance') | ||||||
|  |         ssg = StaticSiteGenerator(src_dir, tmpdir) | ||||||
|  | 
 | ||||||
|  |         # testing a whole build run because of bugs in how I handle pathing adding a "./" in | ||||||
|  |         # the generated URLs for content in the pages/ root | ||||||
|  |         ssg.build_in_destination(os.path.join(HERE, 'instance', 'pages'), tmpdir, True) | ||||||
|  |         with open(os.path.join(tmpdir, 'index.html'), 'r') as markdown_output: | ||||||
|  |             data = markdown_output.read() | ||||||
|  |             assert ('<meta property="og:url" content="http://example.org/">') in data | ||||||
|  | |||||||
| @ -18,7 +18,11 @@ | |||||||
| 				"level": "DEBUG", | 				"level": "DEBUG", | ||||||
| 				"handlers": ["console"] | 				"handlers": ["console"] | ||||||
| 			}, | 			}, | ||||||
| 			"incorporealcms.pages": { | 			"incorporealcms.markdown": { | ||||||
|  | 				"level": "DEBUG", | ||||||
|  | 				"handlers": ["console"] | ||||||
|  | 			}, | ||||||
|  | 			"incorporealcms.ssg": { | ||||||
| 				"level": "DEBUG", | 				"level": "DEBUG", | ||||||
| 				"handlers": ["console"] | 				"handlers": ["console"] | ||||||
| 			} | 			} | ||||||
|  | |||||||
| @ -1,31 +0,0 @@ | |||||||
| """Configure the test application. |  | ||||||
| 
 |  | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> |  | ||||||
| SPDX-License-Identifier: GPL-3.0-only |  | ||||||
| """ |  | ||||||
| 
 |  | ||||||
| LOGGING = { |  | ||||||
|     'version': 1, |  | ||||||
|     'formatters': { |  | ||||||
|         'default': { |  | ||||||
|             'format': '[%(asctime)s %(levelname)-7s %(name)s] %(message)s', |  | ||||||
|         }, |  | ||||||
|     }, |  | ||||||
|     'handlers': { |  | ||||||
|         'console': { |  | ||||||
|             'level': 'DEBUG', |  | ||||||
|             'class': 'logging.StreamHandler', |  | ||||||
|             'formatter': 'default', |  | ||||||
|         }, |  | ||||||
|     }, |  | ||||||
|     'loggers': { |  | ||||||
|         'incorporealcms.mdx': { |  | ||||||
|             'level': 'DEBUG', |  | ||||||
|             'handlers': ['console'], |  | ||||||
|         }, |  | ||||||
|         'incorporealcms.pages': { |  | ||||||
|             'level': 'DEBUG', |  | ||||||
|             'handlers': ['console'], |  | ||||||
|         }, |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
							
								
								
									
										1
									
								
								tests/instance/pages/.ignored-file.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								tests/instance/pages/.ignored-file.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | this is ignored | ||||||
| @ -1,6 +1,6 @@ | |||||||
| Title:          title for the page | Title:          title for the page | ||||||
| Description:    description of this page | Description:    description of this page | ||||||
|                 made even longer |                 made even longer | ||||||
| Image:          http://buh.com/test.img | Image:          /test.img | ||||||
| 
 | 
 | ||||||
| hello | hello | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								tests/instance/pages/subdir/relative-symlink-to-parent.md
									
									
									
									
									
										Symbolic link
									
								
							
							
						
						
									
										1
									
								
								tests/instance/pages/subdir/relative-symlink-to-parent.md
									
									
									
									
									
										Symbolic link
									
								
							| @ -0,0 +1 @@ | |||||||
|  | ../more-metadata.md | ||||||
| @ -1,7 +1,7 @@ | |||||||
| """Test command line invocations. | """Test command line invocations. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| import tempfile | import tempfile | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Test basic configuration stuff. | """Test basic configuration stuff. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Test the feed methods. | """Test the feed methods. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2023 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| import tempfile | import tempfile | ||||||
|  | |||||||
| @ -1,40 +1,45 @@ | |||||||
| """Test the conversion of Markdown pages. | """Test the conversion of Markdown pages. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| from unittest.mock import patch | from unittest.mock import patch | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
|  | from incorporealcms import init_instance | ||||||
| from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path, | from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path, | ||||||
|                                      instance_resource_path_to_request_path, parse_md, |                                      instance_resource_path_to_request_path, parse_md, | ||||||
|                                      request_path_to_breadcrumb_display) |                                      request_path_to_breadcrumb_display) | ||||||
| 
 | 
 | ||||||
| HERE = os.path.dirname(os.path.abspath(__file__)) | HERE = os.path.dirname(os.path.abspath(__file__)) | ||||||
| os.chdir(os.path.join(HERE, 'instance/', 'pages/')) | INSTANCE_DIR = os.path.join(HERE, 'instance') | ||||||
|  | PAGES_DIR = os.path.join(INSTANCE_DIR, 'pages/') | ||||||
|  | 
 | ||||||
|  | # initialize in order to configure debug logging | ||||||
|  | init_instance(INSTANCE_DIR) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_generate_page_navs_index(): | def test_generate_page_navs_index(): | ||||||
|     """Test that the index page has navs to the root (itself).""" |     """Test that the index page has navs to the root (itself).""" | ||||||
|     assert generate_parent_navs('index.md') == [('example.org', '/')] |     assert generate_parent_navs('index.md', PAGES_DIR) == [('example.org', '/')] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_generate_page_navs_subdir_index(): | def test_generate_page_navs_subdir_index(): | ||||||
|     """Test that dir pages have navs to the root and themselves.""" |     """Test that dir pages have navs to the root and themselves.""" | ||||||
|     assert generate_parent_navs('subdir/index.md') == [('example.org', '/'), ('subdir', '/subdir/')] |     assert generate_parent_navs('subdir/index.md', PAGES_DIR) == [('example.org', '/'), ('subdir', '/subdir/')] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_generate_page_navs_subdir_real_page(): | def test_generate_page_navs_subdir_real_page(): | ||||||
|     """Test that real pages have navs to the root, their parent, and themselves.""" |     """Test that real pages have navs to the root, their parent, and themselves.""" | ||||||
|     assert generate_parent_navs('subdir/page.md') == [('example.org', '/'), ('subdir', '/subdir/'), |     assert generate_parent_navs('subdir/page.md', PAGES_DIR) == [('example.org', '/'), ('subdir', '/subdir/'), | ||||||
|                                                       ('Page', '/subdir/page')] |                                                                  ('Page', '/subdir/page')] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_generate_page_navs_subdir_with_title_parsing_real_page(): | def test_generate_page_navs_subdir_with_title_parsing_real_page(): | ||||||
|     """Test that title metadata is used in the nav text.""" |     """Test that title metadata is used in the nav text.""" | ||||||
|     assert generate_parent_navs('subdir-with-title/page.md') == [ |     assert generate_parent_navs('subdir-with-title/page.md', PAGES_DIR) == [ | ||||||
|         ('example.org', '/'), |         ('example.org', '/'), | ||||||
|         ('SUB!', '/subdir-with-title/'), |         ('SUB!', '/subdir-with-title/'), | ||||||
|         ('page', '/subdir-with-title/page') |         ('page', '/subdir-with-title/page') | ||||||
| @ -43,7 +48,7 @@ def test_generate_page_navs_subdir_with_title_parsing_real_page(): | |||||||
| 
 | 
 | ||||||
| def test_generate_page_navs_subdir_with_no_index(): | def test_generate_page_navs_subdir_with_no_index(): | ||||||
|     """Test that breadcrumbs still generate even if a subdir doesn't have an index.md.""" |     """Test that breadcrumbs still generate even if a subdir doesn't have an index.md.""" | ||||||
|     assert generate_parent_navs('no-index-dir/page.md') == [ |     assert generate_parent_navs('no-index-dir/page.md', PAGES_DIR) == [ | ||||||
|         ('example.org', '/'), |         ('example.org', '/'), | ||||||
|         ('/no-index-dir/', '/no-index-dir/'), |         ('/no-index-dir/', '/no-index-dir/'), | ||||||
|         ('page', '/no-index-dir/page') |         ('page', '/no-index-dir/page') | ||||||
| @ -53,26 +58,26 @@ def test_generate_page_navs_subdir_with_no_index(): | |||||||
| def test_page_includes_themes_with_default(): | def test_page_includes_themes_with_default(): | ||||||
|     """Test that a request contains the configured themes and sets the default as appropriate.""" |     """Test that a request contains the configured themes and sets the default as appropriate.""" | ||||||
|     assert '<link rel="stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ |     assert '<link rel="stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|     assert '<link rel="alternate stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ |     assert '<link rel="alternate stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|     assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ |     assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|     assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ |     assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_render_with_style_overrides(): | def test_render_with_style_overrides(): | ||||||
|     """Test that the default can be changed.""" |     """Test that the default can be changed.""" | ||||||
|     with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'): |     with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'): | ||||||
|         assert '<link rel="stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ |         assert '<link rel="stylesheet" type="text/css" title="dark" href="/static/css/dark.css">'\ | ||||||
|             in handle_markdown_file_path('index.md') |             in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|         assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ |         assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ | ||||||
|             in handle_markdown_file_path('index.md') |             in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|     assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ |     assert '<a href="" onclick="setStyle(\'light\'); return false;">[light]</a>'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|     assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ |     assert '<a href="" onclick="setStyle(\'dark\'); return false;">[dark]</a>'\ | ||||||
|         in handle_markdown_file_path('index.md') |         in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_render_with_default_style_override(): | def test_render_with_default_style_override(): | ||||||
| @ -81,23 +86,21 @@ def test_render_with_default_style_override(): | |||||||
|                                                      'warm': '/static/css/warm.css'}): |                                                      'warm': '/static/css/warm.css'}): | ||||||
|         with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'): |         with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'): | ||||||
|             assert '<link rel="stylesheet" type="text/css" title="warm" href="/static/css/warm.css">'\ |             assert '<link rel="stylesheet" type="text/css" title="warm" href="/static/css/warm.css">'\ | ||||||
|                 in handle_markdown_file_path('index.md') |                 in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|             assert '<link rel="alternate stylesheet" type="text/css" title="cool" href="/static/css/cool.css">'\ |             assert '<link rel="alternate stylesheet" type="text/css" title="cool" href="/static/css/cool.css">'\ | ||||||
|                 in handle_markdown_file_path('index.md') |                 in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|             assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ |             assert '<link rel="alternate stylesheet" type="text/css" title="light" href="/static/css/light.css">'\ | ||||||
|                 not in handle_markdown_file_path('index.md') |                 not in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|             assert '<a href="" onclick="setStyle(\'warm\'); return false;">[warm]</a>'\ |             assert '<a href="" onclick="setStyle(\'warm\'); return false;">[warm]</a>'\ | ||||||
|                 in handle_markdown_file_path('index.md') |                 in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
|             assert '<a href="" onclick="setStyle(\'cool\'); return false;">[cool]</a>'\ |             assert '<a href="" onclick="setStyle(\'cool\'); return false;">[cool]</a>'\ | ||||||
|                 in handle_markdown_file_path('index.md') |                 in handle_markdown_file_path('index.md', PAGES_DIR) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_redirects_error_unsupported(): | def test_redirects_error_unsupported(): | ||||||
|     """Test that we throw a warning about the barely-used Markdown redirect tag, which we can't support via SSG.""" |     """Test that we throw a warning about the barely-used Markdown redirect tag, which we can't support via SSG.""" | ||||||
|     os.chdir(os.path.join(HERE, 'instance/', 'broken/')) |  | ||||||
|     with pytest.raises(NotImplementedError): |     with pytest.raises(NotImplementedError): | ||||||
|         handle_markdown_file_path('redirect.md') |         handle_markdown_file_path('redirect.md', os.path.join(INSTANCE_DIR, 'broken')) | ||||||
|     os.chdir(os.path.join(HERE, 'instance/', 'pages/')) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_instance_resource_path_to_request_path_on_index(): | def test_instance_resource_path_to_request_path_on_index(): | ||||||
| @ -131,21 +134,21 @@ def test_request_path_to_breadcrumb_display_patterns(): | |||||||
| 
 | 
 | ||||||
| def test_parse_md_metadata(): | def test_parse_md_metadata(): | ||||||
|     """Test the direct results of parsing a markdown file.""" |     """Test the direct results of parsing a markdown file.""" | ||||||
|     content, md, page_name, page_title, mtime = parse_md('more-metadata.md') |     content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'more-metadata.md'), PAGES_DIR) | ||||||
|     assert page_name == 'title for the page' |     assert page_name == 'title for the page' | ||||||
|     assert page_title == 'title for the page - example.org' |     assert page_title == 'title for the page - example.org' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_parse_md_metadata_forced_no_title(): | def test_parse_md_metadata_forced_no_title(): | ||||||
|     """Test the direct results of parsing a markdown file.""" |     """Test the direct results of parsing a markdown file.""" | ||||||
|     content, md, page_name, page_title, mtime = parse_md('forced-no-title.md') |     content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'forced-no-title.md'), PAGES_DIR) | ||||||
|     assert page_name == '' |     assert page_name == '' | ||||||
|     assert page_title == 'example.org' |     assert page_title == 'example.org' | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_parse_md_metadata_no_title_so_path(): | def test_parse_md_metadata_no_title_so_path(): | ||||||
|     """Test the direct results of parsing a markdown file.""" |     """Test the direct results of parsing a markdown file.""" | ||||||
|     content, md, page_name, page_title, mtime = parse_md('subdir/index.md') |     content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'subdir/index.md'), PAGES_DIR) | ||||||
|     assert page_name == '/subdir/' |     assert page_name == '/subdir/' | ||||||
|     assert page_title == '/subdir/ - example.org' |     assert page_title == '/subdir/ - example.org' | ||||||
| 
 | 
 | ||||||
| @ -153,10 +156,10 @@ def test_parse_md_metadata_no_title_so_path(): | |||||||
| def test_parse_md_no_file(): | def test_parse_md_no_file(): | ||||||
|     """Test the direct results of parsing a markdown file.""" |     """Test the direct results of parsing a markdown file.""" | ||||||
|     with pytest.raises(FileNotFoundError): |     with pytest.raises(FileNotFoundError): | ||||||
|         content, md, page_name, page_title, mtime = parse_md('nope.md') |         content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'nope.md'), PAGES_DIR) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_parse_md_bad_file(): | def test_parse_md_bad_file(): | ||||||
|     """Test the direct results of parsing a markdown file.""" |     """Test the direct results of parsing a markdown file.""" | ||||||
|     with pytest.raises(ValueError): |     with pytest.raises(ValueError): | ||||||
|         content, md, page_name, page_title, mtime = parse_md('actually-a-png.md') |         content, md, page_name, page_title, mtime = parse_md(os.path.join(PAGES_DIR, 'actually-a-png.md'), PAGES_DIR) | ||||||
|  | |||||||
| @ -1,14 +1,17 @@ | |||||||
| """Test the high level SSG operations. | """Test the high level SSG operations. | ||||||
| 
 | 
 | ||||||
| SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> | ||||||
| SPDX-License-Identifier: GPL-3.0-only | SPDX-License-Identifier: GPL-3.0-or-later | ||||||
| """ | """ | ||||||
|  | import logging | ||||||
| import os | import os | ||||||
| import tempfile | import tempfile | ||||||
| 
 | 
 | ||||||
| import incorporealcms.ssg as ssg | import incorporealcms.ssg as ssg | ||||||
| from incorporealcms import init_instance | from incorporealcms import init_instance | ||||||
| 
 | 
 | ||||||
|  | logger = logging.getLogger(__file__) | ||||||
|  | 
 | ||||||
| HERE = os.path.dirname(os.path.abspath(__file__)) | HERE = os.path.dirname(os.path.abspath(__file__)) | ||||||
| 
 | 
 | ||||||
| instance_dir = os.path.join(HERE, 'instance') | instance_dir = os.path.join(HERE, 'instance') | ||||||
| @ -20,7 +23,6 @@ def test_file_copy(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(instance_dir, 'pages')) |  | ||||||
|         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir, |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir, | ||||||
|                                             True) |                                             True) | ||||||
|         assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) |         assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) | ||||||
| @ -32,7 +34,6 @@ def test_file_copy_no_markdown(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(instance_dir, 'pages')) |  | ||||||
|         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir, |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir, | ||||||
|                                             False) |                                             False) | ||||||
|         assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) |         assert os.path.exists(os.path.join(tmpdir, 'no-title.md')) | ||||||
| @ -44,7 +45,6 @@ def test_file_copy_symlink(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(instance_dir, 'pages')) |  | ||||||
|         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-foo.txt', tmpdir) |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-foo.txt', tmpdir) | ||||||
|         # need to copy the destination for os.path.exists to be happy with this |         # need to copy the destination for os.path.exists to be happy with this | ||||||
|         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'foo.txt', tmpdir) |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'foo.txt', tmpdir) | ||||||
| @ -52,12 +52,44 @@ def test_file_copy_symlink(): | |||||||
|         assert os.path.islink(os.path.join(tmpdir, 'symlink-to-foo.txt')) |         assert os.path.islink(os.path.join(tmpdir, 'symlink-to-foo.txt')) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def test_file_copy_subdir_symlink(): | ||||||
|  |     """Test the ability to sync a symlink in a subdirectory to the output dir.""" | ||||||
|  |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|  |         src_dir = os.path.join(HERE, 'instance') | ||||||
|  |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|  |         # need to make the subdirectory as if the generator already did | ||||||
|  |         os.mkdir(os.path.join(tmpdir, 'subdir')) | ||||||
|  |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), 'subdir', | ||||||
|  |                                             'relative-symlink-to-parent.md', tmpdir) | ||||||
|  |         # need to copy the destination for os.path.exists to be happy with this | ||||||
|  |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'more-metadata.md', tmpdir) | ||||||
|  |         logger.warning("created symlink %s", | ||||||
|  |                        os.readlink(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md'))) | ||||||
|  |         assert os.path.islink(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md')) | ||||||
|  |         assert os.path.exists(os.path.join(tmpdir, 'subdir', 'relative-symlink-to-parent.md')) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_file_copy_symlink_of_markdown_also_has_html_symlink(): | ||||||
|  |     """Test the ability to sync source and generated symlinks to the output dir.""" | ||||||
|  |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|  |         src_dir = os.path.join(HERE, 'instance') | ||||||
|  |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|  |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-no-title.md', tmpdir, | ||||||
|  |                                             True) | ||||||
|  |         # need to copy the destination for os.path.exists to be happy with this | ||||||
|  |         generator.build_file_in_destination(os.path.join(instance_dir, 'pages'), '', 'no-title.md', tmpdir, True) | ||||||
|  |         assert os.path.exists(os.path.join(tmpdir, 'symlink-to-no-title.md')) | ||||||
|  |         assert os.path.islink(os.path.join(tmpdir, 'symlink-to-no-title.md')) | ||||||
|  |         # a .md symlink should also create the .html symlink (see issue #24) | ||||||
|  |         assert os.path.exists(os.path.join(tmpdir, 'symlink-to-no-title.html')) | ||||||
|  |         assert os.path.islink(os.path.join(tmpdir, 'symlink-to-no-title.html')) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def test_dir_copy(): | def test_dir_copy(): | ||||||
|     """Test the ability to sync a directory to the output dir.""" |     """Test the ability to sync a directory to the output dir.""" | ||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(instance_dir, 'pages')) |  | ||||||
|         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'media', tmpdir) |         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'media', tmpdir) | ||||||
|         assert os.path.exists(os.path.join(tmpdir, 'media')) |         assert os.path.exists(os.path.join(tmpdir, 'media')) | ||||||
|         assert os.path.isdir(os.path.join(tmpdir, 'media')) |         assert os.path.isdir(os.path.join(tmpdir, 'media')) | ||||||
| @ -68,7 +100,6 @@ def test_dir_copy_symlink(): | |||||||
|     with tempfile.TemporaryDirectory() as tmpdir: |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|         src_dir = os.path.join(HERE, 'instance') |         src_dir = os.path.join(HERE, 'instance') | ||||||
|         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|         os.chdir(os.path.join(instance_dir, 'pages')) |  | ||||||
|         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-subdir', tmpdir) |         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'symlink-to-subdir', tmpdir) | ||||||
|         # need to copy the destination for os.path.exists to be happy with this |         # need to copy the destination for os.path.exists to be happy with this | ||||||
|         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'subdir', tmpdir) |         generator.build_subdir_in_destination(os.path.join(instance_dir, 'pages'), '', 'subdir', tmpdir) | ||||||
| @ -88,3 +119,13 @@ def test_build_in_destination(): | |||||||
|         assert os.path.exists(os.path.join(tmpdir, 'index.html')) |         assert os.path.exists(os.path.join(tmpdir, 'index.html')) | ||||||
|         assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.md')) |         assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.md')) | ||||||
|         assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.html')) |         assert os.path.exists(os.path.join(tmpdir, 'subdir', 'index.html')) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_build_in_destination_ignores_dot_files(): | ||||||
|  |     """Test the ability to walk a source and populate the destination.""" | ||||||
|  |     with tempfile.TemporaryDirectory() as tmpdir: | ||||||
|  |         src_dir = os.path.join(HERE, 'instance') | ||||||
|  |         generator = ssg.StaticSiteGenerator(src_dir, tmpdir) | ||||||
|  |         generator.build_in_destination(os.path.join(src_dir, 'pages'), tmpdir) | ||||||
|  | 
 | ||||||
|  |         assert not os.path.exists(os.path.join(tmpdir, '.ignored-file.md')) | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user