added hard line breaks support

This commit is contained in:
2026-02-08 18:09:54 -05:00
parent 164cb5d980
commit 00d44090a8
8 changed files with 85 additions and 11 deletions

View File

@@ -12,9 +12,11 @@ Top-level fields:
Directory where git repositories are cloned or updated. Directory where git repositories are cloned or updated.
3. `renderer` (string, optional) 3. `renderer` (string, optional)
Markdown renderer to use. Allowed values: `default`, `py-gfm`, `pandoc`. Markdown renderer to use. Allowed values: `default`, `py-gfm`, `pandoc`.
4. `git_repositories` (array, optional) 4. `hard_line_breaks` (boolean, optional)
If `true`, treat single newlines as hard line breaks.
5. `git_repositories` (array, optional)
List of git repositories to manage. Default is an empty list. List of git repositories to manage. Default is an empty list.
5. `directories` (array, optional) 6. `directories` (array, optional)
List of non-git directories to manage. Default is an empty list. List of non-git directories to manage. Default is an empty list.
`git_repositories` entries: `git_repositories` entries:
@@ -55,9 +57,12 @@ Top-level fields:
4. `renderer` (string, optional) 4. `renderer` (string, optional)
Markdown renderer to use for this directory. Allowed values: `default`, `py-gfm`, `pandoc`. Markdown renderer to use for this directory. Allowed values: `default`, `py-gfm`, `pandoc`.
If omitted, it inherits from the parent scope. If omitted, it inherits from the parent scope.
5. `subdirectories` (object, optional) 5. `hard_line_breaks` (boolean, optional)
If `true`, treat single newlines as hard line breaks. If omitted, it inherits
from the parent scope.
6. `subdirectories` (object, optional)
Explicit list of subdirectories to traverse. Explicit list of subdirectories to traverse.
6. `files` (object, optional) 7. `files` (object, optional)
Mapping of Markdown file names to file-level configuration. Mapping of Markdown file names to file-level configuration.
`categories`, `tags`, `author`, and `subdirectories` objects: `categories`, `tags`, `author`, and `subdirectories` objects:
@@ -79,6 +84,9 @@ inheritance via their own `inherit` fields.
The `renderer` field inherits implicitly: if omitted, the renderer is inherited The `renderer` field inherits implicitly: if omitted, the renderer is inherited
from the parent scope; if specified, it overrides the parent without an explicit from the parent scope; if specified, it overrides the parent without an explicit
`inherit` flag. `inherit` flag.
The `hard_line_breaks` field inherits implicitly: if omitted, the value is inherited
from the parent scope; if specified, it overrides the parent without an explicit
`inherit` flag.
Renderer dependencies: Renderer dependencies:
1. `default` uses the Python `Markdown` library. 1. `default` uses the Python `Markdown` library.
@@ -102,10 +110,13 @@ Each value is an object with the following fields:
5. `renderer` (string, optional) 5. `renderer` (string, optional)
Markdown renderer to use for this file. Allowed values: `default`, `py-gfm`, `pandoc`. Markdown renderer to use for this file. Allowed values: `default`, `py-gfm`, `pandoc`.
If omitted, it inherits from the parent scope. If omitted, it inherits from the parent scope.
6. `categories` (object, optional) 6. `hard_line_breaks` (boolean, optional)
If `true`, treat single newlines as hard line breaks. If omitted, it inherits
from the parent scope.
7. `categories` (object, optional)
Overrides categories for this file. Uses the same `content` and `inherit` fields Overrides categories for this file. Uses the same `content` and `inherit` fields
as the top-level `categories` object. as the top-level `categories` object.
7. `tags` (object, optional) 8. `tags` (object, optional)
Overrides tags for this file. Uses the same `content` and `inherit` fields Overrides tags for this file. Uses the same `content` and `inherit` fields
as the top-level `tags` object. as the top-level `tags` object.

View File

