diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c84e8c5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_call: + +permissions: + contents: read + +jobs: + + tests: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Python Version + run: python --version + - name: Update Pip + run: python -m pip install --upgrade pip + - name: Install dependencies + run: pip install -r requirements.txt + - name: Install Syrinx + run: pip install . + - name: Install test dependencies + run: pip install -r tests/requirements.txt + - name: Unit tests + run: pytest diff --git a/meta/content/index.md b/meta/content/index.md index b4f9d41..5277674 100644 --- a/meta/content/index.md +++ b/meta/content/index.md @@ -25,6 +25,10 @@ Organize your content with some standard structure and syrinx will interpret it. 1. a `content/` directory with markdown file `index.md` 2. *Toml* style frontmatter: Markdown files start with a standard header or *"frontmatter"* that is surrounded by triple pluses `+++`. +3. in content directories other than root, `index.md` is optional. Leaving it out signifies that you do not want a separate page build for this branch. +4. Special frontmatter entries: + - `SequenceNumber`: Used by templates to order child items in menu's and lists. + - `Archetype`: The name of the template used to import these table data ### Templating and style diff --git a/meta/content/showcases/index.md b/meta/content/showcases/index.md deleted file mode 100644 index f7ba421..0000000 --- a/meta/content/showcases/index.md +++ /dev/null @@ -1,2 +0,0 @@ -+++ -+++ \ No newline at end of file diff --git a/meta/dist/index.html b/meta/dist/index.html index 454279e..e182073 100644 --- a/meta/dist/index.html +++ b/meta/dist/index.html @@ -36,11 +36,17 @@

Content

  1. a content/ directory with markdown file index.md
  2. Toml style frontmatter: Markdown files start with a standard header or "frontmatter" that is surrounded by triple pluses +++.
  3. +
  4. in content directories other than root, index.md is optional. Leaving it out signifies that you do not want a separate page build for this branch.
  5. +
  6. Special frontmatter entries: +

Templating and style

  1. Templates are written in jinja2 and go into the theme/templates/ directory.
  2. -
  3. The default template used is page.jinja2. If a template is found matching the name of the node (foo.jinja2), or root.jinja2 for the top-most page, this takes precedence.
  4. +
  5. The default template used is page.jinja2. If a template is found matching the name of the node (e.g. foo.jinja2), or root.jinja2 for the top-most page, this takes precedence.
  6. CSS, images and other assets go into theme/assets/.

Table Data

