├── 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 |...
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 |yaml: example
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 | - "
'
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
18 |
19 |
20 |