@@ -12,6 +12,7 @@ Root directory manifest (`.wp-materialize.json`):
"tags": { "content": ["automation", "wordpress"], "inherit": true }, "tags": { "content": ["automation", "wordpress"], "inherit": true },
"author": { "content": ["editorial"], "inherit": true }, "author": { "content": ["editorial"], "inherit": true },
"renderer": "pandoc", "renderer": "pandoc",
"hard_line_breaks": true,
"subdirectories": { "content": ["design", "notes"], "inherit": true }, "subdirectories": { "content": ["design", "notes"], "inherit": true },
"files": { "files": {
"post.md": { "post.md": {
@@ -22,6 +23,7 @@ Root directory manifest (`.wp-materialize.json`):
"essay.md": { "essay.md": {
"use_heading_as_title": { "level": 1, "strict": true }, "use_heading_as_title": { "level": 1, "strict": true },
"renderer": "py-gfm", "renderer": "py-gfm",
"hard_line_breaks": false,
"created_on": "2025-01-10 09:30", "created_on": "2025-01-10 09:30",
"last_modified": "2025-02-14 16:45" "last_modified": "2025-02-14 16:45"
} }
@@ -51,6 +53,7 @@ Subdirectory manifest (`design/.wp-materialize.json`):
"wordpress_root": "/var/www/wordpress", "wordpress_root": "/var/www/wordpress",
"repo_storage_dir": "/home/user/wp-materialize-repos", "repo_storage_dir": "/home/user/wp-materialize-repos",
"renderer": "default", "renderer": "default",
"hard_line_breaks": false,
"git_repositories": [], "git_repositories": [],
"directories": [ "directories": [
{ {
@@ -69,6 +72,7 @@ Subdirectory manifest (`design/.wp-materialize.json`):
"wordpress_root": "/var/www/wordpress", "wordpress_root": "/var/www/wordpress",
"repo_storage_dir": "/home/user/wp-materialize-repos", "repo_storage_dir": "/home/user/wp-materialize-repos",
"renderer": "default", "renderer": "default",
"hard_line_breaks": false,
"git_repositories": [ "git_repositories": [
{ {
"name": "content-repo", "name": "content-repo",
@@ -100,6 +104,7 @@ Subdirectory manifest (`design/.wp-materialize.json`):
"wordpress_root": "/var/www/wordpress", "wordpress_root": "/var/www/wordpress",
"repo_storage_dir": "/home/user/wp-materialize-repos", "repo_storage_dir": "/home/user/wp-materialize-repos",
"renderer": "default", "renderer": "default",
"hard_line_breaks": false,
"git_repositories": [ "git_repositories": [
{ {
"name": "content-repo", "name": "content-repo",

View File

@@ -30,6 +30,7 @@ class Config:
git_repositories: List[GitRepository] git_repositories: List[GitRepository]
directories: List[DirectorySpec] directories: List[DirectorySpec]
renderer: Optional[str] renderer: Optional[str]
hard_line_breaks: bool
def _expect_keys(obj: dict, allowed: set[str], context: str) -> None: def _expect_keys(obj: dict, allowed: set[str], context: str) -> None:
@@ -49,11 +50,16 @@ def load_config(path: Path) -> Config:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ConfigurationError("Config must be a JSON object") raise ConfigurationError("Config must be a JSON object")
_expect_keys(data, {"wordpress_root", "repo_storage_dir", "git_repositories", "directories", "renderer"}, "config") _expect_keys(
data,
{"wordpress_root", "repo_storage_dir", "git_repositories", "directories", "renderer", "hard_line_breaks"},
"config",
)
wordpress_root = _require_path(data, "wordpress_root", required=True) wordpress_root = _require_path(data, "wordpress_root", required=True)
repo_storage_dir = _require_path(data, "repo_storage_dir", required=True) repo_storage_dir = _require_path(data, "repo_storage_dir", required=True)
renderer = _require_renderer(data.get("renderer"), context="config.renderer") renderer = _require_renderer(data.get("renderer"), context="config.renderer")
hard_line_breaks = _require_bool_optional(data.get("hard_line_breaks"), context="config.hard_line_breaks")
git_repositories = [] git_repositories = []
for idx, repo in enumerate(data.get("git_repositories", []) or []): for idx, repo in enumerate(data.get("git_repositories", []) or []):
@@ -88,6 +94,7 @@ def load_config(path: Path) -> Config:
git_repositories=git_repositories, git_repositories=git_repositories,
directories=directories, directories=directories,
renderer=renderer, renderer=renderer,
hard_line_breaks=False if hard_line_breaks is None else hard_line_breaks,
) )
@@ -116,3 +123,11 @@ def _require_renderer(value: object, context: str) -> Optional[str]:
if renderer not in {"default", "py-gfm", "pandoc"}: if renderer not in {"default", "py-gfm", "pandoc"}:
raise ConfigurationError(f"{context} must be one of: default, py-gfm, pandoc") raise ConfigurationError(f"{context} must be one of: default, py-gfm, pandoc")
return renderer return renderer
def _require_bool_optional(value: object, context: str) -> Optional[bool]:
if value is None:
return None
if not isinstance(value, bool):
raise ConfigurationError(f"{context} must be a boolean")
return value

View File

@@ -22,6 +22,7 @@ class _Context:
tags: InheritList tags: InheritList
author: InheritList author: InheritList
renderer: Optional[str] renderer: Optional[str]
hard_line_breaks: bool
subdirectories: InheritList subdirectories: InheritList
manifest_chain: List[Path] manifest_chain: List[Path]
@@ -47,6 +48,7 @@ def evaluate(
tags=InheritList(), tags=InheritList(),
author=InheritList(), author=InheritList(),
renderer=config.renderer, renderer=config.renderer,
hard_line_breaks=config.hard_line_breaks,
subdirectories=InheritList(), subdirectories=InheritList(),
manifest_chain=[], manifest_chain=[],
), ),
@@ -144,6 +146,11 @@ def _evaluate_directory(
effective_tags = _merge_inherit(context.tags, manifest.tags) effective_tags = _merge_inherit(context.tags, manifest.tags)
effective_author = _merge_inherit(context.author, manifest.author) effective_author = _merge_inherit(context.author, manifest.author)
effective_renderer = manifest.renderer if manifest.renderer is not None else context.renderer effective_renderer = manifest.renderer if manifest.renderer is not None else context.renderer
effective_hard_line_breaks = (
manifest.hard_line_breaks
if manifest.hard_line_breaks is not None
else context.hard_line_breaks
)
effective_subdirs = _merge_inherit(context.subdirectories, manifest.subdirectories) effective_subdirs = _merge_inherit(context.subdirectories, manifest.subdirectories)
manifest_chain = context.manifest_chain + [manifest.path] manifest_chain = context.manifest_chain + [manifest.path]
@@ -185,11 +192,17 @@ def _evaluate_directory(
resolved_author = _resolve_author(effective_author.content, str(file_path), issues) resolved_author = _resolve_author(effective_author.content, str(file_path), issues)
resolved_renderer = spec.renderer if spec.renderer is not None else effective_renderer resolved_renderer = spec.renderer if spec.renderer is not None else effective_renderer
resolved_hard_line_breaks = (
spec.hard_line_breaks
if spec.hard_line_breaks is not None
else effective_hard_line_breaks
)
html = convert_markdown( html = convert_markdown(
markdown_body, markdown_body,
context=str(file_path), context=str(file_path),
issues=issues, issues=issues,
renderer=resolved_renderer or "default", renderer=resolved_renderer or "default",
hard_line_breaks=resolved_hard_line_breaks,
) )
if html is None: if html is None:
continue continue
@@ -258,6 +271,7 @@ def _evaluate_directory(
tags=effective_tags, tags=effective_tags,
author=effective_author, author=effective_author,
renderer=effective_renderer, renderer=effective_renderer,
hard_line_breaks=effective_hard_line_breaks,
subdirectories=effective_subdirs, subdirectories=effective_subdirs,
manifest_chain=manifest_chain, manifest_chain=manifest_chain,
), ),

View File

@@ -24,7 +24,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
issues.append(ValidationIssue("Manifest must be a JSON object", context=str(path))) issues.append(ValidationIssue("Manifest must be a JSON object", context=str(path)))
return None return None
allowed = {"categories", "tags", "author", "renderer", "subdirectories", "files"} allowed = {"categories", "tags", "author", "renderer", "hard_line_breaks", "subdirectories", "files"}
extra = set(data.keys()) - allowed extra = set(data.keys()) - allowed
if extra: if extra:
issues.append(ValidationIssue(f"Unexpected keys: {sorted(extra)}", context=str(path))) issues.append(ValidationIssue(f"Unexpected keys: {sorted(extra)}", context=str(path)))
@@ -34,6 +34,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
tags = _parse_inherit_list(data.get("tags"), issues, f"{path}:tags") tags = _parse_inherit_list(data.get("tags"), issues, f"{path}:tags")
author = _parse_inherit_list(data.get("author"), issues, f"{path}:author") author = _parse_inherit_list(data.get("author"), issues, f"{path}:author")
renderer = _parse_renderer_field(data.get("renderer"), issues, f"{path}:renderer") renderer = _parse_renderer_field(data.get("renderer"), issues, f"{path}:renderer")
hard_line_breaks = _parse_bool_field(data.get("hard_line_breaks"), issues, f"{path}:hard_line_breaks")
subdirectories = _parse_inherit_list(data.get("subdirectories"), issues, f"{path}:subdirectories") subdirectories = _parse_inherit_list(data.get("subdirectories"), issues, f"{path}:subdirectories")
files: Dict[str, FileSpec] = {} files: Dict[str, FileSpec] = {}
@@ -57,6 +58,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
"created_on", "created_on",
"last_modified", "last_modified",
"renderer", "renderer",
"hard_line_breaks",
} }
if extra_file: if extra_file:
issues.append( issues.append(
@@ -103,6 +105,11 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
created_on = _parse_datetime_field(file_cfg.get("created_on"), issues, f"{path}:{file_name}:created_on") created_on = _parse_datetime_field(file_cfg.get("created_on"), issues, f"{path}:{file_name}:created_on")
last_modified = _parse_datetime_field(file_cfg.get("last_modified"), issues, f"{path}:{file_name}:last_modified") last_modified = _parse_datetime_field(file_cfg.get("last_modified"), issues, f"{path}:{file_name}:last_modified")
renderer_override = _parse_renderer_field(file_cfg.get("renderer"), issues, f"{path}:{file_name}:renderer") renderer_override = _parse_renderer_field(file_cfg.get("renderer"), issues, f"{path}:{file_name}:renderer")
hard_line_breaks_override = _parse_bool_field(
file_cfg.get("hard_line_breaks"),
issues,
f"{path}:{file_name}:hard_line_breaks",
)
if created_on and last_modified and last_modified < created_on: if created_on and last_modified and last_modified < created_on:
issues.append( issues.append(
ValidationIssue("last_modified cannot be earlier than created_on", context=str(path)) ValidationIssue("last_modified cannot be earlier than created_on", context=str(path))
@@ -117,6 +124,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
created_on=created_on, created_on=created_on,
last_modified=last_modified, last_modified=last_modified,
renderer=renderer_override, renderer=renderer_override,
hard_line_breaks=hard_line_breaks_override,
) )
return Manifest( return Manifest(
@@ -125,6 +133,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None:
tags=tags, tags=tags,
author=author, author=author,
renderer=renderer, renderer=renderer,
hard_line_breaks=hard_line_breaks,
subdirectories=subdirectories, subdirectories=subdirectories,
files=files, files=files,
) )
@@ -178,3 +187,12 @@ def _parse_renderer_field(value: object, issues: list[ValidationIssue], context:
issues.append(ValidationIssue("Must be one of: default, py-gfm, pandoc", context=context)) issues.append(ValidationIssue("Must be one of: default, py-gfm, pandoc", context=context))
return None return None
return renderer return renderer
def _parse_bool_field(value: object, issues: list[ValidationIssue], context: str) -> bool | None:
if value is None:
return None
if not isinstance(value, bool):
issues.append(ValidationIssue("Must be a boolean", context=context))
return None
return value

View File

@@ -60,10 +60,14 @@ def convert_markdown(
context: str, context: str,
issues: list[ValidationIssue], issues: list[ValidationIssue],
renderer: str = "default", renderer: str = "default",
hard_line_breaks: bool = False,
) -> str | None: ) -> str | None:
if renderer == "default": if renderer == "default":
try: try:
return md_lib.markdown(markdown_text, extensions=["extra"], output_format="html5") extensions = ["extra"]
if hard_line_breaks:
extensions.append("nl2br")
return md_lib.markdown(markdown_text, extensions=extensions, output_format="html5")
except Exception as exc: # pragma: no cover - depends on markdown internals except Exception as exc: # pragma: no cover - depends on markdown internals
issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context)) issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context))
return None return None
@@ -78,14 +82,17 @@ def convert_markdown(
issues.append(ValidationIssue("py-gfm extension not found: GithubFlavoredMarkdownExtension", context=context)) issues.append(ValidationIssue("py-gfm extension not found: GithubFlavoredMarkdownExtension", context=context))
return None return None
try: try:
return md_lib.markdown(markdown_text, extensions=[extension_class()], output_format="html5") extensions = [extension_class()]
if hard_line_breaks:
extensions.append("nl2br")
return md_lib.markdown(markdown_text, extensions=extensions, output_format="html5")
except Exception as exc: # pragma: no cover - depends on markdown internals except Exception as exc: # pragma: no cover - depends on markdown internals
issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context)) issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context))
return None return None
if renderer == "pandoc": if renderer == "pandoc":
try: try:
result = subprocess.run( result = subprocess.run(
["pandoc", "--from=markdown", "--to=html5"], ["pandoc", f"--from={'markdown+hard_line_breaks' if hard_line_breaks else 'markdown'}", "--to=html5"],
input=markdown_text, input=markdown_text,
text=True, text=True,
capture_output=True, capture_output=True,

View File

@@ -22,6 +22,7 @@ class FileSpec:
created_on: Optional[datetime] created_on: Optional[datetime]
last_modified: Optional[datetime] last_modified: Optional[datetime]
renderer: Optional[str] renderer: Optional[str]
hard_line_breaks: Optional[bool]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -31,6 +32,7 @@ class Manifest:
tags: InheritList tags: InheritList
author: InheritList author: InheritList
renderer: Optional[str] renderer: Optional[str]
hard_line_breaks: Optional[bool]
subdirectories: InheritList subdirectories: InheritList
files: Dict[str, FileSpec] files: Dict[str, FileSpec]

View File

@@ -15,6 +15,7 @@ def create_config(path: Path) -> None:
"wordpress_root": "/path/to/wordpress", "wordpress_root": "/path/to/wordpress",
"repo_storage_dir": "/path/to/repo-storage", "repo_storage_dir": "/path/to/repo-storage",
"renderer": "default", "renderer": "default",
"hard_line_breaks": False,
"git_repositories": [ "git_repositories": [
{ {
"name": "example-repo", "name": "example-repo",
@@ -47,6 +48,7 @@ def create_manifest(directory: Path) -> Path:
"tags": {"content": [], "inherit": True}, "tags": {"content": [], "inherit": True},
"author": {"content": [], "inherit": True}, "author": {"content": [], "inherit": True},
"renderer": "default", "renderer": "default",
"hard_line_breaks": False,
"subdirectories": {"content": [], "inherit": True}, "subdirectories": {"content": [], "inherit": True},
"files": {}, "files": {},
} }