├── combine ├── checks │ ├── __init__.py │ ├── base.py │ ├── favicon.py │ ├── img_alt.py │ ├── duplicate_id.py │ ├── meta.py │ ├── file_size.py │ ├── mixed_content.py │ ├── issues.py │ ├── title.py │ ├── links.py │ └── open_graph.py ├── exceptions.py ├── base_content │ ├── markdown.template.html │ ├── redirect.template.html │ └── error.template.html ├── __init__.py ├── files │ ├── utils.py │ ├── keep.py │ ├── ignored.py │ ├── template.py │ ├── redirect.py │ ├── __init__.py │ ├── error.py │ ├── markdown.py │ ├── core.py │ └── html.py ├── logger.py ├── jinja │ ├── __init__.py │ ├── exceptions.py │ ├── include_raw.py │ ├── urls.py │ ├── references.py │ ├── markdown.py │ └── code.py ├── config.py ├── core.py ├── cli.py └── dev.py ├── tests ├── site │ ├── .gitignore │ ├── content │ │ ├── _redirects.keep │ │ ├── markdown.md │ │ ├── pricing.html │ │ ├── index.html │ │ └── base.template.html │ ├── output_expected │ │ ├── _redirects │ │ ├── index.html │ │ ├── pricing │ │ │ └── index.html │ │ └── markdown │ │ │ └── index.html │ ├── data.json │ └── combine.yml ├── checks │ ├── snapshots │ │ ├── __init__.py │ │ ├── snap_test_img_alt.py │ │ ├── snap_test_duplicate_id.py │ │ ├── snap_test_title.py │ │ ├── snap_test_meta.py │ │ ├── snap_test_mixed_content.py │ │ └── snap_test_open_graph.py │ ├── test_img_alt.py │ ├── test_duplicate_id.py │ ├── test_meta.py │ ├── test_mixed_content.py │ ├── test_title.py │ └── test_open_graph.py └── test_build.py ├── docs ├── .gitignore ├── content │ ├── assets │ │ ├── img │ │ │ ├── open-graph.png │ │ │ └── repository-open-graph-template.png │ │ └── _tailwind.css │ ├── partials.md │ ├── 404-files.md │ ├── config │ │ ├── output-path.md │ │ ├── content-paths.md │ │ ├── variables.md │ │ └── steps.md │ ├── ignore.md │ ├── internal-links.md │ ├── themes.md │ ├── redirects.md │ ├── index.md │ ├── code.md │ ├── deploy.md │ ├── build.md │ ├── getting-started.md │ ├── absolute-urls.md │ ├── templates.md │ ├── markdown.md │ ├── jinja.md │ ├── base.template.html │ ├── variables.md │ ├── checks.md │ └── _sidebar.html ├── netlify.toml ├── tailwind.config.js └── combine.yml ├── scripts ├── format ├── docs ├── test ├── pre-commit ├── install └── mypy ├── .gitmodules ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── nextrelease.yml ├── LICENSE ├── README.md └── pyproject.toml /combine/checks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/site/.gitignore: -------------------------------------------------------------------------------- 1 | /output 2 | -------------------------------------------------------------------------------- /tests/checks/snapshots/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/site/content/_redirects.keep: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /tests/site/output_expected/_redirects: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /tests/site/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /output 2 | /node_modules 3 | /.cache 4 | -------------------------------------------------------------------------------- /combine/exceptions.py: -------------------------------------------------------------------------------- 1 | class BuildError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | poetry run black combine "$@" 3 | -------------------------------------------------------------------------------- /scripts/docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd docs 3 | poetry run combine work 4 | cd .. 5 | -------------------------------------------------------------------------------- /tests/site/combine.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | json_data: 3 | from_file: data.json 4 | -------------------------------------------------------------------------------- /tests/site/content/markdown.md: -------------------------------------------------------------------------------- 1 | # Markdown 2 | 3 | ```yaml 4 | yaml: example 5 | ``` 6 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | poetry run pytest tests $@ 3 | poetry run combine --help 4 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | ./scripts/format --check 3 | ./scripts/mypy 4 | ./scripts/test 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/theme"] 2 | path = docs/theme 3 | url = https://github.com/dropseed/dropseed-docs-theme 4 | -------------------------------------------------------------------------------- /tests/site/content/pricing.html: -------------------------------------------------------------------------------- 1 | {% extends "base.template.html" %} 2 | 3 | {% block content %}Pricing{% endblock %} 4 | -------------------------------------------------------------------------------- /docs/content/assets/img/open-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/combine/HEAD/docs/content/assets/img/open-graph.png -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | POETRY_VIRTUALENVS_IN_PROJECT=true poetry install 3 | cd docs/theme 4 | ./scripts/install 5 | cd .. 6 | -------------------------------------------------------------------------------- /tests/site/content/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.template.html" %} 2 | 3 | {% block content %}Index{{ json_data.foo }}{% endblock %} 4 | -------------------------------------------------------------------------------- /combine/checks/base.py: -------------------------------------------------------------------------------- 1 | from .issues import Issues 2 | 3 | 4 | class Check: 5 | def run(self) -> Issues: 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /docs/content/assets/img/repository-open-graph-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/combine/HEAD/docs/content/assets/img/repository-open-graph-template.png -------------------------------------------------------------------------------- /combine/base_content/markdown.template.html: -------------------------------------------------------------------------------- 1 | {% extends "base.template.html" %} 2 | 3 | {% block content %} 4 | {% markdown %} 5 | {{ content }} 6 | {% endmarkdown %} 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /combine/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from .core import Combine 4 | 5 | 6 | __author__ = "Dropseed" 7 | __email__ = "python@dropseed.dev" 8 | __version__ = importlib.metadata.version("combine") 9 | -------------------------------------------------------------------------------- /combine/files/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def create_parent_directory(path: str) -> None: 5 | path_dir = os.path.dirname(path) 6 | if not os.path.exists(path_dir): 7 | os.makedirs(path_dir) 8 | -------------------------------------------------------------------------------- /combine/files/keep.py: -------------------------------------------------------------------------------- 1 | from .core import File 2 | 3 | 4 | class KeepFile(File): 5 | def _get_output_relative_path(self) -> str: 6 | # Remove the .keep extension 7 | return super()._get_output_relative_path()[:-5] 8 | -------------------------------------------------------------------------------- /combine/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logger = logging.getLogger(__file__) 5 | 6 | if not logger.hasHandlers(): 7 | # AWS sets a handler automatically, so this helps local 8 | logger.addHandler(logging.StreamHandler()) 9 | -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "output" 3 | command = """\ 4 | pip3 install .. && \ 5 | npm install -g yarn && \ 6 | cd .. && \ 7 | cd docs/theme && \ 8 | ./scripts/install && \ 9 | cd ../ && \ 10 | combine build \ 11 | """ 12 | -------------------------------------------------------------------------------- /tests/site/output_expected/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | Indexbar 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/site/output_expected/pricing/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | Pricing 10 | 11 | -------------------------------------------------------------------------------- /tests/site/content/base.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | {% block content %}{% endblock %} 10 | 11 | 12 | -------------------------------------------------------------------------------- /combine/files/ignored.py: -------------------------------------------------------------------------------- 1 | from .core import File 2 | import jinja2 3 | 4 | 5 | class IgnoredFile(File): 6 | def _get_output_relative_path(self) -> str: 7 | return "" 8 | 9 | def _render_to_output( 10 | self, output_path: str, jinja_environment: jinja2.Environment 11 | ) -> str: 12 | return "" 13 | -------------------------------------------------------------------------------- /scripts/mypy: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | poetry run mypy combine \ 3 | --ignore-missing-imports \ 4 | --warn-unreachable \ 5 | --warn-redundant-casts \ 6 | --warn-unused-ignores \ 7 | --disallow-untyped-defs \ 8 | --disallow-incomplete-defs \ 9 | --no-incremental \ 10 | --html-report ./.reports/mypy \ 11 | --txt-report ./.reports/mypy 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.venv 2 | /.vscode 3 | /.reports 4 | 5 | .DS_Store 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | -------------------------------------------------------------------------------- /combine/jinja/__init__.py: -------------------------------------------------------------------------------- 1 | from .code import CodeHighlightExtension 2 | from .markdown import MarkdownExtension, markdown_filter 3 | from .include_raw import IncludeRawExtension 4 | from .urls import absolute_url 5 | 6 | 7 | default_extensions = [CodeHighlightExtension, MarkdownExtension, IncludeRawExtension] 8 | default_filters = { 9 | "absolute_url": absolute_url, 10 | "markdown": markdown_filter, 11 | } 12 | -------------------------------------------------------------------------------- /tests/checks/snapshots/snap_test_img_alt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # snapshottest: v1 - https://goo.gl/zC4yUc 3 | from __future__ import unicode_literals 4 | 5 | from snapshottest import Snapshot 6 | 7 | 8 | snapshots = Snapshot() 9 | 10 | snapshots['test_img_alt_check 1'] = [ 11 | { 12 | 'context': { 13 | 'element': '' 14 | }, 15 | 'type': 'image-alt-missing' 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /combine/base_content/redirect.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redirecting... 6 | 7 | 8 | 9 | 10 |

