Compare commits
10
Commits
58eebac637
...
8c6c0d318e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6c0d318e | ||
|
|
015f1375ec | ||
|
|
3abb6ce8fe | ||
|
|
1250ead61a | ||
|
|
7a1926113c | ||
|
|
74925f1668 | ||
|
|
ce7ba6f893 | ||
|
|
b51f445ed1 | ||
|
|
5660ef2974 | ||
|
|
1a5f3bfc67 |
+8
-10
@@ -1,6 +1,6 @@
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
from markdown_it.token import Token
|
from markdown_it.token import Token as Token
|
||||||
from pygments import highlight as pygments_highlight
|
from pygments import highlight as pygments_highlight
|
||||||
from pygments.formatters import HtmlFormatter
|
from pygments.formatters import HtmlFormatter
|
||||||
from pygments.lexers import get_lexer_by_name
|
from pygments.lexers import get_lexer_by_name
|
||||||
@@ -32,7 +32,7 @@ def highlight(code: str, lang: str, attrs: str) -> str:
|
|||||||
md = MarkdownIt(options_update={"highlight": highlight})
|
md = MarkdownIt(options_update={"highlight": highlight})
|
||||||
|
|
||||||
|
|
||||||
def extract_summary(tokens: list[Token]) -> Markup:
|
def render_summary(tokens: list[Token]) -> Markup:
|
||||||
if not tokens or tokens[0].type != "paragraph_open" or tokens[0].hidden:
|
if not tokens or tokens[0].type != "paragraph_open" or tokens[0].hidden:
|
||||||
raise MarkdownError(
|
raise MarkdownError(
|
||||||
"Body does not start with a paragraph; set 'summary' in frontmatter"
|
"Body does not start with a paragraph; set 'summary' in frontmatter"
|
||||||
@@ -43,11 +43,9 @@ def extract_summary(tokens: list[Token]) -> Markup:
|
|||||||
raise MarkdownError("Content has no paragraph to use as summary")
|
raise MarkdownError("Content has no paragraph to use as summary")
|
||||||
|
|
||||||
|
|
||||||
def parse(body: str, include_summary: bool = True) -> tuple[Markup, Markup | None]:
|
def render(tokens: list[Token]) -> Markup:
|
||||||
tokens = md.parse(body)
|
return Markup(md.renderer.render(tokens, md.options, {}))
|
||||||
if include_summary:
|
|
||||||
summary = extract_summary(tokens)
|
|
||||||
else:
|
def parse(body: str) -> list[Token]:
|
||||||
summary = None
|
return md.parse(body)
|
||||||
content = Markup(md.renderer.render(tokens, md.options, {}))
|
|
||||||
return content, summary
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from omicron.ssg.output.output import Output
|
from omicron.ssg.output.output import Output, OutputError
|
||||||
from omicron.ssg.output.content import Content, ContentError
|
from omicron.ssg.output.content import Content, ContentError
|
||||||
from omicron.ssg.output.factory import create_content, is_content
|
from omicron.ssg.output.factory import create_content, is_content
|
||||||
from omicron.ssg.output.file import File
|
from omicron.ssg.output.file import File
|
||||||
from omicron.ssg.output.index import Index, discover_index
|
from omicron.ssg.output.index import Index, discover_index
|
||||||
from omicron.ssg.output.memory import Memory
|
from omicron.ssg.output.memory import Memory
|
||||||
|
from omicron.ssg.output.directory import Directory
|
||||||
|
from omicron.ssg.output.symlink import Symlink
|
||||||
from omicron.ssg.output.tags import Tags, discover_tags
|
from omicron.ssg.output.tags import Tags, discover_tags
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Output",
|
"Output",
|
||||||
|
"OutputError",
|
||||||
"Content",
|
"Content",
|
||||||
"ContentError",
|
"ContentError",
|
||||||
"create_content",
|
"create_content",
|
||||||
@@ -15,7 +18,9 @@ __all__ = [
|
|||||||
"File",
|
"File",
|
||||||
"Index",
|
"Index",
|
||||||
"discover_index",
|
"discover_index",
|
||||||
|
"Directory",
|
||||||
"Memory",
|
"Memory",
|
||||||
|
"Symlink",
|
||||||
"Tags",
|
"Tags",
|
||||||
"discover_tags",
|
"discover_tags",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from omicron.ssg.output.content import Content
|
from omicron.ssg.output.content import Content
|
||||||
|
from omicron.ssg.output.file import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import omicron.ssg.markdown as md
|
import omicron.ssg.markdown as md
|
||||||
|
|
||||||
@@ -28,10 +30,44 @@ class Article(Content):
|
|||||||
)
|
)
|
||||||
self.title: str = meta["title"]
|
self.title: str = meta["title"]
|
||||||
|
|
||||||
|
def handle_reference(self, path: Path) -> Content | File:
|
||||||
|
site = self.site
|
||||||
|
if path in site.by_source:
|
||||||
|
return site.by_source[path]
|
||||||
|
return self.copy_referenced_file(path)
|
||||||
|
|
||||||
|
def resolve_references(self, tokens: list[md.Token]) -> None:
|
||||||
|
# MarkdownIt guarantees links are always in the second level
|
||||||
|
for token in tokens:
|
||||||
|
if token.type != "inline" or token.children is None:
|
||||||
|
continue
|
||||||
|
for child in token.children:
|
||||||
|
if child.type == "link_open":
|
||||||
|
attr = "href"
|
||||||
|
elif child.type == "image":
|
||||||
|
attr = "src"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = child.attrs.get(attr)
|
||||||
|
assert type(href) is str, "unexpected link/image href type"
|
||||||
|
parsed = urlparse(href)
|
||||||
|
|
||||||
|
if parsed.scheme or parsed.netloc:
|
||||||
|
continue
|
||||||
|
if not parsed.path or parsed.path.startswith("/"):
|
||||||
|
continue
|
||||||
|
target = self.locate_reference(Path(parsed.path))
|
||||||
|
|
||||||
|
output = self.handle_reference(target)
|
||||||
|
child.attrs[attr] = parsed._replace(path=output.url).geturl()
|
||||||
|
|
||||||
def write(self) -> None:
|
def write(self) -> None:
|
||||||
content, summary = md.parse(self.read_body(), self.summary is None)
|
tokens = md.parse(self.read_body())
|
||||||
if summary is not None:
|
self.resolve_references(tokens)
|
||||||
self.summary = summary
|
if self.summary is None:
|
||||||
|
self.summary = md.render_summary(tokens)
|
||||||
|
content = md.render(tokens)
|
||||||
|
|
||||||
template = self.site.jinja_env.get_template("article.html")
|
template = self.site.jinja_env.get_template("article.html")
|
||||||
html = template.render(page=self, content=content, site=self.site)
|
html = template.render(page=self, content=content, site=self.site)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from omicron.ssg.output.output import Output
|
from omicron.ssg.output.output import Output, OutputError, is_normalized
|
||||||
|
from omicron.ssg.output.file import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ else:
|
|||||||
Site = "omicron.ssg.site.Site"
|
Site = "omicron.ssg.site.Site"
|
||||||
|
|
||||||
|
|
||||||
class ContentError(RuntimeError):
|
class ContentError(OutputError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ class Content(Output):
|
|||||||
super().__init__(site, Content.build_path(section, slug))
|
super().__init__(site, Content.build_path(section, slug))
|
||||||
if tags is None:
|
if tags is None:
|
||||||
tags = set()
|
tags = set()
|
||||||
|
if not source.is_absolute():
|
||||||
|
raise ContentError("content source path must be absolute")
|
||||||
|
if not is_normalized(source):
|
||||||
|
raise ContentError("content source path must be normalized")
|
||||||
self.source = source
|
self.source = source
|
||||||
self.section: str = section
|
self.section: str = section
|
||||||
self.created = created
|
self.created = created
|
||||||
@@ -42,9 +47,32 @@ class Content(Output):
|
|||||||
def build_path(section: str, slug: str) -> Path:
|
def build_path(section: str, slug: str) -> Path:
|
||||||
if not Content._SECTION_RE.match(section):
|
if not Content._SECTION_RE.match(section):
|
||||||
raise ContentError(f"Invalid section '{section}'")
|
raise ContentError(f"Invalid section '{section}'")
|
||||||
path = Path(f"{section}/{slug}.html")
|
path = Path(f"{section}/{slug}/index.html")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def locate_reference(self, path: Path) -> Path:
|
||||||
|
if path.is_absolute():
|
||||||
|
raise ContentError("Can't resolve absolute references")
|
||||||
|
base = self.site.site_path / "content"
|
||||||
|
file = (self.source.parent / path).resolve()
|
||||||
|
if not file.is_relative_to(base):
|
||||||
|
raise ContentError("Reference escapes content directory")
|
||||||
|
return file
|
||||||
|
|
||||||
|
def copy_referenced_file(self, path: Path) -> File:
|
||||||
|
if not path.is_file():
|
||||||
|
raise ContentError(f"Referenced file '{path}' does not exist")
|
||||||
|
|
||||||
|
dest = self.destination.parent / path.name
|
||||||
|
existing = self.site.by_path.get(dest)
|
||||||
|
if isinstance(existing, File) and existing.source == path:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
file = File(self.site, dest, path)
|
||||||
|
self.site.add_by_path(file)
|
||||||
|
file.write()
|
||||||
|
return file
|
||||||
|
|
||||||
def read_body(self) -> str:
|
def read_body(self) -> str:
|
||||||
with self.source.open("r", encoding="utf-8") as f:
|
with self.source.open("r", encoding="utf-8") as f:
|
||||||
f.readline() # opening ---
|
f.readline() # opening ---
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from omicron.ssg.output.output import Output
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from omicron.ssg.site import Site # for static checking with mypy
|
||||||
|
else:
|
||||||
|
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
||||||
|
|
||||||
|
|
||||||
|
class Directory(Output):
|
||||||
|
def __init__(self, site: Site, destination: Path):
|
||||||
|
super().__init__(site, destination)
|
||||||
|
url_path = Path("/") / site.base_dir / destination
|
||||||
|
# rebuild url because Output strips /index.html which is a valid dir
|
||||||
|
self.url = url_path.as_posix()
|
||||||
|
|
||||||
|
def write(self) -> None:
|
||||||
|
dest = self.site.output_path / self.destination
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
log.debug("Created directory %s", dest)
|
||||||
@@ -44,7 +44,7 @@ class Index(Output):
|
|||||||
if page_num == 1:
|
if page_num == 1:
|
||||||
file = Path("index.html")
|
file = Path("index.html")
|
||||||
else:
|
else:
|
||||||
file = Path(f"page-{page_num}.html")
|
file = Path(f"page-{page_num}/index.html")
|
||||||
|
|
||||||
if kind == "root":
|
if kind == "root":
|
||||||
path = file
|
path = file
|
||||||
|
|||||||
@@ -9,13 +9,27 @@ else:
|
|||||||
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
||||||
|
|
||||||
|
|
||||||
|
def is_normalized(path: Path) -> bool:
|
||||||
|
return ".." not in path.parts
|
||||||
|
|
||||||
|
|
||||||
|
class OutputError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Output(ABC):
|
class Output(ABC):
|
||||||
def __init__(self, site: Site, destination: Path):
|
def __init__(self, site: Site, destination: Path):
|
||||||
self._site_ref = weakref.ref(site)
|
self._site_ref = weakref.ref(site)
|
||||||
|
if destination.is_absolute():
|
||||||
|
raise OutputError("destination path must be relative")
|
||||||
|
if not is_normalized(destination):
|
||||||
|
raise OutputError("destination path must be normalized")
|
||||||
self.destination = destination
|
self.destination = destination
|
||||||
url = site.base_dir + "/" + destination.as_posix()
|
|
||||||
url = url.removesuffix("/index.html") or "/"
|
url = site.base_dir / destination
|
||||||
self.url = url
|
if url.name == "index.html":
|
||||||
|
url = url.parent
|
||||||
|
self.url = url.as_posix()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def site(self) -> Site:
|
def site(self) -> Site:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from omicron.ssg.output.output import Output, OutputError, is_normalized
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from omicron.ssg.site import Site # for static checking with mypy
|
||||||
|
else:
|
||||||
|
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
||||||
|
|
||||||
|
|
||||||
|
class Symlink(Output):
|
||||||
|
def __init__(self, site: Site, link: Path, target: Path):
|
||||||
|
if target.is_absolute():
|
||||||
|
raise OutputError("Symlinks target must be relative")
|
||||||
|
if not is_normalized(target):
|
||||||
|
raise OutputError("Symlinks target must be normalized")
|
||||||
|
super().__init__(site, link)
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def write(self) -> None:
|
||||||
|
site = self.site
|
||||||
|
if self.target not in site.by_path:
|
||||||
|
raise OutputError(f"Symlink target '{self.target}' is not a known output")
|
||||||
|
link = site.output_path / self.destination
|
||||||
|
link.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if link.exists() or link.is_symlink():
|
||||||
|
link.unlink()
|
||||||
|
rel_target = os.path.relpath(site.output_path / self.target, link.parent)
|
||||||
|
link.symlink_to(rel_target)
|
||||||
|
log.debug("Symlinked %s -> %s", link, rel_target)
|
||||||
+43
-9
@@ -4,8 +4,10 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from omicron.ssg.output import (
|
from omicron.ssg.output import (
|
||||||
Output,
|
Output,
|
||||||
|
OutputError,
|
||||||
Content,
|
Content,
|
||||||
ContentError,
|
ContentError,
|
||||||
|
Directory,
|
||||||
is_content,
|
is_content,
|
||||||
create_content,
|
create_content,
|
||||||
File,
|
File,
|
||||||
@@ -30,11 +32,13 @@ class ConfigError(RuntimeError):
|
|||||||
|
|
||||||
class Site:
|
class Site:
|
||||||
def __init__(self, site: Path):
|
def __init__(self, site: Path):
|
||||||
self.site_path = site.absolute()
|
self.site_path = site.resolve()
|
||||||
self.content: list[Content] = []
|
self.content: list[Content] = []
|
||||||
|
self.directories: list[Directory] = []
|
||||||
self.other_outputs: list[Output] = []
|
self.other_outputs: list[Output] = []
|
||||||
self.by_tag: dict[str, list[Content]] = {}
|
self.by_tag: dict[str, list[Content]] = {}
|
||||||
self.by_path: dict[Path, Output] = {}
|
self.by_path: dict[Path, Output] = {}
|
||||||
|
self.by_source: dict[Path, Content] = {}
|
||||||
self.by_section: dict[str, list[Content]] = {}
|
self.by_section: dict[str, list[Content]] = {}
|
||||||
self.tags_by_count: list[tuple[str, int]] = []
|
self.tags_by_count: list[tuple[str, int]] = []
|
||||||
|
|
||||||
@@ -61,9 +65,9 @@ class Site:
|
|||||||
return cast(dict[str, Any], config)
|
return cast(dict[str, Any], config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_base_url(value: str | None) -> tuple[str, str]:
|
def parse_base_url(value: str | None) -> tuple[str, Path]:
|
||||||
if not value:
|
if not value:
|
||||||
return ("", "")
|
return ("", Path("/"))
|
||||||
parsed = urlparse(value)
|
parsed = urlparse(value)
|
||||||
if parsed.scheme and parsed.netloc:
|
if parsed.scheme and parsed.netloc:
|
||||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
@@ -72,7 +76,11 @@ class Site:
|
|||||||
else:
|
else:
|
||||||
origin = ""
|
origin = ""
|
||||||
|
|
||||||
path = parsed.path.removesuffix("/")
|
if parsed.path:
|
||||||
|
path = Path(parsed.path)
|
||||||
|
else:
|
||||||
|
path = Path("/")
|
||||||
|
|
||||||
return (origin, path)
|
return (origin, path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -98,10 +106,35 @@ class Site:
|
|||||||
raise ContentError(
|
raise ContentError(
|
||||||
f"path '{output.destination}' is already claimed by another output"
|
f"path '{output.destination}' is already claimed by another output"
|
||||||
)
|
)
|
||||||
|
new_dirs: list[Directory] = []
|
||||||
|
for parent in output.destination.parents:
|
||||||
|
if parent == Path("."):
|
||||||
|
break
|
||||||
|
if parent in self.by_path:
|
||||||
|
if not isinstance(self.by_path[parent], Directory):
|
||||||
|
raise OutputError(
|
||||||
|
f"path '{parent}' is claimed by a file but needed as a directory"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
new_dirs.append(Directory(self, parent))
|
||||||
self.by_path[output.destination] = output
|
self.by_path[output.destination] = output
|
||||||
|
if isinstance(output, Directory):
|
||||||
|
self.directories.append(output)
|
||||||
|
for d in new_dirs:
|
||||||
|
self.by_path[d.destination] = d
|
||||||
|
self.directories.append(d)
|
||||||
|
|
||||||
|
def add_by_source(self, content: Content) -> None:
|
||||||
|
assert content.source.is_absolute(), "Expecting content to have absolute source"
|
||||||
|
self.by_source[content.source] = content
|
||||||
|
|
||||||
def home_url(self) -> str:
|
def home_url(self) -> str:
|
||||||
return self.base_dir or "/"
|
return self.base_dir.as_posix()
|
||||||
|
|
||||||
|
def url_for(self, path: str) -> str:
|
||||||
|
if Path(path).is_absolute():
|
||||||
|
raise OutputError("path must be relative")
|
||||||
|
return (self.base_dir / path).as_posix()
|
||||||
|
|
||||||
def section_url(self, section: str) -> str:
|
def section_url(self, section: str) -> str:
|
||||||
path = Index.build_path("section", 1, section)
|
path = Index.build_path("section", 1, section)
|
||||||
@@ -144,6 +177,7 @@ class Site:
|
|||||||
self.add_by_path(content)
|
self.add_by_path(content)
|
||||||
self.add_by_section(content)
|
self.add_by_section(content)
|
||||||
self.add_by_tag(content)
|
self.add_by_tag(content)
|
||||||
|
self.add_by_source(content)
|
||||||
self.content.append(content)
|
self.content.append(content)
|
||||||
self.update_tags_by_count()
|
self.update_tags_by_count()
|
||||||
for index in discover_index(self):
|
for index in discover_index(self):
|
||||||
@@ -160,11 +194,11 @@ class Site:
|
|||||||
"base_url config value is missing a domain name, "
|
"base_url config value is missing a domain name, "
|
||||||
"can't add canonical url to content"
|
"can't add canonical url to content"
|
||||||
)
|
)
|
||||||
if self.base_dir and not self.base_dir.startswith("/"):
|
if not self.base_dir.is_absolute():
|
||||||
raise ConfigError("base_url config value must be an absolute path")
|
raise ConfigError("base_url config value must be an absolute path")
|
||||||
self.discover()
|
self.discover()
|
||||||
self.output_path.mkdir(parents=True, exist_ok=True)
|
outputs: list[Output] = [*self.directories, *self.content]
|
||||||
for content in self.content:
|
for output in sorted(outputs, key=lambda o: o.destination.as_posix()):
|
||||||
content.write()
|
output.write()
|
||||||
for output in self.other_outputs:
|
for output in self.other_outputs:
|
||||||
output.write()
|
output.write()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{{ site.name }}{% endblock %}</title>
|
<title>{% block title %}{{ site.name }}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ site.base_dir }}/assets/style.css">
|
<link rel="stylesheet" href="{{ site.url_for('assets/style.css') }}">
|
||||||
<link rel="stylesheet" href="{{ site.base_dir }}/assets/pygments.css">
|
<link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}">
|
||||||
<link rel="canonical" href="{{ site.origin }}{{ page.url }}">
|
{% if site.origin %}<link rel="canonical" href="{{ site.origin }}{{ page.url }}">{% endif %}
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user