From 00d44090a8ecccd2b88f955769762903649ed0d9 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sun, 8 Feb 2026 18:09:54 -0500 Subject: [PATCH] added hard line breaks support --- configurations.md | 23 +++++++++++++++++------ examples.md | 5 +++++ src/config.py | 17 ++++++++++++++++- src/evaluation.py | 14 ++++++++++++++ src/manifest.py | 20 +++++++++++++++++++- src/markdown_utils.py | 13 ++++++++++--- src/models.py | 2 ++ src/scaffold.py | 2 ++ 8 files changed, 85 insertions(+), 11 deletions(-) diff --git a/configurations.md b/configurations.md index d8759b1..660565d 100644 --- a/configurations.md +++ b/configurations.md @@ -12,9 +12,11 @@ Top-level fields: Directory where git repositories are cloned or updated. 3. `renderer` (string, optional) 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. -5. `directories` (array, optional) +6. `directories` (array, optional) List of non-git directories to manage. Default is an empty list. `git_repositories` entries: @@ -55,9 +57,12 @@ Top-level fields: 4. `renderer` (string, optional) Markdown renderer to use for this directory. Allowed values: `default`, `py-gfm`, `pandoc`. 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. -6. `files` (object, optional) +7. `files` (object, optional) Mapping of Markdown file names to file-level configuration. `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 from the parent scope; if specified, it overrides the parent without an explicit `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: 1. `default` uses the Python `Markdown` library. @@ -102,10 +110,13 @@ Each value is an object with the following fields: 5. `renderer` (string, optional) Markdown renderer to use for this file. Allowed values: `default`, `py-gfm`, `pandoc`. 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 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 as the top-level `tags` object. diff --git a/examples.md b/examples.md index c607b02..a3d2580 100644 --- a/examples.md +++ b/examples.md @@ -12,6 +12,7 @@ Root directory manifest (`.wp-materialize.json`): "tags": { "content": ["automation", "wordpress"], "inherit": true }, "author": { "content": ["editorial"], "inherit": true }, "renderer": "pandoc", + "hard_line_breaks": true, "subdirectories": { "content": ["design", "notes"], "inherit": true }, "files": { "post.md": { @@ -22,6 +23,7 @@ Root directory manifest (`.wp-materialize.json`): "essay.md": { "use_heading_as_title": { "level": 1, "strict": true }, "renderer": "py-gfm", + "hard_line_breaks": false, "created_on": "2025-01-10 09:30", "last_modified": "2025-02-14 16:45" } @@ -51,6 +53,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", "renderer": "default", + "hard_line_breaks": false, "git_repositories": [], "directories": [ { @@ -69,6 +72,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", "renderer": "default", + "hard_line_breaks": false, "git_repositories": [ { "name": "content-repo", @@ -100,6 +104,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", "renderer": "default", + "hard_line_breaks": false, "git_repositories": [ { "name": "content-repo", diff --git a/src/config.py b/src/config.py index 56ab02f..7675f70 100644 --- a/src/config.py +++ b/src/config.py @@ -30,6 +30,7 @@ class Config: git_repositories: List[GitRepository] directories: List[DirectorySpec] renderer: Optional[str] + hard_line_breaks: bool 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): 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) repo_storage_dir = _require_path(data, "repo_storage_dir", required=True) 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 = [] for idx, repo in enumerate(data.get("git_repositories", []) or []): @@ -88,6 +94,7 @@ def load_config(path: Path) -> Config: git_repositories=git_repositories, directories=directories, 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"}: raise ConfigurationError(f"{context} must be one of: default, py-gfm, pandoc") 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 diff --git a/src/evaluation.py b/src/evaluation.py index e411dc3..d011181 100644 --- a/src/evaluation.py +++ b/src/evaluation.py @@ -22,6 +22,7 @@ class _Context: tags: InheritList author: InheritList renderer: Optional[str] + hard_line_breaks: bool subdirectories: InheritList manifest_chain: List[Path] @@ -47,6 +48,7 @@ def evaluate( tags=InheritList(), author=InheritList(), renderer=config.renderer, + hard_line_breaks=config.hard_line_breaks, subdirectories=InheritList(), manifest_chain=[], ), @@ -144,6 +146,11 @@ def _evaluate_directory( effective_tags = _merge_inherit(context.tags, manifest.tags) effective_author = _merge_inherit(context.author, manifest.author) 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) 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_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( markdown_body, context=str(file_path), issues=issues, renderer=resolved_renderer or "default", + hard_line_breaks=resolved_hard_line_breaks, ) if html is None: continue @@ -258,6 +271,7 @@ def _evaluate_directory( tags=effective_tags, author=effective_author, renderer=effective_renderer, + hard_line_breaks=effective_hard_line_breaks, subdirectories=effective_subdirs, manifest_chain=manifest_chain, ), diff --git a/src/manifest.py b/src/manifest.py index 0ab44a1..e6a53e4 100644 --- a/src/manifest.py +++ b/src/manifest.py @@ -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))) return None - allowed = {"categories", "tags", "author", "renderer", "subdirectories", "files"} + allowed = {"categories", "tags", "author", "renderer", "hard_line_breaks", "subdirectories", "files"} extra = set(data.keys()) - allowed if extra: 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") author = _parse_inherit_list(data.get("author"), issues, f"{path}:author") 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") files: Dict[str, FileSpec] = {} @@ -57,6 +58,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: "created_on", "last_modified", "renderer", + "hard_line_breaks", } if extra_file: 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") 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") + 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: issues.append( 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, last_modified=last_modified, renderer=renderer_override, + hard_line_breaks=hard_line_breaks_override, ) return Manifest( @@ -125,6 +133,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: tags=tags, author=author, renderer=renderer, + hard_line_breaks=hard_line_breaks, subdirectories=subdirectories, 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)) return None 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 diff --git a/src/markdown_utils.py b/src/markdown_utils.py index e51533f..39a1743 100644 --- a/src/markdown_utils.py +++ b/src/markdown_utils.py @@ -60,10 +60,14 @@ def convert_markdown( context: str, issues: list[ValidationIssue], renderer: str = "default", + hard_line_breaks: bool = False, ) -> str | None: if renderer == "default": 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 issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context)) return None @@ -78,14 +82,17 @@ def convert_markdown( issues.append(ValidationIssue("py-gfm extension not found: GithubFlavoredMarkdownExtension", context=context)) return None 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 issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context)) return None if renderer == "pandoc": try: 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, text=True, capture_output=True, diff --git a/src/models.py b/src/models.py index 23be2f6..d39b873 100644 --- a/src/models.py +++ b/src/models.py @@ -22,6 +22,7 @@ class FileSpec: created_on: Optional[datetime] last_modified: Optional[datetime] renderer: Optional[str] + hard_line_breaks: Optional[bool] @dataclass(frozen=True) @@ -31,6 +32,7 @@ class Manifest: tags: InheritList author: InheritList renderer: Optional[str] + hard_line_breaks: Optional[bool] subdirectories: InheritList files: Dict[str, FileSpec] diff --git a/src/scaffold.py b/src/scaffold.py index 77514df..bea43ac 100644 --- a/src/scaffold.py +++ b/src/scaffold.py @@ -15,6 +15,7 @@ def create_config(path: Path) -> None: "wordpress_root": "/path/to/wordpress", "repo_storage_dir": "/path/to/repo-storage", "renderer": "default", + "hard_line_breaks": False, "git_repositories": [ { "name": "example-repo", @@ -47,6 +48,7 @@ def create_manifest(directory: Path) -> Path: "tags": {"content": [], "inherit": True}, "author": {"content": [], "inherit": True}, "renderer": "default", + "hard_line_breaks": False, "subdirectories": {"content": [], "inherit": True}, "files": {}, }