diff --git a/pyproject.toml b/pyproject.toml index eb2315d..eb472e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syrinx" -version = "0.0.5" +version = "0.0.6" authors = [ { name="Jasper van den Bosch"}, ] diff --git a/requirements.txt b/requirements.txt index 82201d4..edd298d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ jinja2 markdown pandas -pytest -lxml -parameterized \ No newline at end of file +lxml \ No newline at end of file diff --git a/syrinx/build.py b/syrinx/build.py index 0403db7..f1910f9 100644 --- a/syrinx/build.py +++ b/syrinx/build.py @@ -1,17 +1,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, Optional from os.path import isdir, join, isfile import shutil, os from jinja2 import Environment, FileSystemLoader, select_autoescape +from syrinx.exceptions import ThemeError if TYPE_CHECKING: from syrinx.read import ContentNode -def choose_template_file(node: ContentNode, isfile: Callable[[str], bool], dir: str) -> str: +def choose_template_file( + node: ContentNode, + isfile: Callable[[str], bool], + dir: str + ) -> str: name = node.name or 'root' - if isfile(join(dir, f'{name}.jinja2')): - return f'{name}.jinja2' - return 'page.jinja2' + for tem_name in (name, 'page'): + if isfile(join(dir, f'{tem_name}.jinja2')): + return f'{tem_name}.jinja2' + else: + raise ThemeError(f'Missing template for "{node.name}"') def dir_exists_not_empty(path: str) -> bool: @@ -21,6 +28,28 @@ def dir_exists_not_empty(path: str) -> bool: return False +def build_node( + node: ContentNode, + root: ContentNode, + parent_path: str, + template_dir: str, + env: Environment + ): + """Recursive function to render page, then move on to children + """ + if node.buildPage: + fname_tem = choose_template_file(node, isfile, template_dir) + page_template = env.get_template(fname_tem) + html = page_template.render(index=node, root=root) + node_path = join(parent_path, node.name) + os.makedirs(node_path, exist_ok=True) + out_fpath = join(node_path, 'index.html') + with open(out_fpath, 'w') as fhandle: + fhandle.write(html) + for child in node.branches: + build_node(child, root, node_path, template_dir, env) + + def build(root: ContentNode, root_dir: str): assert isdir(root_dir) @@ -38,20 +67,7 @@ def build(root: ContentNode, root_dir: str): shutil.rmtree(dist_dir) os.makedirs(dist_dir, exist_ok=True) - def build_node(node: ContentNode, root: ContentNode, parent_path: str): - fname_tem = choose_template_file(node, isfile, template_dir) - page_template = env.get_template(fname_tem) - html = page_template.render(index=node, root=root) - node_path = join(parent_path, node.name) - os.makedirs(node_path, exist_ok=True) - out_fpath = join(node_path, 'index.html') - with open(out_fpath, 'w') as fhandle: - fhandle.write(html) - for child in node.branches: - build_node(child, root, node_path) - - - build_node(root, root, dist_dir) + build_node(root, root, dist_dir, template_dir, env) dist_assets_dir = join(dist_dir, 'assets') diff --git a/syrinx/exceptions.py b/syrinx/exceptions.py new file mode 100644 index 0000000..43b1f19 --- /dev/null +++ b/syrinx/exceptions.py @@ -0,0 +1,7 @@ + +class ContentError(Exception): + pass + + +class ThemeError(Exception): + pass \ No newline at end of file diff --git a/syrinx/preprocess.py b/syrinx/preprocess.py index 0c47307..c985acd 100644 --- a/syrinx/preprocess.py +++ b/syrinx/preprocess.py @@ -8,7 +8,7 @@ """ from __future__ import annotations from typing import TYPE_CHECKING, Dict -from os.path import join, isdir, abspath, basename +from os.path import join, isdir, basename from os import makedirs from glob import glob from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -20,7 +20,6 @@ def preprocess(root_dir: str) -> None: - assert isdir(root_dir) archetype_files = glob(join(root_dir, 'archetypes', '*.md')) @@ -58,6 +57,8 @@ def preprocess(root_dir: str) -> None: if 'SequenceNumber' not in df.columns: df['SequenceNumber'] = list(range(len(df))) + df['Archetype'] = [archetype_name]*len(df) + for label, row in df.iterrows(): output = archetype.render(dict(row) | {df.index.name: label}) with open(join(collection_dir, f'{label}.md'), 'w') as fhandle: diff --git a/syrinx/read.py b/syrinx/read.py index 90f7eab..5f022d2 100644 --- a/syrinx/read.py +++ b/syrinx/read.py @@ -1,9 +1,11 @@ from __future__ import annotations -from typing import List, Dict +from typing import List, Dict, Tuple from os.path import dirname, basename, join -import os +from os import walk import tomllib from markdown import markdown +from sys import maxsize as SYS_MAX_SIZE +from syrinx.exceptions import ContentError """ This section is just about reading and interpreting the content @@ -16,6 +18,13 @@ class ContentNode: content_html: str front: Dict[str, str] sequenceNumber: int + buildPage: bool + + def __init__(self): + self.buildPage = False + self.leaves = [] + self.branches = [] + self.sequenceNumber = SYS_MAX_SIZE def reorder_children(node: ContentNode): node.leaves = sorted(node.leaves, key=lambda n: (n.sequenceNumber, n.name)) @@ -24,29 +33,40 @@ def reorder_children(node: ContentNode): reorder_children(child) +def read_file(fpath: str) -> Tuple[Dict, str]: + with open(fpath) as fhandle: + lines = fhandle.readlines() + markers = [l for (l, line) in enumerate(lines) if line.strip() == '+++'] + assert len(markers) == 2 + fm_string = ''.join(lines[1:markers[1]]) + fm_dict = tomllib.loads(fm_string) + md_content = ''.join(lines[markers[1]+1:]) + return fm_dict, md_content + + def read(root_dir: str) -> ContentNode: - content_dir = join(root_dir, 'content') - tree: Dict[str, ContentNode] = dict() root = ContentNode() root.name = '' - for (dirpath, dirnames, fnames) in os.walk(content_dir): - - ## ideally process the index page first (not sure if this is necessary?) - index_index = fnames.index('index.md') - fnames.insert(0, fnames.pop(index_index)) + for (dirpath, _, fnames) in walk(content_dir): indexNode = ContentNode() indexNode.name = basename(dirpath) if dirpath == content_dir: indexNode = root + if 'index.md' not in fnames: + raise ContentError('root index file missing') else: parent = tree[dirname(dirpath)] parent.branches.append(indexNode) tree[dirpath] = indexNode + + ## ideally process the index page first (not sure if this is necessary?) + if 'index.md' in fnames: + fnames.insert(0, fnames.pop(fnames.index('index.md'))) for fname in fnames: fparts = fname.split('.') ext = fparts[-1] @@ -54,16 +74,7 @@ def read(root_dir: str) -> ContentNode: continue name = fparts[0] - in_fpath = join(dirpath, fname) - - with open(in_fpath) as fhandle: - lines = fhandle.readlines() - - markers = [l for (l, line) in enumerate(lines) if line.strip() == '+++'] - assert len(markers) == 2 - fm_string = ''.join(lines[1:markers[1]]) - fm_dict = tomllib.loads(fm_string) - md_content = ''.join(lines[markers[1]+1:]) + fm_dict, md_content = read_file(join(dirpath, fname)) if name == 'index': node = indexNode @@ -72,11 +83,11 @@ def read(root_dir: str) -> ContentNode: node.name = name indexNode.leaves.append(node) - node.leaves = [] - node.branches = [] node.front = fm_dict node.content_html = markdown(md_content) - node.sequenceNumber = int(fm_dict.get('SequenceNumber', '99999')) + if 'SequenceNumber' in fm_dict: + node.sequenceNumber = fm_dict['SequenceNumber'] + node.buildPage = True reorder_children(root) diff --git a/tests/build.py b/tests/build.py index c42f63f..81a00d7 100644 --- a/tests/build.py +++ b/tests/build.py @@ -33,3 +33,15 @@ def test_choose_template_file_other(self, foo_exists: bool, expected: str): choose_template_file(node, isfile, '/t'), expected ) + + def test_choose_template_file_none(self): + """If no template is available, throw exception + """ + from syrinx.build import choose_template_file + from syrinx.exceptions import ThemeError + node = Mock() + isfile = Mock() + node.name = 'foo' + isfile.side_effect = lambda p: False + with self.assertRaisesRegex(ThemeError, 'Missing template for "foo"'): + choose_template_file(node, isfile, '/t') diff --git a/tests/build_node.py b/tests/build_node.py new file mode 100644 index 0000000..68800fc --- /dev/null +++ b/tests/build_node.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock, patch + +class BuildNodeTests(TestCase): + + @patch('syrinx.build.isfile') + @patch('syrinx.build.open') + def test_follows_buildPage(self, open, isfile): + from syrinx.build import build_node + node = Mock() + root = Mock() + env = Mock() + isfile.return_value = True + node.name = 'foo' + node.branches = [] + node.buildPage = False + build_node(node, root, '', '', env) + self.assertFalse(env.get_template().render.called) + node.buildPage = True + build_node(node, root, '', '', env) + self.assertTrue(env.get_template().render.called) diff --git a/tests/read.py b/tests/read.py new file mode 100644 index 0000000..ee299a0 --- /dev/null +++ b/tests/read.py @@ -0,0 +1,44 @@ +from unittest import TestCase +from unittest.mock import patch + + +class ReadTests(TestCase): + + @patch('syrinx.read.walk') + @patch('syrinx.read.read_file') + def test_read_BuildPage_branch_with_index(self, read_file, walk): + """If index.md present on branch then set "BuildPage" to true + """ + read_file.return_value = dict(), '' + walk.return_value = [ + ('/pth/content', None, ['index.md']), + ('/pth/content/lorem', None, ['ipsum.md', 'index.md']), + ] + from syrinx.read import read + root = read('/pth') + self.assertTrue(root.branches[0].buildPage) + + @patch('syrinx.read.walk') + @patch('syrinx.read.read_file') + def test_read_BuildPage_branch_without_index(self, read_file, walk): + """If index.md absent on branch then set "BuildPage" to false + """ + read_file.return_value = dict(), '' + walk.return_value = [ + ('/pth/content', None, ['index.md']), + ('/pth/content/foo', None, ['bar.md']), + ] + from syrinx.read import read + root = read('/pth') + self.assertFalse(root.branches[0].buildPage) + + @patch('syrinx.read.walk') + def test_read_Fail_if_index_missing(self, walk): + """The root index.md is not optional, + raise an exception if it's missing. + """ + walk.return_value = [('/pth/content', None, ['other.md'])] + from syrinx.read import read + from syrinx.exceptions import ContentError + with self.assertRaisesRegex(ContentError, 'root index file missing'): + read('/pth') diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..e2977f4 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest +parameterized \ No newline at end of file