"""Scaffold new wiki pages from type-spec templates. These commands produce structurally-correct frontmatter and a body skeleton with TODO placeholders by loading templates from type-spec files. The prose (Description, Summary, Key Takeaways, ...) is still written by the LLM afterwards with its normal edit tool. The split between type-spec templates and LLM-provided prose is intentional: type definitions (naming, frontmatter shape, directory placement, templates) are deterministic and stored in /types/; the content is judgment and provided by the LLM. Frontmatter defaults, enum validity, and required-ness all come from the type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them. Directory placement for subtype-driven types (currently just entities) also comes from the type-spec, via its `layout:` frontmatter (see `TypeResolver.get_layout`) - not a hand-maintained Python dict. """ from __future__ import annotations from pathlib import Path import datetime from typing import Any, Dict, Optional import re import typer from chemenu import config from chemenu.commands._util import ( check_collision, check_raw_files_exist, fail, parse_set_fields, rel_path, success, ) from chemenu.frontmatter_io import write_page from chemenu.type_resolver import resolver def _default_summary(summary: str) -> str: """Scaffold-time placeholder for an unfilled --summary, so schema validation's `summary` minLength requirement doesn't block page creation. Matches the same "TODO: add summary" text index_build.py already falls back to when a page has no frontmatter summary.""" return summary.strip() or "TODO: add summary" def _resolve_type_and_get_template(type_path: str, source_dir: Path = None): """Resolve a type path, load the type-spec, and extract its template.""" type_spec = resolver.load_type_spec(type_path, source_dir) template = resolver.extract_template(type_spec) return type_spec, template def _enum_help(type_path: str, field_name: str) -> str: """Build a help string from the schema's own enum, so any text listing a field's valid values can never drift from what the schema accepts.""" return f"One of: {'|'.join(resolver.get_enum(type_path, field_name))}" def _parse_date(field_name: str, text: str) -> datetime.date: """A `--set =` as a real date, or a refusal naming it.""" try: return datetime.date.fromisoformat(text) except ValueError: fail(f"--set {field_name}={text!r} must be YYYY-MM-DD.") def _build_frontmatter( type_path: str, schema: Optional[Dict[str, Any]], today: datetime.date, explicit: Dict[str, Any] ) -> Dict[str, Any]: """Build a page's frontmatter dict in schema-declaration order. `explicit` supplies every CLI-derived value the caller already has; fields not in `explicit` get a type-appropriate default (today's date for date-formatted fields, the scaffold placeholder for `summary`, the schema's own `default:` where declared, an empty list for arrays), or are omitted entirely if optional with no sensible default (e.g. `source_url`). This is what lets frontmatter shape - and scaffold-time defaults like `provenance: general` - follow the schema instead of being hand-declared per CLI command. """ frontmatter: Dict[str, Any] = {"type": type_path} for field_name, field_schema in (schema or {}).get("properties", {}).items(): if field_name == "type": continue if field_name in explicit: value = explicit[field_name] # A `--set date=2026-08-23` arrives as a string; the corpus stores # dates as `datetime.date`, and the schema already says which fields # those are. Converting here keeps one representation on disk instead # of leaving it to whoever wrote the CLI call. if field_schema.get("format") == "date" and isinstance(value, str): value = _parse_date(field_name, value) frontmatter[field_name] = value elif field_name == "summary": frontmatter[field_name] = _default_summary("") elif field_name == "author": resolved_author = config.default_author() if resolved_author is None: fail( "No author configured for this instance. Set `git config user.name`, " "or export WIKI_AUTHOR to override it, then retry." ) frontmatter[field_name] = resolved_author elif field_schema.get("format") == "date": frontmatter[field_name] = today elif "default" in field_schema: frontmatter[field_name] = field_schema["default"] elif field_schema.get("type") == "array": frontmatter[field_name] = [] # Carry through any caller-supplied field the schema doesn't declare, # rather than silently dropping it: a typo'd `--set` must surface as a # validation error (via `additionalProperties: false`) instead of being # quietly ignored, and a schema that does allow extra properties should # keep them. for field_name, value in explicit.items(): if field_name not in frontmatter: frontmatter[field_name] = value return frontmatter def _filter_bullets(value: Any) -> str: """Render a list frontmatter field as `- [[item]]` bullet lines.""" items = value or [] return "\n".join(f"- [[{item}]]" for item in items) if items else "- None identified" def _filter_join(value: Any) -> str: """Comma-join a list frontmatter field.""" return ", ".join(value or []) def _filter_capitalize(value: Any) -> str: return str(value).capitalize() def _filter_table_header(value: Any) -> str: """Render an array field as wikilinked markdown table column headers.""" return " | ".join(f"[[{item}]]" for item in (value or [])) def _filter_table_sep(value: Any) -> str: """Render the markdown table separator row for an array field, one column per item.""" return "|".join("--------" for _ in (value or [])) or "--------" def _filter_table_cells(value: Any) -> str: """Render a placeholder table body row, one cell per array item.""" return " | ".join("..." for _ in (value or [])) _TEMPLATE_FILTERS = { "bullets": _filter_bullets, "join": _filter_join, "capitalize": _filter_capitalize, "table_header": _filter_table_header, "table_sep": _filter_table_sep, "table_cells": _filter_table_cells, } def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str: """Apply variable substitutions to a template string. Supports: - `{field}` - plain substitution from `variables[field]` - `{field|filter}` - apply a named filter (bullets, join, capitalize) to `variables[field]`'s value, so templates can render list/enum frontmatter fields directly instead of the caller precomputing a separate display-only variable for each one - `{field|literal text}` - literal fallback if `field` isn't in `variables` at all and the suffix isn't a recognized filter name """ def replace_match(match: re.Match) -> str: full_match = match.group(0) var_name = match.group(1) if '|' in var_name: var_name, suffix = var_name.split('|', 1) var_name = var_name.strip() suffix = suffix.strip() if var_name not in variables: return suffix value = variables[var_name] filter_fn = _TEMPLATE_FILTERS.get(suffix) return filter_fn(value) if filter_fn else str(value) return str(variables.get(var_name, full_match)) pattern = r'\{([^}]+)\}' return re.sub(pattern, replace_match, template) def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]: """Return the subtype-driven subdirectory under a type's `base_dir`, from the type-spec's own `layout:` frontmatter. Thin wrapper over `TypeResolver.subtype_dir` - the one place this computation lives, shared with `move` and `lint`'s misplaced-page finding.""" return resolver.subtype_dir(type_path, subtype) def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path: """Resolve where an instance of this type is written: `/`, plus a subtype subdirectory when the type declares a `layout:`. Thin wrapper over `TypeResolver.compute_target_dir` - the one placement rule, also used by `move` and `lint`'s misplaced-page finding - converting its `ValueError` into the CLI's normal friendly-failure path. `base_dir` is resolved against `config.KB_DIR` by default, so tests that point KB_DIR at a temporary fixture wiki can never write into the real `kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT` instead - for artifacts that are agent-directed material rather than knowledge, and so live outside the knowledge layer.""" try: return resolver.compute_target_dir(type_path, frontmatter) except ValueError as exc: fail(str(exc)) def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None: """Validate frontmatter against its type-spec schema, converting a ValueError into the CLI's normal friendly-failure path instead of an uncaught traceback. The schema's own error message (which already names the offending field and, for enums, lists the valid values) is shown as-is - there is no separate hand-maintained validity check to keep in sync with it.""" try: resolver.validate_frontmatter(frontmatter, type_path, source_dir) except ValueError as exc: fail(str(exc)) def _load_type_or_fail(type_path: str, source_dir: Path): """Resolve a type path and load its type-spec + template, converting an unresolvable/invalid `--type` into the CLI's normal friendly-failure path.""" try: return _resolve_type_and_get_template(type_path, source_dir) except ValueError as exc: fail(str(exc)) def new_page_command( type_name: str = typer.Argument( ..., help="Type name, e.g. entity|concept|source|comparison (see `wikitool types list`)", ), name: str = typer.Option(..., "--name", help="Page title (a type's title_prefix is added automatically)"), type_path_override: str = typer.Option( "", "--type", help="Override the type-spec path (defaults to the one named by TYPE_NAME)" ), set_fields: Optional[list[str]] = typer.Option( None, "--set", help="Frontmatter field, repeatable: --set entity_type=tool --set tags=a,b. Array values split on commas (escape a literal one as \\,); repeating --set for an array field appends instead of replacing", ), ): """Scaffold a new wiki page of any type. The type's own type-spec drives everything: which frontmatter fields exist and are required (its `.schema.yaml`), their scaffold defaults (schema `default:`), where the page is written (`base_dir` + `layout`), what prefixes its title (`title_prefix`), and its body skeleton (the type-spec's template). Adding a new type therefore needs no change here. """ type_path = type_path_override or resolver.find_type_by_name(type_name) if not type_path: available = sorted(fm.get("name") for _, fm in resolver.list_type_specs()) fail(f"No type-spec named '{type_name}'. Available: {', '.join(available)}") today = datetime.date.today() try: title_prefix = resolver.get_title_prefix(type_path) root = resolver.get_root(type_path) except ValueError as exc: fail(str(exc)) page_title = f"{title_prefix}{name}" if root == "kb": # Title collisions matter because wikilinks resolve by title alone, so # two pages sharing a stem are indistinguishable to every link in the # wiki. Artifacts outside kb/ are not addressed by title and are not # part of that namespace, so the check does not apply to them. check_collision(page_title) schema = resolver.get_schema(type_path) explicit = parse_set_fields(set_fields, schema) declared = (schema or {}).get("properties", {}) if "summary" in declared: explicit.setdefault("summary", _default_summary("")) if "name" in declared: # The CLI already has this value; a type that stores its own name in # frontmatter should not have to be told it twice. explicit.setdefault("name", name) if "description" in declared: explicit.setdefault("description", "TODO: add description") frontmatter = _build_frontmatter(type_path, schema, today, explicit) # Capture fields (Gitea #67, e.g. `fidelity`/`authority` on a source page) # are deliberately absent from `required:` - putting them there would make # every existing instance's source pages stop validating, a # boundary-crossing change (version-parts.md). The requirement is instead # enforced here, in the tool, exactly like `source_type`'s no-default # refusal (#66) reads from the schema alone: without a `default:` and # omitted from `explicit`, a capture field simply never lands in # `frontmatter`, so its absence has to be caught before it is silently # written as a page with no capture record at all. try: capture_fields = resolver.get_capture_fields(type_path) except ValueError as exc: fail(str(exc)) missing_capture = [f for f in capture_fields if not frontmatter.get(f)] if missing_capture: fail( f"Type {type_path} requires capture field(s) {', '.join(missing_capture)} - pass them " "explicitly, e.g. --set fidelity=verbatim --set authority=reporting. " "new does not guess them (see raw/CONTRACT.md)." ) guessed_unknown = [f for f in capture_fields if frontmatter.get(f) == "unknown"] if guessed_unknown: fail( f"Capture field(s) {', '.join(guessed_unknown)} cannot be set to 'unknown' here - that " "value is backfill-only, written only by `wikitool touch` on a page predating this rule." ) target_dir = _target_dir(type_path, frontmatter) _type_spec, template = _load_type_or_fail(type_path, target_dir) _validate_or_fail(frontmatter, type_path, target_dir) if "raw_files" in frontmatter: check_raw_files_exist(frontmatter["raw_files"]) path = target_dir / f"{page_title}.md" body = _apply_template_variables( template, { **frontmatter, "name": name, "today": today.isoformat(), }, ) write_page(path, frontmatter, body) success(f"Created {rel_path(path)}")