Redirecting...

11 | Click here if you are not redirected. 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /combine/files/template.py: -------------------------------------------------------------------------------- 1 | import jinja2 2 | from ..jinja.references import get_references_in_path 3 | from .ignored import IgnoredFile 4 | 5 | 6 | class TemplateFile(IgnoredFile): 7 | def load(self, jinja_environment: jinja2.Environment) -> None: 8 | self.references = get_references_in_path(self.path, jinja_environment) 9 | 10 | def _render_to_output( 11 | self, output_path: str, jinja_environment: jinja2.Environment 12 | ) -> str: 13 | return "" 14 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const docsTheme = require("./theme/tailwind.config.js") 2 | 3 | docsTheme.theme.extend.colors = { 4 | "d-brown-100": "#fbf9f8", 5 | "d-brown-200": "#e1cdc0", 6 | "d-brown-300": "#c7b5a8", 7 | "d-brown-400": "#ad9c91", 8 | "d-brown-500": "#948479", 9 | "d-brown-600": "#7a6b61", 10 | "d-brown-700": "#60534a", 11 | "d-brown-800": "#463a32", 12 | "d-brown-900": "#252525", 13 | "d-green": "#6b8f71", 14 | "d-red": "#e15634", 15 | } 16 | 17 | module.exports = docsTheme 18 | -------------------------------------------------------------------------------- /tests/checks/test_img_alt.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | from combine.checks.img_alt import ImgAltCheck 4 | 5 | 6 | def test_img_alt_check(snapshot): 7 | content = """ 8 | 9 | 10 | 11 | 12 | 13 | 14 | test 15 | 16 | """ 17 | check = ImgAltCheck(BeautifulSoup(content, "html.parser")) 18 | issues = check.run() 19 | snapshot.assert_match(issues.as_data()) 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: {} 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - run: pipx install poetry 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | cache: poetry 18 | - run: poetry install 19 | - run: ./scripts/mypy 20 | - run: ./scripts/test 21 | -------------------------------------------------------------------------------- /docs/content/partials.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Partials 3 | description: Include reusable pieces of content across your templates. 4 | --- 5 | 6 | # Partials 7 | 8 | A useful convention for Combine sites is to create "partials". 9 | These are simply snippets of HTML (saved as [ignored files](/ignore/)) that you can `{% include %}` in multiple places across your site. 10 | 11 | ```html+jinja 12 | 13 |

