diff --git a/incorporealcms/feed.py b/incorporealcms/feed.py index 81e2e86..5a80071 100644 --- a/incorporealcms/feed.py +++ b/incorporealcms/feed.py @@ -24,6 +24,9 @@ logger = logging.getLogger(__name__) def generate_feed(feed_type: str, instance_dir: str, dest_dir: str) -> None: """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: feed_type: 'atom' or 'rss' feed 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.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 feed_path = os.path.join(instance_dir, 'feed') 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 feed_entry_path in sorted(feed_entry_paths): # 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), os.path.join(instance_dir, 'pages')) + resolved_path = os.path.relpath(os.path.realpath(feed_entry_path), pages_dir) 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)}' except (OSError, ValueError, TypeError): logger.exception("error loading/rendering markdown!") diff --git a/incorporealcms/markdown.py b/incorporealcms/markdown.py index 355daa5..3999457 100644 --- a/incorporealcms/markdown.py +++ b/incorporealcms/markdown.py @@ -44,18 +44,21 @@ def instance_resource_path_to_request_path(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. Args: 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: - logger.debug("opening path '%s'", path) - with open(path, 'r') as input_file: + absolute_path = os.path.join(pages_root, path) + 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) entry = input_file.read() - logger.debug("path '%s' read", path) + logger.debug("path '%s' read", absolute_path) md = init_md() content = Markup(md.convert(entry)) # nosec B704 except (OSError, FileNotFoundError): @@ -67,17 +70,25 @@ def parse_md(path: str): 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 logger.debug("title (potentially derived): %s", page_title) return content, md, page_name, page_title, mtime -def handle_markdown_file_path(path: str) -> str: - """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) +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. + + 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 template_name = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html' @@ -92,14 +103,21 @@ def handle_markdown_file_path(path: str) -> str: description=get_meta_str(md, 'description'), image=Config.BASE_HOST + get_meta_str(md, 'image'), 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, mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'), extra_footer=extra_footer) -def generate_parent_navs(path): - """Create a series of paths/links to navigate up from the given resource path.""" +def generate_parent_navs(path, pages_root: str): + """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': # bail and return the domain name as a terminal case return [(Config.DOMAIN_NAME, '/')] @@ -124,14 +142,14 @@ def generate_parent_navs(path): # read the resource try: - with open(path, 'r') as entry_file: + with open(os.path.join(pages_root, path), 'r') as entry_file: entry = entry_file.read() _ = Markup(md.convert(entry)) # nosec B704 page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title') 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: - 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): diff --git a/incorporealcms/ssg.py b/incorporealcms/ssg.py index f6bbe0f..e45b718 100644 --- a/incorporealcms/ssg.py +++ b/incorporealcms/ssg.py @@ -86,7 +86,6 @@ class StaticSiteGenerator(object): convert_markdown: whether or not to convert Markdown files (or simply copy them) """ 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): logger.debug("starting to build against %s || %s || %s", base_dir, subdirs, files) # remove the absolute path of the directory from the base_dir @@ -113,9 +112,12 @@ class StaticSiteGenerator(object): dest_dir: the output directory to place the subdir in """ 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 - 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}'") os.symlink(src, dst, target_is_directory=True) else: @@ -137,9 +139,12 @@ class StaticSiteGenerator(object): dest_dir: the output directory to place the subdir in """ 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 - 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}'") os.symlink(src, dst, target_is_directory=False) if src.endswith('.md') and convert_markdown: @@ -151,7 +156,7 @@ class StaticSiteGenerator(object): os.symlink(second_src, second_dst, target_is_directory=False) else: - src = os.path.join(base_dir, file_) + src = os.path.join(source_dir, base_dir, file_) print(f"copying file '{src}' -> '{dst}'") shutil.copy2(src, dst) @@ -160,7 +165,7 @@ class StaticSiteGenerator(object): rendered_file = dst.removesuffix('.md') + '.html' print(f"rendering file '{src}' -> '{rendered_file}'") try: - content = handle_markdown_file_path(src) + content = handle_markdown_file_path(src, source_dir) except UnicodeDecodeError: # perhaps this isn't a markdown file at all for some reason; we # copied it above so stick with tha diff --git a/tests/functional_markdown_tests.py b/tests/functional_markdown_tests.py index 3ff53d3..9e73f64 100644 --- a/tests/functional_markdown_tests.py +++ b/tests/functional_markdown_tests.py @@ -23,13 +23,11 @@ def test_graphviz_is_rendered(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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) with open(os.path.join(tmpdir, 'test-graphviz.html'), 'r') as graphviz_output: data = graphviz_output.read() assert 'data:image/png;base64' in data - os.chdir(HERE) def test_invalid_graphviz_is_not_rendered(): @@ -37,12 +35,10 @@ def test_invalid_graphviz_is_not_rendered(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') ssg = StaticSiteGenerator(src_dir, tmpdir) - os.chdir(os.path.join(src_dir, 'broken')) with pytest.raises(ValueError): ssg.build_file_in_destination(os.path.join(HERE, 'instance', 'broken'), '', 'test-invalid-graphviz.md', tmpdir, True) - os.chdir(HERE) def test_figures_are_rendered(): @@ -50,7 +46,6 @@ def test_figures_are_rendered(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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) with open(os.path.join(tmpdir, 'figures.html'), 'r') as graphviz_output: @@ -62,7 +57,6 @@ def test_figures_are_rendered(): assert ('<figure class="left"><img alt="fancy logo" src="bss-square-no-bg.png" />' '<span></span></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(): @@ -70,7 +64,6 @@ def test_og_image(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') ssg = StaticSiteGenerator(src_dir, tmpdir) - os.chdir(os.path.join(src_dir, 'pages')) 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: @@ -83,7 +76,6 @@ def test_og_url(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') ssg = StaticSiteGenerator(src_dir, tmpdir) - os.chdir(os.path.join(src_dir, 'pages')) # 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 diff --git a/tests/instance/config.json b/tests/instance/config.json index ef2ed9c..5f471ed 100644 --- a/tests/instance/config.json +++ b/tests/instance/config.json @@ -18,6 +18,10 @@ "level": "DEBUG", "handlers": ["console"] }, + "incorporealcms.markdown": { + "level": "DEBUG", + "handlers": ["console"] + }, "incorporealcms.ssg": { "level": "DEBUG", "handlers": ["console"] diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 6a184eb..13c6312 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -8,33 +8,38 @@ from unittest.mock import patch import pytest +from incorporealcms import init_instance from incorporealcms.markdown import (generate_parent_navs, handle_markdown_file_path, instance_resource_path_to_request_path, parse_md, request_path_to_breadcrumb_display) 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(): """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(): """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(): """Test that real pages have navs to the root, their parent, and themselves.""" - assert generate_parent_navs('subdir/page.md') == [('example.org', '/'), ('subdir', '/subdir/'), - ('Page', '/subdir/page')] + assert generate_parent_navs('subdir/page.md', PAGES_DIR) == [('example.org', '/'), ('subdir', '/subdir/'), + ('Page', '/subdir/page')] def test_generate_page_navs_subdir_with_title_parsing_real_page(): """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', '/'), ('SUB!', '/subdir-with-title/'), ('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(): """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', '/'), ('/no-index-dir/', '/no-index-dir/'), ('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(): """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">'\ - 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">'\ - 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>'\ - 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>'\ - in handle_markdown_file_path('index.md') + in handle_markdown_file_path('index.md', PAGES_DIR) def test_render_with_style_overrides(): """Test that the default can be changed.""" with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'dark'): 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">'\ - 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>'\ - 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>'\ - in handle_markdown_file_path('index.md') + in handle_markdown_file_path('index.md', PAGES_DIR) def test_render_with_default_style_override(): @@ -81,23 +86,21 @@ def test_render_with_default_style_override(): 'warm': '/static/css/warm.css'}): with patch('incorporealcms.Config.DEFAULT_PAGE_STYLE', 'warm'): 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">'\ - 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">'\ - 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>'\ - 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>'\ - in handle_markdown_file_path('index.md') + in handle_markdown_file_path('index.md', PAGES_DIR) 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.""" - os.chdir(os.path.join(HERE, 'instance/', 'broken/')) with pytest.raises(NotImplementedError): - handle_markdown_file_path('redirect.md') - os.chdir(os.path.join(HERE, 'instance/', 'pages/')) + handle_markdown_file_path('redirect.md', os.path.join(INSTANCE_DIR, 'broken')) 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(): """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_title == 'title for the page - example.org' def test_parse_md_metadata_forced_no_title(): """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_title == 'example.org' def test_parse_md_metadata_no_title_so_path(): """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_title == '/subdir/ - example.org' @@ -153,10 +156,10 @@ def test_parse_md_metadata_no_title_so_path(): def test_parse_md_no_file(): """Test the direct results of parsing a markdown file.""" 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(): """Test the direct results of parsing a markdown file.""" 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) diff --git a/tests/test_ssg.py b/tests/test_ssg.py index 8f2f923..a0bee09 100644 --- a/tests/test_ssg.py +++ b/tests/test_ssg.py @@ -3,12 +3,15 @@ SPDX-FileCopyrightText: © 2025 Brian S. Stephan <bss@incorporeal.org> SPDX-License-Identifier: GPL-3.0-or-later """ +import logging import os import tempfile import incorporealcms.ssg as ssg from incorporealcms import init_instance +logger = logging.getLogger(__file__) + HERE = os.path.dirname(os.path.abspath(__file__)) instance_dir = os.path.join(HERE, 'instance') @@ -20,7 +23,6 @@ def test_file_copy(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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, True) 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: src_dir = os.path.join(HERE, 'instance') 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, False) 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: src_dir = os.path.join(HERE, 'instance') 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) # 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) @@ -57,7 +57,6 @@ def test_file_copy_symlink_of_markdown_also_has_html_symlink(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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-no-title.md', tmpdir, True) # need to copy the destination for os.path.exists to be happy with this @@ -74,7 +73,6 @@ def test_dir_copy(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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) assert os.path.exists(os.path.join(tmpdir, 'media')) assert os.path.isdir(os.path.join(tmpdir, 'media')) @@ -85,7 +83,6 @@ def test_dir_copy_symlink(): with tempfile.TemporaryDirectory() as tmpdir: src_dir = os.path.join(HERE, 'instance') 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) # 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)