From 02bc0ce81d676a8ff20b8ba654bb4418b1d9d469 Mon Sep 17 00:00:00 2001 From: Peisong Xiao Date: Sun, 8 Feb 2026 17:47:35 -0500 Subject: [PATCH] added renderer selection (py-gfm as alternative) --- README.md | 1 + configurations.md | 28 +++++++++++++++++++++------- examples.md | 5 +++++ pyproject.toml | 1 + requirements.txt | 1 + src/config.py | 16 +++++++++++++++- src/evaluation.py | 12 +++++++++++- src/manifest.py | 29 +++++++++++++++++++++++++++-- src/markdown_utils.py | 35 +++++++++++++++++++++++++++++------ src/models.py | 2 ++ src/scaffold.py | 2 ++ 11 files changed, 115 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ebed2b4..ca34fab 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Each managed directory must contain a `.wp-materialize.json` manifest. See `conf 1. Python 3.10+ 2. Packages: - `Markdown>=3.6` + - `py_gfm` (only required when using `renderer: "py-gfm"`) ## System Prerequisites diff --git a/configurations.md b/configurations.md index 1645406..efcb8bd 100644 --- a/configurations.md +++ b/configurations.md @@ -10,9 +10,11 @@ Top-level fields: Path to the WordPress root directory where the `wp` CLI is executed. 2. `repo_storage_dir` (string, required) Directory where git repositories are cloned or updated. -3. `git_repositories` (array, optional) +3. `renderer` (string, optional) + Markdown renderer to use. Allowed values: `default`, `py-gfm`. +4. `git_repositories` (array, optional) List of git repositories to manage. Default is an empty list. -4. `directories` (array, optional) +5. `directories` (array, optional) List of non-git directories to manage. Default is an empty list. `git_repositories` entries: @@ -50,9 +52,12 @@ Top-level fields: Inherited tags for this directory and its children. 3. `author` (object, optional) Inherited author for this directory and its children. Must resolve to a single author. -4. `subdirectories` (object, optional) +4. `renderer` (string, optional) + Markdown renderer to use for this directory. Allowed values: `default`, `py-gfm`. + If omitted, it inherits from the parent scope. +5. `subdirectories` (object, optional) Explicit list of subdirectories to traverse. -5. `files` (object, optional) +6. `files` (object, optional) Mapping of Markdown file names to file-level configuration. `categories`, `tags`, `author`, and `subdirectories` objects: @@ -61,7 +66,8 @@ Top-level fields: List of values for the given field. For `categories`, each string is a hierarchical path such as `Systems/Infrastructure`. For `subdirectories`, each string is a directory name under the current directory. - For `author`, exactly one string must remain after inheritance is applied. + For `author`, exactly one string must remain after inheritance is applied and it should be + a WordPress user ID (integer as a string). 2. `inherit` (boolean, optional, default `true`) If `true`, append to the parent effective list. If `false`, replace the parent list entirely. @@ -70,6 +76,10 @@ Note: Root directory manifests do not need to specify `inherit` for these top-le fields (the default is `true`). File-level overrides inside `files` still support 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. + `files` entries: Each key is a Markdown file name (relative to the manifest directory). @@ -84,10 +94,13 @@ Each value is an object with the following fields: Manual override for the post creation time in `YYYY-MM-DD hh:mm` format. 4. `last_modified` (string, optional) Manual override for the post modified time in `YYYY-MM-DD hh:mm` format. -5. `categories` (object, optional) +5. `renderer` (string, optional) + Markdown renderer to use for this file. Allowed values: `default`, `py-gfm`. + If omitted, it inherits from the parent scope. +6. `categories` (object, optional) Overrides categories for this file. Uses the same `content` and `inherit` fields as the top-level `categories` object. -6. `tags` (object, optional) +7. `tags` (object, optional) Overrides tags for this file. Uses the same `content` and `inherit` fields as the top-level `tags` object. @@ -102,6 +115,7 @@ If `created_on` or `last_modified` is not provided, the system infers the value. For `git_repositories` sources it uses git commit timestamps; for `directories` sources it uses filesystem timestamps. The system does not auto-detect git for entries declared under `directories`, even if the path is inside a git repo. +If `created_on` is in the future, WordPress will mark the post as scheduled. ## Post Identity diff --git a/examples.md b/examples.md index c3cb519..163d384 100644 --- a/examples.md +++ b/examples.md @@ -11,6 +11,7 @@ Root directory manifest (`.wp-materialize.json`): "categories": { "content": ["Systems", "Infrastructure"], "inherit": true }, "tags": { "content": ["automation", "wordpress"], "inherit": true }, "author": { "content": ["editorial"], "inherit": true }, + "renderer": "py-gfm", "subdirectories": { "content": ["design", "notes"], "inherit": true }, "files": { "post.md": { @@ -20,6 +21,7 @@ Root directory manifest (`.wp-materialize.json`): }, "essay.md": { "use_heading_as_title": { "level": 1, "strict": true }, + "renderer": "default", "created_on": "2025-01-10 09:30", "last_modified": "2025-02-14 16:45" } @@ -48,6 +50,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): { "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", + "renderer": "default", "git_repositories": [], "directories": [ { @@ -65,6 +68,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): { "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", + "renderer": "default", "git_repositories": [ { "name": "content-repo", @@ -95,6 +99,7 @@ Subdirectory manifest (`design/.wp-materialize.json`): { "wordpress_root": "/var/www/wordpress", "repo_storage_dir": "/home/user/wp-materialize-repos", + "renderer": "default", "git_repositories": [ { "name": "content-repo", diff --git a/pyproject.toml b/pyproject.toml index f8e0499..b99a129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "Markdown>=3.6", + "py_gfm", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index cb286b3..40d8241 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ Markdown>=3.6 +py_gfm diff --git a/src/config.py b/src/config.py index ae650a0..6471a4d 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,7 @@ class Config: repo_storage_dir: Path git_repositories: List[GitRepository] directories: List[DirectorySpec] + renderer: Optional[str] def _expect_keys(obj: dict, allowed: set[str], context: str) -> None: @@ -48,10 +49,11 @@ 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"}, "config") + _expect_keys(data, {"wordpress_root", "repo_storage_dir", "git_repositories", "directories", "renderer"}, "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") git_repositories = [] for idx, repo in enumerate(data.get("git_repositories", []) or []): @@ -85,6 +87,7 @@ def load_config(path: Path) -> Config: repo_storage_dir=repo_storage_dir, git_repositories=git_repositories, directories=directories, + renderer=renderer, ) @@ -102,3 +105,14 @@ def _require_path(data: dict, key: str, required: bool) -> Path: if not isinstance(value, str) or not value.strip(): raise ConfigurationError(f"{key} must be a non-empty string") return Path(value) + + +def _require_renderer(value: object, context: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigurationError(f"{context} must be a non-empty string") + renderer = value.strip() + if renderer not in {"default", "py-gfm"}: + raise ConfigurationError(f"{context} must be one of: default, py-gfm") + return renderer diff --git a/src/evaluation.py b/src/evaluation.py index 2220e10..e411dc3 100644 --- a/src/evaluation.py +++ b/src/evaluation.py @@ -21,6 +21,7 @@ class _Context: categories: InheritList tags: InheritList author: InheritList + renderer: Optional[str] subdirectories: InheritList manifest_chain: List[Path] @@ -45,6 +46,7 @@ def evaluate( categories=InheritList(), tags=InheritList(), author=InheritList(), + renderer=config.renderer, subdirectories=InheritList(), manifest_chain=[], ), @@ -141,6 +143,7 @@ def _evaluate_directory( effective_categories = _merge_inherit(context.categories, manifest.categories) 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_subdirs = _merge_inherit(context.subdirectories, manifest.subdirectories) manifest_chain = context.manifest_chain + [manifest.path] @@ -181,7 +184,13 @@ def _evaluate_directory( resolved_tags = _normalize_list(resolved_tags, "tag", str(file_path), issues) resolved_author = _resolve_author(effective_author.content, str(file_path), issues) - html = convert_markdown(markdown_body, context=str(file_path), issues=issues) + resolved_renderer = spec.renderer if spec.renderer is not None else effective_renderer + html = convert_markdown( + markdown_body, + context=str(file_path), + issues=issues, + renderer=resolved_renderer or "default", + ) if html is None: continue @@ -248,6 +257,7 @@ def _evaluate_directory( categories=effective_categories, tags=effective_tags, author=effective_author, + renderer=effective_renderer, subdirectories=effective_subdirs, manifest_chain=manifest_chain, ), diff --git a/src/manifest.py b/src/manifest.py index 97ea5ca..d2a65a7 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", "subdirectories", "files"} + allowed = {"categories", "tags", "author", "renderer", "subdirectories", "files"} extra = set(data.keys()) - allowed if extra: issues.append(ValidationIssue(f"Unexpected keys: {sorted(extra)}", context=str(path))) @@ -33,6 +33,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: categories = _parse_inherit_list(data.get("categories"), issues, f"{path}:categories") 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") subdirectories = _parse_inherit_list(data.get("subdirectories"), issues, f"{path}:subdirectories") files: Dict[str, FileSpec] = {} @@ -48,7 +49,15 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: if not isinstance(file_cfg, dict): issues.append(ValidationIssue(f"{file_name} must be an object", context=str(path))) continue - extra_file = set(file_cfg.keys()) - {"title", "use_heading_as_title", "categories", "tags", "created_on", "last_modified"} + extra_file = set(file_cfg.keys()) - { + "title", + "use_heading_as_title", + "categories", + "tags", + "created_on", + "last_modified", + "renderer", + } if extra_file: issues.append( ValidationIssue(f"{file_name} has unexpected keys: {sorted(extra_file)}", context=str(path)) @@ -93,6 +102,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: tags_override = _parse_inherit_list(file_cfg.get("tags"), issues, f"{path}:{file_name}:tags") 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") 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)) @@ -106,6 +116,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: tags=tags_override, created_on=created_on, last_modified=last_modified, + renderer=renderer_override, ) return Manifest( @@ -113,6 +124,7 @@ def load_manifest(path: Path, issues: list[ValidationIssue]) -> Manifest | None: categories=categories, tags=tags, author=author, + renderer=renderer, subdirectories=subdirectories, files=files, ) @@ -153,3 +165,16 @@ def _parse_datetime_field(value: object, issues: list[ValidationIssue], context: except ValueError: issues.append(ValidationIssue("Invalid datetime format (expected YYYY-MM-DD hh:mm)", context=context)) return None + + +def _parse_renderer_field(value: object, issues: list[ValidationIssue], context: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + issues.append(ValidationIssue("Must be a non-empty string", context=context)) + return None + renderer = value.strip() + if renderer not in {"default", "py-gfm"}: + issues.append(ValidationIssue("Must be one of: default, py-gfm", context=context)) + return None + return renderer diff --git a/src/markdown_utils.py b/src/markdown_utils.py index 2a734e2..240b954 100644 --- a/src/markdown_utils.py +++ b/src/markdown_utils.py @@ -54,9 +54,32 @@ def _promote_headings(text: str) -> str: return "\n".join(promoted_lines) -def convert_markdown(markdown_text: str, context: str, issues: list[ValidationIssue]) -> str | None: - try: - return md_lib.markdown(markdown_text, extensions=["extra"], 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 +def convert_markdown( + markdown_text: str, + context: str, + issues: list[ValidationIssue], + renderer: str = "default", +) -> str | None: + if renderer == "default": + try: + return md_lib.markdown(markdown_text, extensions=["extra"], 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 == "py-gfm": + try: + import mdx_gfm + except Exception as exc: # pragma: no cover - dependency missing + issues.append(ValidationIssue(f"py-gfm is not available: {exc}", context=context)) + return None + extension_class = getattr(mdx_gfm, "GithubFlavoredMarkdownExtension", None) + if extension_class is None: + 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") + except Exception as exc: # pragma: no cover - depends on markdown internals + issues.append(ValidationIssue(f"Markdown conversion failed: {exc}", context=context)) + return None + issues.append(ValidationIssue(f"Unknown renderer: {renderer}", context=context)) + return None diff --git a/src/models.py b/src/models.py index c010b27..23be2f6 100644 --- a/src/models.py +++ b/src/models.py @@ -21,6 +21,7 @@ class FileSpec: tags: Optional[InheritList] created_on: Optional[datetime] last_modified: Optional[datetime] + renderer: Optional[str] @dataclass(frozen=True) @@ -29,6 +30,7 @@ class Manifest: categories: InheritList tags: InheritList author: InheritList + renderer: Optional[str] subdirectories: InheritList files: Dict[str, FileSpec] diff --git a/src/scaffold.py b/src/scaffold.py index cbafb74..77514df 100644 --- a/src/scaffold.py +++ b/src/scaffold.py @@ -14,6 +14,7 @@ def create_config(path: Path) -> None: payload = { "wordpress_root": "/path/to/wordpress", "repo_storage_dir": "/path/to/repo-storage", + "renderer": "default", "git_repositories": [ { "name": "example-repo", @@ -45,6 +46,7 @@ def create_manifest(directory: Path) -> Path: "categories": {"content": [], "inherit": True}, "tags": {"content": [], "inherit": True}, "author": {"content": [], "inherit": True}, + "renderer": "default", "subdirectories": {"content": [], "inherit": True}, "files": {}, }