A page heading

14 | 15 |

...

16 | 17 | {% include "partials/_help_footer.html" %} 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/content/404-files.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 404 files 3 | description: Create 404 templates that your hosting provider can find. 4 | --- 5 | 6 | # 404 files 7 | 8 | Most hosting providers have an option to specify an error or 404 template. 9 | 10 | Since Combine generates pretty urls automatically (which would move `content/404.html` to `output/404/index.html`), 11 | there is an optional `.keep.html` extension that keeps files exactly where you put them. 12 | 13 | For example, `content/404.keep.html` would be output as `output/404.html`. 14 | -------------------------------------------------------------------------------- /tests/site/output_expected/markdown/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 |

Markdown

11 |
yaml: example
12 | 
13 | 14 | 15 | -------------------------------------------------------------------------------- /combine/jinja/exceptions.py: -------------------------------------------------------------------------------- 1 | class MissingVariableError(Exception): 2 | def __init__(self, name: str) -> None: 3 | self.name = name 4 | self.message = f'The required variable "{self.name}" is missing' 5 | super().__init__(self.message) 6 | 7 | 8 | class ReservedVariableError(Exception): 9 | def __init__(self, name: str) -> None: 10 | self.name = name 11 | self.message = ( 12 | f'The variable"{self.name}" is reserved and should only be set by combine' 13 | ) 14 | super().__init__(self.message) 15 | -------------------------------------------------------------------------------- /tests/checks/test_duplicate_id.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | from combine.checks.duplicate_id import DuplicateIDCheck 4 | 5 | 6 | def test_duplicate_id_check(snapshot): 7 | content = """ 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | """ 17 | check = DuplicateIDCheck(BeautifulSoup(content, "html.parser")) 18 | issues = check.run() 19 | snapshot.assert_match(issues.as_data()) 20 | -------------------------------------------------------------------------------- /tests/checks/snapshots/snap_test_duplicate_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # snapshottest: v1 - https://goo.gl/zC4yUc 3 | from __future__ import unicode_literals 4 | 5 | from snapshottest import Snapshot 6 | 7 | 8 | snapshots = Snapshot() 9 | 10 | snapshots['test_duplicate_id_check 1'] = [ 11 | { 12 | 'context': { 13 | 'elements': [ 14 | '', 15 | '
' 16 | ], 17 | 'id': 'foo' 18 | }, 19 | 'type': 'duplicate-id' 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /tests/checks/snapshots/snap_test_title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # snapshottest: v1 - https://goo.gl/zC4yUc 3 | from __future__ import unicode_literals 4 | 5 | from snapshottest import Snapshot 6 | 7 | 8 | snapshots = Snapshot() 9 | 10 | snapshots['test_title_empty_check 1'] = [ 11 | { 12 | 'context': { 13 | }, 14 | 'type': 'title-empty' 15 | } 16 | ] 17 | 18 | snapshots['test_title_missing_check 1'] = [ 19 | { 20 | 'context': { 21 | }, 22 | 'type': 'title-missing' 23 | } 24 | ] 25 | 26 | snapshots['test_title_ok_check 1'] = [ 27 | ] 28 | -------------------------------------------------------------------------------- /combine/checks/favicon.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import Check 4 | from .issues import Issues, Issue 5 | 6 | 7 | class FaviconCheck(Check): 8 | def __init__(self, site_dir: str) -> None: 9 | self.site_dir = site_dir 10 | 11 | def run(self) -> Issues: 12 | issues = Issues() 13 | 14 | if not os.path.exists(os.path.join(self.site_dir, "favicon.ico")): 15 | issues.append( 16 | Issue( 17 | type="favicon-missing", 18 | description="Your site should have a Favicon at /favicon.ico.", 19 | ) 20 | ) 21 | 22 | return issues 23 | -------------------------------------------------------------------------------- /docs/content/config/output-path.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: combine.yml output_path 3 | description: Choose which directory your static site is output to. 4 | --- 5 | 6 | # Output path 7 | 8 | The `output_path` can change where your site is created. 9 | 10 | By default, Combine will put your site in the `output` directory: 11 | 12 | ```yaml 13 | # combine.yml 14 | output_path: output 15 | ``` 16 | 17 | This can be renamed if you have a conflict or are transitioning from another convention: 18 | 19 | ```yaml 20 | # combine.yml 21 | output_path: public 22 | ``` 23 | 24 | Or can be redirected out of your repo entirely: 25 | 26 | ```yaml 27 | # combine.yml 28 | output_path: /var/www/mysite 29 | ``` 30 | -------------------------------------------------------------------------------- /tests/checks/snapshots/snap_test_meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # snapshottest: v1 - https://goo.gl/zC4yUc 3 | from __future__ import unicode_literals 4 | 5 | from snapshottest import Snapshot 6 | 7 | 8 | snapshots = Snapshot() 9 | 10 | snapshots['test_meta_description_empty_check 1'] = [ 11 | { 12 | 'context': { 13 | 'element': '' 14 | }, 15 | 'type': 'meta-description-empty' 16 | } 17 | ] 18 | 19 | snapshots['test_meta_description_length_check 1'] = [ 20 | { 21 | 'context': { 22 | 'description': 'Whoops', 23 | 'length': 6 24 | }, 25 | 'type': 'meta-description-length' 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /docs/content/ignore.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Ignored files 3 | description: Prevent templates and other sources files from ending up on your live site. 4 | --- 5 | 6 | # Ignored files 7 | 8 | Because Combine is used to generate a static site, 9 | you will typically want to keep templates or other source files out of your final build. 10 | 11 | There are three ways to keep files out of your generated site. 12 | 13 | 1. Use `{name}.template.{extension}`, like we do for `base.template.html` and other [templates](/templates/). 14 | 1. Start the file name with an `_`, such as `_main.css`. 15 | This is often combined with [build steps](/build/) for source files. 16 | 1. Start the file name with a `.`, like hidden files on your OS. 17 | -------------------------------------------------------------------------------- /tests/test_build.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | 4 | from combine import Combine 5 | 6 | 7 | def test_combine_build(): 8 | site_dir = os.path.join(os.path.dirname(__file__), "site") 9 | 10 | # Pretend we are in tests/site 11 | os.chdir(site_dir) 12 | 13 | combine = Combine(config_path="combine.yml") 14 | combine.build() 15 | 16 | site_output_dir = os.path.join(site_dir, "output") 17 | site_output_expected_dir = os.path.join(site_dir, "output_expected") 18 | 19 | res = subprocess.run(["diff", "-w", "-r", site_output_dir, site_output_expected_dir], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 20 | assert res.stdout.decode("utf-8") == "" 21 | assert res.returncode == 0 22 | -------------------------------------------------------------------------------- /combine/files/redirect.py: -------------------------------------------------------------------------------- 1 | import jinja2 2 | import os 3 | 4 | from .html import HTMLFile 5 | from .utils import create_parent_directory 6 | 7 | 8 | class RedirectFile(HTMLFile): 9 | def _render_to_output( 10 | self, output_path: str, jinja_environment: jinja2.Environment 11 | ) -> str: 12 | target_path = os.path.join(output_path, self.output_relative_path) 13 | create_parent_directory(target_path) 14 | 15 | redirect_to = open(self.path, "r").read().strip() 16 | 17 | template = jinja_environment.get_template("redirect.template.html") 18 | with open(target_path, "w+") as f: 19 | f.write(template.render(redirect_url=redirect_to)) 20 | 21 | return target_path 22 | -------------------------------------------------------------------------------- /docs/content/internal-links.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Internal links 3 | description: Link to pages using the predictable output from Combine. 4 | --- 5 | 6 | # Internal links 7 | 8 | The easiest way to link to pages and assets is to use relative paths. 9 | These will work in both development and production, 10 | and should be very predictable based on the names of your directories and files. 11 | 12 | For example: 13 | 14 | ```html+jinja 15 | 16 | 17 | Pricing 18 | ``` 19 | 20 | The same is true for images, CSS, etc. 21 | 22 | In some cases, like open graph tags, you are *required* to use an [absolute URL](/absolute-urls/) which gets more complicated. 23 | -------------------------------------------------------------------------------- /combine/jinja/include_raw.py: -------------------------------------------------------------------------------- 1 | from jinja2 import nodes, BaseLoader 2 | from jinja2.parser import Parser 3 | from jinja2.ext import Extension 4 | from markupsafe import Markup 5 | 6 | 7 | class IncludeRawExtension(Extension): 8 | tags = {"include_raw"} 9 | 10 | def parse(self, parser: Parser) -> nodes.Node: 11 | lineno = parser.stream.expect("name:include_raw").lineno 12 | template = parser.parse_expression() 13 | result = self.call_method("_render", [template], lineno=lineno) 14 | return nodes.Output([result], lineno=lineno) 15 | 16 | def _render(self, filename: str) -> Markup: 17 | source = self.environment.loader.get_source(self.environment, filename) # type: ignore 18 | return Markup(source[0]) 19 | -------------------------------------------------------------------------------- /docs/content/config/content-paths.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: combine.yml content_paths 3 | description: Incorporate multiple content directories (or themes) into your static site. 4 | --- 5 | 6 | # Content paths 7 | 8 | Combine merges multiple `content_paths` together before rendering your final site. 9 | You will rarely need to change this behavior. 10 | 11 | By default, this includes a set of templates defined by Combine itself + your `content`: 12 | 13 | ```yaml 14 | # combine.yml 15 | content_paths: 16 | - "content" 17 | - "/base_content" 18 | ``` 19 | 20 | If a [`theme`](/themes/) is present, 21 | that will be included too: 22 | 23 | ```yaml 24 | # combine.yml 25 | content_paths: 26 | - "content" 27 | - "theme/content" 28 | - "/base_content" 29 | ``` 30 | -------------------------------------------------------------------------------- /.github/workflows/nextrelease.yml: -------------------------------------------------------------------------------- 1 | name: nextrelease 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | types: [labeled, unlabeled, edited, synchronize] 8 | 9 | jobs: 10 | sync: 11 | if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' && github.head_ref == 'nextrelease' }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: dropseed/nextrelease@v2 15 | env: 16 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.DROPSEED_PYPI_TOKEN }} 17 | with: 18 | prepare_cmd: | 19 | sed -i -e "s/version = \"[^\"]*\"$/version = \"$VERSION\"/g" pyproject.toml 20 | publish_cmd: | 21 | pip3 install -U pip poetry && poetry publish --build --no-interaction 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | tag_prefix: "" 24 | -------------------------------------------------------------------------------- /tests/checks/snapshots/snap_test_mixed_content.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # snapshottest: v1 - https://goo.gl/zC4yUc 3 | from __future__ import unicode_literals 4 | 5 | from snapshottest import Snapshot 6 | 7 | 8 | snapshots = Snapshot() 9 | 10 | snapshots['test_duplicate_id_check 1'] = [ 11 | { 12 | 'context': { 13 | 'element': '' 14 | }, 15 | 'type': 'https-mixed-content' 16 | }, 17 | { 18 | 'context': { 19 | 'element': '' 20 | }, 21 | 'type': 'https-mixed-content' 22 | }, 23 | { 24 | 'context': { 25 | 'element': '''''' 27 | }, 28 | 'type': 'https-mixed-content' 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /combine/checks/img_alt.py: -------------------------------------------------------------------------------- 1 | import bs4 2 | from .base import Check 3 | from .issues import Issues, Issue 4 | 5 | 6 | class ImgAltCheck(Check): 7 | def __init__(self, html_soup: bs4.BeautifulSoup) -> None: 8 | self.html_soup = html_soup 9 | 10 | def run(self) -> Issues: 11 | issues = Issues() 12 | 13 | for img in self.html_soup.findAll("img"): 14 | alt = img.get("alt") 15 | if alt is None: 16 | issues.append( 17 | Issue( 18 | type="image-alt-missing", 19 | description="All tags should have alt text describing the image, or be set to an empty string (`" 20 | "`)", 21 | context={"element": str(img)}, 22 | ) 23 | ) 24 | 25 | return issues 26 | -------------------------------------------------------------------------------- /docs/combine.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - run: "./theme/node_modules/.bin/pitchfork index output -c .content" 3 | - run: "./theme/node_modules/.bin/parcel build theme/content/assets/_app.js --out-dir output/assets --out-file app.js" 4 | watch: "./theme/node_modules/.bin/parcel watch theme/content/assets/_app.js --out-dir output/assets --out-file app.js" 5 | - run: "./theme/node_modules/.bin/tailwind -i ./content/assets/_tailwind.css -o ./output/assets/tailwind.css" 6 | watch: "./theme/node_modules/.bin/tailwind -i ./content/assets/_tailwind.css -o ./output/assets/tailwind.css --watch" 7 | 8 | variables: 9 | name: Combine 10 | base_url: 11 | default: "https://combine.dropseed.dev" 12 | from_env: URL # netlify 13 | version: 14 | default: "\"latest\"" 15 | from_env: COMMIT_REF 16 | google_tag_manager_id: 17 | from_env: GOOGLE_TAG_MANAGER_ID 18 | -------------------------------------------------------------------------------- /tests/checks/test_meta.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | from combine.checks.meta import MetaDescriptionCheck 4 | 5 | 6 | def test_meta_description_empty_check(snapshot): 7 | content = """ 8 | 9 | 10 | 11 | 12 | 13 | 14 | """ 15 | check = MetaDescriptionCheck(BeautifulSoup(content, "html.parser")) 16 | issues = check.run() 17 | snapshot.assert_match(issues.as_data()) 18 | 19 | 20 | def test_meta_description_length_check(snapshot): 21 | content = """ 22 | 23 | 24 | 25 | 26 | 27 | 28 | """ 29 | check = MetaDescriptionCheck(BeautifulSoup(content, "html.parser")) 30 | issues = check.run() 31 | snapshot.assert_match(issues.as_data()) 32 | -------------------------------------------------------------------------------- /tests/checks/test_mixed_content.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | from combine.checks.mixed_content import MixedContentCheck 4 | 5 | 6 | def test_duplicate_id_check(snapshot): 7 | content = """ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |