diff --git a/README.md b/README.md index 6ea94d44..d535102d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ Cactus makes it easy to relatively link to pages and static assets inside your p Just use the URL you would normally use: don't forget the leading slash. +You can add extra context to your pages. Just insert it on the top of the file like this: + + name: koen + age: 29 + {% extends "base.html" %} + … + +which will be converted to a dict: `{'name': 'koen', 'age': '29'}` and added to the context. +If the first line of your document should contain a colon, +you can use the more explicit style of metadata [as used in Jekyll](http://jekyllrb.com/docs/frontmatter/): + + --- + Author: Me + Style: Great + --- + Todays motto: whatever + +Always use `---` (three dashes) to fence the metadata. ### Templates diff --git a/cactus/page.py b/cactus/page.py index 7816e6b3..12969fa1 100644 --- a/cactus/page.py +++ b/cactus/page.py @@ -71,21 +71,12 @@ def data(self): except: logger.warning("Template engine could not process page: %s", self.path) - def context(self, data=None, extra=None): + def context(self): """ The page context. """ - if extra is None: - extra = {} - context = {'__CACTUS_CURRENT_PAGE__': self,} - - page_context, data = self.parse_context(data or self.data()) - context.update(self.site.context()) - context.update(extra) - context.update(page_context) - return Context(context) def render(self): @@ -93,15 +84,8 @@ def render(self): Takes the template data with context and renders it to the final output file. """ - data = self.data() - context = self.context(data=data) - - # This is not very nice, but we already used the header context in the - # page context, so we don't need it anymore. - page_context, data = self.parse_context(data) - context, data = self.site.plugin_manager.preBuildPage( - self.site, self, context, data) + self.site, self, self.context(), self.data()) return Template(data).render(context) @@ -125,37 +109,5 @@ def build(self): self.site.plugin_manager.postBuildPage(self) - def parse_context(self, data, splitChar=':'): - """ - Values like - - name: koen - age: 29 - - will be converted in a dict: {'name': 'koen', 'age': '29'} - """ - - if not self.is_html(): - return {}, data - - values = {} - lines = data.splitlines() - if not lines: - return {}, '' - - for i, line in enumerate(lines): - - if not line: - continue - - elif splitChar in line: - line = line.split(splitChar) - values[line[0].strip()] = (splitChar.join(line[1:])).strip() - - else: - break - - return values, '\n'.join(lines[i:]) - def __repr__(self): return ''.format(self.source_path) diff --git a/cactus/plugin/builtin/page_context.py b/cactus/plugin/builtin/page_context.py new file mode 100644 index 00000000..21d40ec1 --- /dev/null +++ b/cactus/plugin/builtin/page_context.py @@ -0,0 +1,133 @@ +#coding:utf-8 +from __future__ import unicode_literals +import logging +import re + +logger = logging.getLogger(__name__) + + +class PageContextPlugin(object): + """ + A plugin to extract context variables from the top of the file + + TODO: + - read style of metadata from config + - read splitChar from config + """ + ORDER = 0 + + def preBuildPage(self, page, context, data): + """ + Update the page context with the context variables from the file + and delete them + """ + if not page.is_html() or not data: + return context, data + + lines = data.splitlines() + + # Look into the file to decide on the style of metadata + # TODO: make that configurable + if lines[0].strip() == '---': + page_context, lines = self.jekyll_style(lines, page) + else: + page_context, lines = self.simple_style(lines) + context.update(page_context) + + return context, '\n'.join(lines) + + def jekyll_style(self, lines, page): + """ + metadata as defined in http://jekyllrb.com/docs/frontmatter/ + + `lines` should not be empty + `page` is required only for the error message + """ + if lines[0].strip() != '---': # no metadata + return {}, lines + + context = {} + splitChar = ':' + for i, line in enumerate(lines[1:]): + if line.strip() == '---': # end of metadata + i += 1 + break + + try: + key, value = line.split(splitChar, 1) + except ValueError: + # splitChar not in line + logger.warning('Page context data in file %s seem to end in line %d', + page.source_path, i) + break + + context[key.strip()] = value.strip() + + return context, lines[i+1:] + + def simple_style(self, lines): + """ + Only lines at the top of the file with a colon are metadata. + No multiline metadata + + `lines` should not be empty + """ + context = {} + splitChar = ':' + + for i, line in enumerate(lines): + + if splitChar not in line: + break + + key, value = line.split(splitChar, 1) + context[key.strip()] = value.strip() + + return context, lines[i:] + + def multimarkdown_style(self, lines): + """ + metadata as defined in http://fletcher.github.io/MultiMarkdown-4/metadata + + `lines` should not be empty + """ + context = {} + splitChar = ':' + fenced = False + key = None + whitespace = re.compile(r'\s') + + if splitChar not in lines[0] and lines[0].strip() != '---': + return {}, lines # no metadata + + for i, line in enumerate(lines): + # fencing is allowed but not required in multimarkdown + if line.strip() == '---': + if i == 0: + fenced = True + continue + elif fenced: + i += 1 + break + + # a blank line triggers the beginning of the rest of the document + if not line.strip(): + # if the file starts with a blank line, nothing should be changed + if i != 0: + i += 1 + break + + # indented lines are the continuation of the previous value + # but multiline values don't have to be indented + if key and (whitespace.match(line) or not splitChar in line): + context[key] = ' '.join( + (context[key], line.lstrip())) + continue + + key, value = line.split(splitChar, 1) + + # keys are lower cased and stripped of all whitespace + key = re.sub(r'\s', '', key.lower()) + context[key] = value.strip() + + return context, lines[i:] diff --git a/cactus/site.py b/cactus/site.py index 052366a7..66364ff1 100644 --- a/cactus/site.py +++ b/cactus/site.py @@ -16,6 +16,7 @@ from cactus.plugin.builtin.cache import CacheDurationPlugin from cactus.plugin.builtin.context import ContextPlugin from cactus.plugin.builtin.ignore import IgnorePatternsPlugin +from cactus.plugin.builtin.page_context import PageContextPlugin from cactus.plugin.loader import CustomPluginsLoader, ObjectsPluginLoader from cactus.plugin.manager import PluginManager from cactus.static.external.manager import ExternalManager @@ -75,8 +76,11 @@ def __init__(self, path, config_paths=None, ui=None, [ CustomPluginsLoader(self.plugin_path), # User plugins ObjectsPluginLoader([ # Builtin plugins - ContextPlugin(), CacheDurationPlugin(), - IgnorePatternsPlugin(), PageContextCompatibilityPlugin(), + ContextPlugin(), + PageContextPlugin(), + CacheDurationPlugin(), + IgnorePatternsPlugin(), + PageContextCompatibilityPlugin(), ]) ] ) diff --git a/cactus/tests/test_context.py b/cactus/tests/test_context.py index a6c04f28..f373cb21 100644 --- a/cactus/tests/test_context.py +++ b/cactus/tests/test_context.py @@ -42,25 +42,65 @@ def test_custom_context(self): class TestCustomPageContext(SiteTestCase): """ - Test that custom context in the header of pages is feeded to a page + Test that custom context in the header of pages is fed to a page Includes the built-in site context ('CACTUS'), and custom context. """ - def setUp(self): - super(TestCustomPageContext, self).setUp() - - page = "a: 1\n\tb: Monkey\n{{ a }}\n{{ b }}" - with open(os.path.join(self.site.page_path, "test.html"), "w") as f: - f.write(page) - - self.site.build() def test_custom_context(self): """ Test that custom context is provided """ - with open(os.path.join(self.site.build_path, "test.html")) as f: - a, b = f.read().split("\n") + test_data = [ + ("a: 1\n" + "\tb: Monkey\n" + "{{ a }}\n" + "{{ b }}", - self.assertEqual(a, "1") - self.assertEqual(b, "Monkey") \ No newline at end of file + "1\n" + "Monkey"), + + ("a: 1\n" + "\n" # blank line inserted + "\tb: Monkey\n" + "{{ a }}", + + "\n" + "\tb: Monkey\n" + "1"), + + ("---\n" # fenced + " a: 1\n" + "b: Monkey\n" + "---\n" + "{{ a }}\n" + "{{ b }}", + + "1\n" + "Monkey"), + + ("---\n" # double fenced + " a: 1\n" + "b: Monkey\n" + "---\n" + "---\n" + "c: 1\n" + "---\n" + "{{ a }}\n" + "{{ b }}", + + "---\n" + "c: 1\n" + "---\n" + "1\n" + "Monkey"), + + ] + for page, expected in test_data: + with open(os.path.join(self.site.page_path, "test.html"), "w") as f: + f.write(page) + + self.site.build() + + with open(os.path.join(self.site.build_path, "test.html")) as f: + self.assertEqual(f.read(), expected) diff --git a/cactus/tests/test_page_context.py b/cactus/tests/test_page_context.py new file mode 100644 index 00000000..3d90031a --- /dev/null +++ b/cactus/tests/test_page_context.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import os +try: + import unittest2 as unittest +except ImportError: + import unittest +from testfixtures import LogCapture + +from cactus.plugin.builtin.page_context import PageContextPlugin +from cactus.tests import SiteTestCase +from cactus.page import Page + + +class TestPagecontextStyles(unittest.TestCase): + + def test_jekyll_style(self): + testdata = [ + (['---', # fenced + 'Author : Me', + 'Content: Awesome', + '---', + 'Here comes the content'], + {'Author': 'Me', 'Content': 'Awesome'}, + ['Here comes the content']), + + (['Author : Me', # no start of metadata + '---', + 'Here comes the content'], + {}, + ['Author : Me', + '---', + 'Here comes the content']), + + (['', # file starts with a blank line + '---', + 'Todays motto: whatever'], + {}, # no context data + ['', '---', 'Todays motto: whatever']), # everything preserved + + (['This file has', + 'no metadata whatsoever'], + {}, + ['This file has', 'no metadata whatsoever']), + + ] + plugin = PageContextPlugin() + class Page: + source_path = '/some/path' + page = Page() + + for i, (input_lines, + expected_context, + expected_output_lines) in enumerate(testdata): + self.assertEqual((expected_context, expected_output_lines), + plugin.jekyll_style(input_lines, page), + 'Test Data No. %d failed' % i) + + input_lines = ['---', + 'Author : Me', # no end of metadata + 'Here comes the content'] + with LogCapture() as l: + context, data = plugin.jekyll_style(input_lines, page) + self.assertEqual(len(l.records), 1) + self.assertEqual(l.records[0].levelname, 'WARNING') + self.assertEqual(l.records[0].msg, 'Page context data in file %s seem to end in line %d') + + def test_simple_style(self): + testdata = [ + (['Author : Me', + 'Content: Awesome', + '', + 'Here comes the content'], + {'Author': 'Me', 'Content': 'Awesome'}, + ['', 'Here comes the content']), + + (['', # file starts with a blank line + 'Todays motto: whatever'], + {}, # no context data + ['', 'Todays motto: whatever']), # blank line preserved + + (['This file has', + 'no metadata whatsoever'], + {}, + ['This file has', 'no metadata whatsoever']), + ] + plugin = PageContextPlugin() + + for i, (input_lines, + expected_context, + expected_output_lines) in enumerate(testdata): + self.assertEqual((expected_context, expected_output_lines), + plugin.simple_style(input_lines), + 'Test Data No. %d failed' % i) + + def test_multimarkdown_style(self): + testdata = [ + (['Author : Me', + 'Content: Awesome', + '', + 'Here comes the content'], + {'author': 'Me', 'content': 'Awesome'}, + ['Here comes the content']), + + (['---', # fenced + 'Author : Me', + 'Content: Awesome', + '---', + 'Here comes the content'], + {'author': 'Me', 'content': 'Awesome'}, + ['Here comes the content']), + + (['Multiline: in two', + ' lines', + '', + 'Here comes the content'], + {'multiline': 'in two lines'}, + ['Here comes the content']), + + (['Multiline: in two', + 'lines', # not indented + '', + 'Here comes the content'], + {'multiline': 'in two lines'}, + ['Here comes the content']), + + (['Multiline: in', + ' three', + ' lines', + '', + 'Here comes the content'], + {'multiline': 'in three lines'}, + ['Here comes the content']), + + (['Multiline: in two lines', + ' with: colon', + '', + 'Here comes the content'], + {'multiline': 'in two lines with: colon'}, + ['Here comes the content']), + + (['', # file starts with a blank line + 'Todays motto: whatever'], + {}, # no context data + ['', 'Todays motto: whatever']), # blank line preserved + + (['This file has', + 'no metadata whatsoever'], + {}, + ['This file has', 'no metadata whatsoever']), + ] + plugin = PageContextPlugin() + + for i, (input_lines, + expected_context, + expected_output_lines) in enumerate(testdata): + self.assertEqual((expected_context, expected_output_lines), + plugin.multimarkdown_style(input_lines), + 'Test Data No. %d failed' % i) + + +class TestPageTags(SiteTestCase): + def test_preBuildPage(self): + testdata = [ + ('simple.html', # filename + "name: koen\n" # input data + "age: 29\n" + "A Cactus is a spiny plant", + {}, # given context + {"name": "koen", "age": "29"}, # expected context + "A Cactus is a spiny plant"), # expected content + + ('given_context.html', + "name: koen\n" + "age: 29\n" + "A Cactus is a spiny plant", + {"subject": "biology"}, + {"subject": "biology", "name": "koen", "age": "29"}, + "A Cactus is a spiny plant"), + + ('given_context.html', + "name: koen\n" + "subject: computer\n" + "Cactus is great software", + {"subject": "biology"}, + {"subject": "computer", "name": "koen"}, + "Cactus is great software"), + + ('separating-line.html', + "name: koen\n" + "\n" + "A Cactus is a spiny plant", + {}, + {"name": "koen"}, + "\nA Cactus is a spiny plant"), + + ('jekyll.html', + "---\n" + "name: koen\n" + "---\n" + "A Cactus is a spiny plant", + {}, + {"name": "koen"}, + "A Cactus is a spiny plant"), + + ('empty.html', + "", + {}, + {}, + ""), + + ('empty-line.html', + "\n", + {}, + {}, + ""), + + ] + pcp = PageContextPlugin() + for (filename, + input_data, + given_context, + expected_context, + expected_content) in testdata: + page = Page(self.site, filename) + context, content = pcp.preBuildPage( + page, given_context, input_data) + self.assertEqual(context, expected_context, "Testcase " + filename) + self.assertEqual(content, expected_content, "Testcase " + filename) + + diff --git a/cactus/tests/test_plugins.py b/cactus/tests/test_plugins.py index e96b3f22..600ddeb2 100644 --- a/cactus/tests/test_plugins.py +++ b/cactus/tests/test_plugins.py @@ -28,7 +28,7 @@ def test_load_plugin(self): self._load_test_plugin('test.py', 'test.py') plugins = self.site.plugin_manager.plugins - self.assertEqual(1, len(plugins )) + self.assertEqual(1, len(plugins)) self.assertEqual('plugin_test', plugins[0].plugin_name) self.assertEqual(2, plugins[0].ORDER) @@ -71,4 +71,6 @@ def test_call(self): #postBuild self.assertEqual(1, len(plugin.postBuild.calls)) - self.assertEqual((self.site,), plugin.postBuild.calls[0]['args']) \ No newline at end of file + self.assertEqual((self.site,), plugin.postBuild.calls[0]['args']) + + diff --git a/setup.py b/setup.py index 9f337d47..fe9e2b74 100644 --- a/setup.py +++ b/setup.py @@ -133,7 +133,7 @@ def is_package(package_name): }, zip_safe=False, setup_requires=['nose'], - tests_require=['nose', 'mock', 'tox', 'unittest2'], + tests_require=['nose', 'mock', 'tox', 'unittest2', 'testfixtures'], classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console",