← Files JinkōARCHIVED FILE
skills/jinko-document/scripts/create_document_from_markdown.py
20.1 KB · Oct 2, 2026 · 00:29 UTC
#!/usr/bin/env python3
"""Create or update a Jinko document from markdown and retain the upload payload.
Dry-run by default. Pass --apply to upload files and create or update the document.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
from pathlib import Path
from typing import Any
from check_markdown_structure import HEADING_RE, inspect_markdown, structural_losses
from jinko import JinkoClient
REFERENCE_PLACEHOLDER = "<!-- jinko:references -->"
LOCAL_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
IMAGE_SUFFIXES = frozenset({".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"})
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--name", help="New document name (required for creation)")
parser.add_argument(
"--document-sid",
help="Existing Document SID to update instead of creating a document",
)
parser.add_argument(
"--baseline-markdown",
help="Last approved upload payload (required for updates)",
)
parser.add_argument(
"--append-only-section",
action="append",
default=[],
help="Heading whose existing non-empty content must remain an exact prefix",
)
parser.add_argument(
"--markdown-file",
required=True,
help="Path to the markdown file to upload",
)
parser.add_argument(
"--folder",
"--parent-folder-id",
dest="folder",
help="Optional destination Jinko folder id",
)
parser.add_argument(
"--description",
help="Optional document description",
)
parser.add_argument(
"--version-name",
help="Optional version name",
)
parser.add_argument(
"--version-description",
help="Optional version description",
)
parser.add_argument(
"--reference-manifest",
help="Optional JSON manifest of existing Reference SIDs or URLs",
)
parser.add_argument(
"--asset-root",
help=(
"Optional allowed root for local Markdown images. Paths still resolve "
"relative to the Markdown file."
),
)
parser.add_argument(
"--output-markdown",
required=True,
help="New path where the exact transformed upload payload will be retained",
)
parser.add_argument(
"--apply",
action="store_true",
help="Upload local files and create or update the Jinko document",
)
parser.add_argument(
"--confirm-digest",
metavar="SHA256",
help="Approval digest shown by the dry run; required with --apply",
)
return parser.parse_args()
def build_version_payload(args: argparse.Namespace) -> str | dict[str, str] | None:
if args.version_name and args.version_description:
return {
"name": args.version_name,
"description": args.version_description,
}
if args.version_name:
return args.version_name
return None
def bytes_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def file_record(path: Path) -> dict[str, str | int]:
content = path.read_bytes()
return {
"path": str(path),
"size": len(content),
"sha256": bytes_sha256(content),
}
def is_remote_url(target: str) -> bool:
return target.startswith(("http://", "https://"))
def is_special_markdown_target(target: str) -> bool:
return target.startswith(("data:", "#"))
def resolve_local_file(
raw_path: str,
*,
base_dir: Path,
allowed_root: Path | None = None,
label: str,
) -> Path:
"""Resolve a local upload path without allowing it to escape its input directory."""
resolved_root = (allowed_root or base_dir).resolve()
# Resolve before checking containment so ../ paths and symlink escapes are rejected.
resolved_path = (base_dir / raw_path).resolve()
try:
resolved_path.relative_to(resolved_root)
except ValueError as error:
raise ValueError(
f"Referenced {label} is outside its allowed directory: "
f"{raw_path} -> {resolved_path} (allowed: {resolved_root})"
) from error
if not resolved_path.is_file():
raise FileNotFoundError(
f"Referenced {label} does not exist: {raw_path} -> {resolved_path}"
)
return resolved_path
def resolve_allowed_root(raw_path: str | None, *, default: Path, label: str) -> Path:
"""Resolve and validate an explicitly authorized local upload root."""
root = Path(raw_path).resolve() if raw_path else default.resolve()
if not root.is_dir():
raise NotADirectoryError(
f"{label} does not exist or is not a directory: {root}"
)
if root == Path(root.anchor) or root == Path.home().resolve():
raise ValueError(
f"{label} must be narrower than a filesystem or home directory"
)
return root
def validate_image_file(path: Path) -> None:
"""Require a supported image extension and matching file signature."""
suffix = path.suffix.lower()
if suffix not in IMAGE_SUFFIXES:
accepted = ", ".join(sorted(IMAGE_SUFFIXES))
raise ValueError(
f"Unsupported local image type for {path}; accepted extensions: {accepted}"
)
with path.open("rb") as image_file:
header = image_file.read(4096)
valid = {
".gif": header.startswith((b"GIF87a", b"GIF89a")),
".jpeg": header.startswith(b"\xff\xd8\xff"),
".jpg": header.startswith(b"\xff\xd8\xff"),
".png": header.startswith(b"\x89PNG\r\n\x1a\n"),
".webp": (
len(header) >= 12 and header.startswith(b"RIFF") and header[8:12] == b"WEBP"
),
".svg": b"<svg" in header.lstrip(b"\xef\xbb\xbf \t\r\n").lower(),
}[suffix]
if not valid:
raise ValueError(f"Local image content does not match its extension: {path}")
def resolve_image_file(
raw_path: str,
*,
base_dir: Path,
allowed_root: Path,
) -> Path:
path = resolve_local_file(
raw_path,
base_dir=base_dir,
allowed_root=allowed_root,
label="image",
)
validate_image_file(path)
return path
def upload_local_images(
client: JinkoClient,
markdown: str,
*,
markdown_dir: Path,
asset_root: Path,
) -> tuple[str, list[tuple[str, str]]]:
uploaded: dict[str, str] = {}
replacements: list[tuple[str, str]] = []
def replace(match: re.Match[str]) -> str:
alt_text = match.group(1)
raw_target = match.group(2).strip()
if is_remote_url(raw_target) or is_special_markdown_target(raw_target):
return match.group(0)
if raw_target not in uploaded:
image_path = resolve_image_file(
raw_target,
base_dir=markdown_dir,
allowed_root=asset_root,
)
image = client.upload_image(image_file_path=image_path)
uploaded[raw_target] = image.url
replacements.append((raw_target, image.url))
return f""
rewritten = LOCAL_IMAGE_RE.sub(replace, markdown)
return rewritten, replacements
def load_reference_entries(path: Path) -> list[dict[str, Any]]:
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, list):
entries = payload
elif isinstance(payload, dict) and isinstance(payload.get("references"), list):
entries = payload["references"]
else:
raise ValueError(
"Reference manifest must be a list or an object with a 'references' list"
)
if not all(isinstance(entry, dict) for entry in entries):
raise ValueError("Each reference manifest entry must be an object")
for entry in entries:
identities = [
key
for key in ("sid", "url")
if isinstance(entry.get(key), str) and entry[key]
]
if len(identities) != 1:
raise ValueError(
"Each reference entry must provide exactly one explicit sid or url; "
"create PDF References with jinko-reference first"
)
return entries
def resolve_reference(
client: JinkoClient,
entry: dict[str, Any],
):
sid = entry.get("sid")
if isinstance(sid, str) and sid:
return client.get_reference(sid)
url = entry.get("url")
if isinstance(url, str) and url:
return url
raise ValueError("Reference entry has no explicit sid or url")
def build_references_block(
client: JinkoClient,
manifest_path: Path,
) -> tuple[str, list[str]]:
entries = load_reference_entries(manifest_path)
lines = [REFERENCE_PLACEHOLDER, "## References", ""]
actions: list[str] = []
for index, entry in enumerate(entries, start=1):
resolved = resolve_reference(
client,
entry,
)
citation = entry.get("citation") or f"[{index}]"
title = entry.get("title")
if isinstance(resolved, str):
link_url = resolved
link_title = title or citation
actions.append(f"{citation} -> reused URL {link_url}")
else:
link_url = resolved.url
link_title = title or resolved.name
actions.append(f"{citation} -> {resolved.sid}")
lines.append(f"- {citation} [{link_title}]({link_url})")
lines.append("")
return "\n".join(lines), actions
def build_dry_run_actions(
markdown: str,
*,
markdown_dir: Path,
asset_root: Path,
manifest_path: Path | None,
) -> list[str]:
actions: list[str] = []
uploaded_images: set[str] = set()
for match in LOCAL_IMAGE_RE.finditer(markdown):
raw_target = match.group(2).strip()
if (
raw_target in uploaded_images
or is_remote_url(raw_target)
or is_special_markdown_target(raw_target)
):
continue
image_path = resolve_image_file(
raw_target,
base_dir=markdown_dir,
allowed_root=asset_root,
)
uploaded_images.add(raw_target)
actions.append(f"Would upload image: {image_path}")
if manifest_path is None:
return actions
for index, entry in enumerate(load_reference_entries(manifest_path), start=1):
citation = entry.get("citation") or f"[{index}]"
sid = entry.get("sid")
if isinstance(sid, str) and sid:
actions.append(f"Would reuse reference: {citation} -> {sid}")
continue
url = entry.get("url")
if isinstance(url, str) and url:
actions.append(f"Would reuse URL: {citation} -> {url}")
continue
raise ValueError("Reference entry has no explicit sid or url")
return actions
def build_approval_manifest(
args: argparse.Namespace,
*,
markdown_path: Path,
markdown: str,
asset_root: Path,
manifest_path: Path | None,
output_path: Path,
baseline_path: Path | None,
) -> dict[str, Any]:
api_key = os.getenv("JINKO_API_KEY")
images: dict[str, dict[str, Any]] = {}
for match in LOCAL_IMAGE_RE.finditer(markdown):
raw_target = match.group(2).strip()
if is_remote_url(raw_target) or is_special_markdown_target(raw_target):
continue
image_path = resolve_image_file(
raw_target,
base_dir=markdown_path.parent,
allowed_root=asset_root,
)
images[str(image_path)] = {"target": raw_target, **file_record(image_path)}
inputs: dict[str, Any] = {
"markdown": file_record(markdown_path),
"images": sorted(images.values(), key=lambda item: str(item["path"])),
}
if manifest_path:
entries = load_reference_entries(manifest_path)
inputs["reference_manifest"] = {
**file_record(manifest_path),
"identities": [
{key: entry[key] for key in ("sid", "url") if entry.get(key)}
for entry in entries
],
}
if baseline_path:
inputs["baseline_markdown"] = file_record(baseline_path)
return {
"schema": "jinko-document-approval-v1",
"arguments": {
"operation": "update" if args.document_sid else "create",
"document_sid": args.document_sid,
"append_only_sections": args.append_only_section,
"name": args.name,
"folder": args.folder,
"description": args.description,
"version": build_version_payload(args),
"asset_root": str(asset_root),
"output_markdown": str(output_path),
"jinko_destination": {
"project_id": os.getenv("JINKO_PROJECT_ID"),
"base_url": os.getenv("JINKO_BASE_URL", "https://api.jinko.ai"),
"app_url": os.getenv("JINKO_URL"),
"api_key_sha256": (
bytes_sha256(api_key.encode("utf-8")) if api_key else None
),
},
},
"inputs": inputs,
}
def approval_digest(manifest: dict[str, Any]) -> str:
canonical = json.dumps(
manifest,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return bytes_sha256(canonical)
def inject_references(markdown: str, references_block: str) -> str:
lines = markdown.splitlines()
marker_indexes = [
index
for index, line in enumerate(lines)
if line.strip() == REFERENCE_PLACEHOLDER
]
if len(marker_indexes) > 1:
raise ValueError("Markdown contains more than one Jinko references marker")
if marker_indexes:
start = marker_indexes[0]
end = start + 1
heading_index = end
while heading_index < len(lines) and not lines[heading_index].strip():
heading_index += 1
heading = (
HEADING_RE.match(lines[heading_index])
if heading_index < len(lines)
else None
)
if (
heading
and len(heading.group(1)) == 2
and heading.group(2).strip().casefold() == "references"
):
end = heading_index + 1
while end < len(lines):
next_heading = HEADING_RE.match(lines[end])
if next_heading and len(next_heading.group(1)) <= 2:
break
end += 1
replaced = [*lines[:start], *references_block.splitlines(), *lines[end:]]
return "\n".join(replaced).rstrip() + "\n"
stripped = markdown.rstrip()
return f"{stripped}\n\n{references_block}\n"
def main() -> None:
args = parse_args()
if args.document_sid:
if not args.baseline_markdown:
raise ValueError("Document updates require --baseline-markdown")
unsupported = [
flag
for flag, value in (
("--name", args.name),
("--folder", args.folder),
("--description", args.description),
("--version-name", args.version_name),
("--version-description", args.version_description),
)
if value
]
if unsupported:
raise ValueError(
"Document updates do not accept creation metadata: "
+ ", ".join(unsupported)
)
elif not args.name:
raise ValueError("Document creation requires --name")
elif args.baseline_markdown or args.append_only_section:
raise ValueError(
"--baseline-markdown and --append-only-section apply only to updates"
)
markdown_path = Path(args.markdown_file).resolve()
markdown_dir = markdown_path.parent
markdown = markdown_path.read_text(encoding="utf-8")
asset_root = resolve_allowed_root(
args.asset_root,
default=markdown_dir,
label="Asset root",
)
manifest_path = (
Path(args.reference_manifest).resolve() if args.reference_manifest else None
)
baseline_path = (
Path(args.baseline_markdown).resolve() if args.baseline_markdown else None
)
if baseline_path:
baseline_structure = inspect_markdown(
baseline_path, ignore_generated_references=manifest_path is not None
)
candidate_structure = inspect_markdown(
markdown_path, ignore_generated_references=manifest_path is not None
)
losses = structural_losses(
baseline_structure,
candidate_structure,
args.append_only_section,
)
if losses:
raise ValueError("Structural losses detected: " + "; ".join(losses))
output_path = Path(args.output_markdown).resolve()
if not output_path.parent.is_dir():
raise NotADirectoryError(
f"Output Markdown parent directory does not exist: {output_path.parent}"
)
if output_path.exists():
raise FileExistsError(f"Refusing to overwrite output Markdown: {output_path}")
dry_run_actions = build_dry_run_actions(
markdown,
markdown_dir=markdown_dir,
asset_root=asset_root,
manifest_path=manifest_path,
)
approval_manifest = build_approval_manifest(
args,
markdown_path=markdown_path,
markdown=markdown,
asset_root=asset_root,
manifest_path=manifest_path,
output_path=output_path,
baseline_path=baseline_path,
)
digest = approval_digest(approval_manifest)
if not args.apply:
print("Dry run: no Jinko API calls will be made.")
if args.document_sid:
print(f"Would update document: {args.document_sid}")
else:
print(f"Would create document: {args.name}")
print(f"Markdown source: {markdown_path}")
if not args.document_sid:
print(f"Destination folder: {args.folder or 'project root'}")
print(f"Final payload output: {output_path}")
print(f"Allowed image root: {asset_root}")
for action in dry_run_actions:
print(action)
print(
"Approval manifest: "
+ json.dumps(approval_manifest, ensure_ascii=True, sort_keys=True)
)
print(f"Approval digest: {digest}")
print(
"After approving these exact inputs and arguments, run again with "
f"--apply --confirm-digest {digest}."
)
return
if not args.confirm_digest:
raise ValueError("--apply requires --confirm-digest from a dry run")
if args.confirm_digest.lower() != digest:
raise ValueError(
"Inputs or arguments do not match the approved digest; run a dry run "
"and approve the current manifest."
)
if not os.getenv("JINKO_API_KEY") or not os.getenv("JINKO_PROJECT_ID"):
raise ValueError(
"--apply requires JINKO_API_KEY and JINKO_PROJECT_ID in the approved environment"
)
client = JinkoClient()
markdown, image_replacements = upload_local_images(
client,
markdown,
markdown_dir=markdown_dir,
asset_root=asset_root,
)
reference_actions: list[str] = []
if manifest_path:
references_block, reference_actions = build_references_block(
client,
manifest_path,
)
markdown = inject_references(markdown, references_block)
with output_path.open("x", encoding="utf-8") as output_file:
output_file.write(markdown)
payload_digest = bytes_sha256(markdown.encode("utf-8"))
if args.document_sid:
document = client.get_document(args.document_sid)
document.update_markdown(markdown)
else:
document = client.create_document_from_markdown(
markdown_content=markdown,
name=args.name,
folder=args.folder,
description=args.description,
version=build_version_payload(args),
)
print(f"Resource SID: {document.sid}")
print(f"Resource link: {document.url}")
print(f"Retained upload payload: {output_path}")
print(f"Upload payload SHA-256: {payload_digest}")
for source_path, image_url in image_replacements:
print(f"Uploaded image: {source_path} -> {image_url}")
for action in reference_actions:
print(f"Reference: {action}")
if __name__ == "__main__":
main()
SHA-256: e4c933d38b04e39b92263a91486d477b4031bf99cd6e5730400836393f82a074