unaltraweb 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +5 -5
  3. data/README.md +19 -7
  4. data/_plugins/figure_captions.rb +47 -10
  5. data/_sass/_documentation.scss +7 -5
  6. data/_sass/_manual.scss +7 -0
  7. data/docs/_documentation/en/02-tools.md +4 -4
  8. data/docs/_documentation/en/03-usage.md +61 -0
  9. data/docs/_documentation/en/13-unaltremanual.md +1 -1
  10. data/docs/_documentation/en/20-syntax.md +14 -0
  11. data/docs/_documentation/en/25-caption-credits.md +120 -0
  12. data/docs/_documentation/en/26-image-backgrounds.md +103 -0
  13. data/docs/_documentation/en/31-template.md +1 -1
  14. data/docs/_documentation/en/32-development.md +1 -1
  15. data/docs/_documentation/en/40-distribution.md +25 -5
  16. data/docs/_documentation/en/42-docker-image.md +7 -7
  17. data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
  18. data/docs/_documentation/en/44-editorial-review.md +237 -0
  19. data/docs/agents/action-prompts/00-start-site-session.txt +10 -5
  20. data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
  21. data/docs/agents/manual-authoring-components.md +38 -0
  22. data/docs/agents/mcp-contract.md +102 -10
  23. data/docs/agents/visual-companions-0.4.0.md +74 -0
  24. data/docs/assets/img/caption-credits-demo.svg +19 -0
  25. data/scripts/editorial_check.py +12 -0
  26. data/scripts/image_background_check.py +12 -0
  27. data/scripts/manual/build_pdf.py +94 -16
  28. data/scripts/manual/filters/figure-captions.lua +65 -10
  29. data/scripts/manual/templates/manual.tex +2 -1
  30. data/scripts/test_gem_build.py +32 -2
  31. data/scripts/test_reproducible_jekyll_build.py +1 -1
  32. data/scripts/test_wheel_install.py +28 -0
  33. data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
  34. data/scripts/validate_distribution.py +18 -3
  35. data/src/unaltraweb_mcp/component-contract.json +28 -28
  36. data/src/unaltraweb_mcp/editorial.py +495 -0
  37. data/src/unaltraweb_mcp/editorial_sources.py +504 -0
  38. data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
  39. data/src/unaltraweb_mcp/image_probe.py +149 -0
  40. data/src/unaltraweb_mcp/processes.py +146 -0
  41. metadata +15 -2
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """Run the packaged image background advisory without an MCP factory runtime."""
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7
+
8
+ from unaltraweb_mcp.image_backgrounds import main
9
+
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -45,11 +45,18 @@ DISPLAY_MATH_BLOCK_RE = re.compile(
45
45
  re.MULTILINE | re.DOTALL,
46
46
  )
47
47
  BASEURL_RE = re.compile(r"\{\{\s*site\.baseurl\s*\}\}")
48
+ KRAMDOWN_ATTRS = r'''\{:(?:[^{}"'\n]|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')*\}'''
49
+ CAPTION_SOURCE_ATTR_RE = re.compile(
50
+ r'''(?:\A|\s)data-caption-source\s*=\s*(?:"(?P<double>(?:\\.|[^"\\])*)"|'(?P<single>(?:\\.|[^'\\])*)'|(?P<bare>[^\s}]+))'''
51
+ )
48
52
  IMAGE_RE = re.compile(
49
53
  r"!\[(?P<alt>[^\]]*)\]\((?P<path>\S+?)(?:\s+(?:\"(?P<double_title>[^\"]*)\"|'(?P<single_title>[^']*)'))?\)"
50
- r"(?P<attrs>\{:[^}\n]*\})?"
54
+ rf"(?:[ \t]*(?P<attrs>{KRAMDOWN_ATTRS}))?"
55
+ )
56
+ TABLE_DIV_RE = re.compile(
57
+ rf'''^:::\s*table\s+(?P<quote>["'])(?P<caption>[^\n]+?)(?P=quote)(?:[ \t]+(?P<attrs>{KRAMDOWN_ATTRS}))?[ \t]*\n(?P<body>.*?)^:::\s*$''',
58
+ re.MULTILINE | re.DOTALL,
51
59
  )
52
- TABLE_DIV_RE = re.compile(r'^::: table\s+["\'](.+?)["\']\s*\n(.*?)^:::\s*$', re.MULTILINE | re.DOTALL)
53
60
  LISTING_DIV_RE = re.compile(
54
61
  r'^:::\s*listing\s+"(?P<caption>[^"]+)"\s*\n(?P<body>.*?)^:::\s*$',
55
62
  re.MULTILINE | re.DOTALL,
@@ -61,7 +68,7 @@ TABLE_GUARD_AFTER_HEADING_RE = re.compile(
61
68
  re.MULTILINE,
62
69
  )
63
70
  SUBFIGURES_DIV_RE = re.compile(
64
- r'^:::\s*subfigures(?:\s+(?P<layout>[^\s"]+))?(?:\s+"(?P<caption>[^"]*)")?\s*\n'
71
+ rf'^:::\s*subfigures(?:[ \t]+(?P<layout>[^\s"{{]+))?(?:[ \t]+"(?P<caption>[^"]*)")?(?:[ \t]+(?P<attrs>{KRAMDOWN_ATTRS}))?[ \t]*\n'
65
72
  r'(?P<body>.*?)^:::\s*$',
66
73
  re.MULTILINE | re.DOTALL,
67
74
  )
@@ -787,6 +794,60 @@ def resolve_visual_source(
787
794
  raise ManualPdfError(f"No printable SVG found for diagram source: {path}")
788
795
 
789
796
 
797
+ def caption_source_attribute(raw: str) -> tuple[str, str]:
798
+ """Consume the caption-only field before emitting image attributes."""
799
+ source = raw.strip().removeprefix("{:").removesuffix("}").strip()
800
+ match = CAPTION_SOURCE_ATTR_RE.search(source)
801
+ if not match:
802
+ return "", raw
803
+ credit = next(value for value in match.group("double", "single", "bare") if value is not None)
804
+ credit = re.sub(r'''\\(["'\\])''', r"\1", credit).strip()
805
+ remaining = (source[:match.start()] + " " + source[match.end():]).strip()
806
+ return credit, "{: " + remaining + "}" if remaining else ""
807
+
808
+
809
+ def caption_with_source(caption: str, source: str) -> str:
810
+ return caption + f" [{source}]{{.uw-caption-source}}" if source else caption
811
+
812
+
813
+ def image_destinations(markdown: str) -> list[str]:
814
+ """Find assets after transformation, including nested caption spans/cites.
815
+
816
+ The source IMAGE_RE cannot represent arbitrary nested inline brackets. Asset
817
+ freshness must still include every image consumed by Pandoc, including an
818
+ inline image nested in another image's caption.
819
+ """
820
+ paths = []
821
+ for opening in re.finditer(r"(?<!\\)!\[", markdown):
822
+ index, depth = opening.end(), 1
823
+ while index < len(markdown) and depth:
824
+ character = markdown[index]
825
+ if character == "\\":
826
+ index += 2
827
+ continue
828
+ if character == "[":
829
+ depth += 1
830
+ elif character == "]":
831
+ depth -= 1
832
+ index += 1
833
+ if depth or index >= len(markdown) or markdown[index] != "(":
834
+ continue
835
+ start = index = index + 1
836
+ parentheses = 0
837
+ while index < len(markdown):
838
+ character = markdown[index]
839
+ if character.isspace() or (character == ")" and not parentheses):
840
+ break
841
+ if character == "(":
842
+ parentheses += 1
843
+ elif character == ")":
844
+ parentheses -= 1
845
+ index += 1
846
+ if index > start:
847
+ paths.append(markdown[start:index])
848
+ return paths
849
+
850
+
790
851
  def pandoc_image_attributes(raw: str) -> str:
791
852
  source = raw.strip()
792
853
  if not source:
@@ -887,7 +948,9 @@ def transform_markdown(
887
948
  return "[" + "; ".join(f"@{key}" for key in keys) + "]"
888
949
 
889
950
  def table(match: re.Match[str]) -> str:
890
- body = match.group(2).strip()
951
+ body = match.group("body").strip()
952
+ credit, _ = caption_source_attribute(match.group("attrs") or "")
953
+ caption = caption_with_source(match.group("caption").strip(), credit)
891
954
  rows = [
892
955
  re.sub(r"\s+", " ", line.strip().strip("|"))
893
956
  for line in body.splitlines()
@@ -898,7 +961,7 @@ def transform_markdown(
898
961
  page_guard = r"\clearpage" if required_baselines >= 40 else f"\\Needspace{{{required_baselines}\\baselineskip}}"
899
962
  return (
900
963
  f"```{{=latex}}\n{page_guard}\n```\n\n"
901
- f"Table: {match.group(1).strip()}\n\n{body}"
964
+ f"Table: {caption}\n\n{body}"
902
965
  )
903
966
 
904
967
  def listing(match: re.Match[str]) -> str:
@@ -957,8 +1020,9 @@ def transform_markdown(
957
1020
  default_language=visual_default_language,
958
1021
  languages=[str(value) for value in configured_visual_languages],
959
1022
  )
960
- caption = title or alt
961
- attributes = pandoc_image_attributes(match.group("attrs") or "")
1023
+ credit, raw_attrs = caption_source_attribute(match.group("attrs") or "")
1024
+ caption = caption_with_source(title or alt, credit)
1025
+ attributes = pandoc_image_attributes(raw_attrs)
962
1026
  return f"![{caption}]({printable}){attributes}"
963
1027
 
964
1028
  def callout(match: re.Match[str]) -> str:
@@ -991,6 +1055,14 @@ def transform_markdown(
991
1055
  padding = " " if value.startswith("`") or value.endswith("`") else ""
992
1056
  return f"{fence}{padding}{value}{padding}{fence}{{=latex}}"
993
1057
 
1058
+ def credited_caption(caption: str, credit: str) -> str:
1059
+ # Leave credit-bearing captions as Pandoc inlines so citeproc and
1060
+ # links see them before the figure filter styles the credit span.
1061
+ # Separate an authored closing code fence from the generated raw
1062
+ # fence; adjacent backticks would change the Markdown tokenisation.
1063
+ return (raw_latex_inline(r"\caption[{") + caption + " " + raw_latex_inline("}]{")
1064
+ + caption_with_source(caption, credit) + raw_latex_inline("}"))
1065
+
994
1066
  images = list(IMAGE_RE.finditer(match.group("body")))
995
1067
  if not images:
996
1068
  raise ManualPdfError(f"Subfigures block contains no images in {source.relative_to(project)}")
@@ -1008,13 +1080,16 @@ def transform_markdown(
1008
1080
  )
1009
1081
 
1010
1082
  overall_caption = latex_caption(match.group("caption") or "")
1083
+ overall_credit, _ = caption_source_attribute(match.group("attrs") or "")
1011
1084
  figure_start = "```{=latex}\n\\begin{figure}[H]\n\\centering\n"
1012
- if overall_caption:
1085
+ if overall_caption and not overall_credit:
1013
1086
  figure_start += (
1014
1087
  f"\\caption{{{overall_caption}}}\n"
1015
1088
  "{\\color{ManualMuted!45}\\rule{\\linewidth}{0.35pt}}\\par\\medskip\n"
1016
1089
  )
1017
1090
  rendered = [figure_start + "```"]
1091
+ if overall_credit:
1092
+ rendered.append(credited_caption(match.group("caption") or "", overall_credit))
1018
1093
  image_index = 0
1019
1094
  max_image_height = f"{0.52 / len(row_sizes):.3f}".rstrip("0").rstrip(".")
1020
1095
  for row_index, row_size in enumerate(row_sizes):
@@ -1031,7 +1106,8 @@ def transform_markdown(
1031
1106
  default_language=visual_default_language,
1032
1107
  languages=[str(value) for value in configured_visual_languages],
1033
1108
  )
1034
- attributes = pandoc_image_attributes(item.group("attrs") or "")
1109
+ credit, raw_attrs = caption_source_attribute(item.group("attrs") or "")
1110
+ attributes = pandoc_image_attributes(raw_attrs)
1035
1111
  row.extend([
1036
1112
  raw_latex_inline(
1037
1113
  f"\\begin{{subfigure}}[t]{{{panel_width}\\linewidth}}"
@@ -1041,10 +1117,11 @@ def transform_markdown(
1041
1117
  f"![]({printable}){attributes}",
1042
1118
  ])
1043
1119
  separator = r"\hfill" if column_index < row_size - 1 else ""
1044
- row.append(raw_latex_inline(
1045
- f"\\caption{{{latex_caption(caption)}}}"
1046
- f"\\end{{subfigure}}{separator}"
1047
- ))
1120
+ if credit:
1121
+ row.append(credited_caption(caption, credit))
1122
+ else:
1123
+ row.append(raw_latex_inline(f"\\caption{{{latex_caption(caption)}}}"))
1124
+ row.append(raw_latex_inline(f"\\end{{subfigure}}{separator}"))
1048
1125
  rendered.append("".join(row))
1049
1126
  if row_index < len(row_sizes) - 1:
1050
1127
  rendered.append("```{=latex}\n\\par\\medskip\n```")
@@ -1246,7 +1323,9 @@ def assemble(project: Path, config: dict[str, Any], lang: str, paths: dict[str,
1246
1323
  metadata["include-home"] = includes_home
1247
1324
  metadata["has-listings"] = "data-listing-caption=" in assembled_markdown
1248
1325
  prose_markdown = FENCED_CODE_BLOCK_RE.sub("", assembled_markdown)
1249
- metadata["has-figures"] = bool(IMAGE_RE.search(prose_markdown) or r"\begin{figure}" in prose_markdown)
1326
+ # Transformed image captions can contain nested credit spans/citations;
1327
+ # IMAGE_RE is the source reader, not a parser for those Pandoc inlines.
1328
+ metadata["has-figures"] = bool(image_destinations(MARKDOWN_INLINE_CODE_RE.sub("", prose_markdown)) or r"\begin{figure}" in prose_markdown)
1250
1329
  metadata["has-tables"] = bool(re.search(r"^Table:\s+\S", prose_markdown, re.MULTILINE))
1251
1330
  return metadata, source_paths, assembled_markdown
1252
1331
 
@@ -1410,8 +1489,7 @@ def build_dependencies(project: Path, metadata: dict[str, Any], source_paths: li
1410
1489
  dependencies.append((f"asset:{path.relative_to(project)}", path))
1411
1490
  dependency_markdown = FENCED_CODE_BLOCK_RE.sub("", markdown)
1412
1491
  dependency_markdown = MARKDOWN_INLINE_CODE_RE.sub("", dependency_markdown)
1413
- for match in IMAGE_RE.finditer(dependency_markdown):
1414
- raw = match.group("path")
1492
+ for raw in image_destinations(dependency_markdown):
1415
1493
  if raw.startswith(("http://", "https://", "data:", "#")):
1416
1494
  continue
1417
1495
  local_path, _ = split_url_decoration(raw)
@@ -4,22 +4,53 @@ local function append_all(destination, values)
4
4
  end
5
5
  end
6
6
 
7
- function Figure(figure)
8
- if not FORMAT:match("latex") or #figure.caption.long == 0 then
9
- return nil
10
- end
11
-
12
- local caption = {pandoc.RawInline("latex", "\\caption{")}
13
- for index, block in ipairs(figure.caption.long) do
7
+ local function caption_inlines(caption)
8
+ local result = pandoc.Inlines({})
9
+ for index, block in ipairs(caption.long) do
14
10
  if index > 1 then
15
- table.insert(caption, pandoc.Space())
11
+ table.insert(result, pandoc.Space())
16
12
  end
17
13
  if block.content then
18
- append_all(caption, block.content)
14
+ append_all(result, block.content)
19
15
  else
20
- table.insert(caption, pandoc.Str(pandoc.utils.stringify(block)))
16
+ table.insert(result, pandoc.Str(pandoc.utils.stringify(block)))
17
+ end
18
+ end
19
+ return result
20
+ end
21
+
22
+ local function description_only(inlines)
23
+ local has_source = false
24
+ local description = inlines:walk({
25
+ Span = function(span)
26
+ if span.classes:includes("uw-caption-source") then
27
+ has_source = true
28
+ return {}
29
+ end
21
30
  end
31
+ })
32
+ while #description > 0 and (description[#description].t == "Space" or description[#description].t == "SoftBreak") do
33
+ table.remove(description)
34
+ end
35
+ return description, has_source
36
+ end
37
+
38
+ local function figure_caption(figure)
39
+ if not FORMAT:match("latex") or #figure.caption.long == 0 then
40
+ return nil
41
+ end
42
+
43
+ local full = caption_inlines(figure.caption)
44
+ local short, has_source = description_only(full)
45
+ local caption = {}
46
+ if has_source then
47
+ table.insert(caption, pandoc.RawInline("latex", "\\caption[{"))
48
+ append_all(caption, short)
49
+ table.insert(caption, pandoc.RawInline("latex", "}]{"))
50
+ else
51
+ table.insert(caption, pandoc.RawInline("latex", "\\caption{"))
22
52
  end
53
+ append_all(caption, full)
23
54
  table.insert(caption, pandoc.RawInline("latex", "}"))
24
55
  if figure.identifier and figure.identifier ~= "" then
25
56
  table.insert(caption, pandoc.RawInline("latex", "\\label{" .. figure.identifier .. "}"))
@@ -33,3 +64,27 @@ function Figure(figure)
33
64
  table.insert(blocks, pandoc.RawBlock("latex", "\\end{figure}"))
34
65
  return blocks
35
66
  end
67
+
68
+ local function table_caption(element)
69
+ if not FORMAT:match("latex") then
70
+ return nil
71
+ end
72
+ local short, has_source = description_only(caption_inlines(element.caption))
73
+ if has_source then
74
+ element.caption.short = short
75
+ return element
76
+ end
77
+ end
78
+
79
+ local function source_style(span)
80
+ if FORMAT:match("latex") and span.classes:includes("uw-caption-source") then
81
+ local inlines = {pandoc.RawInline("latex", "{\\itshape ")}
82
+ append_all(inlines, span.content)
83
+ table.insert(inlines, pandoc.RawInline("latex", "}"))
84
+ return inlines
85
+ end
86
+ end
87
+
88
+ -- Preserve semantic credit spans until short captions have been computed.
89
+ -- Citeproc runs before this filter, including citations inside the spans.
90
+ return {{Figure = figure_caption, Table = table_caption}, {Span = source_style}}
@@ -85,7 +85,8 @@
85
85
  singlelinecheck=false
86
86
  }
87
87
  \captionsetup[figure]{position=top,margin=1em,aboveskip=0pt,belowskip=7pt}
88
- \captionsetup[table]{position=top,margin=1em,aboveskip=0pt,belowskip=7pt}
88
+ % longtable uses the caption skip below a top caption; belowskip alone is ignored.
89
+ \captionsetup[table]{position=top,margin=1em,skip=7pt}
89
90
  \captionsetup[lstlisting]{position=top,margin=0pt,aboveskip=0pt,belowskip=7pt}
90
91
  \captionsetup[subfigure]{
91
92
  font={manualsubcaption,color=ManualMuted},
@@ -23,7 +23,7 @@ CONTRACT = json.loads((ROOT / "src/unaltraweb_mcp/component-contract.json").read
23
23
  RUNTIME_IMAGE = str(CONTRACT["components"]["runtime"]["reference"])
24
24
 
25
25
 
26
- def run(command: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]:
26
+ def run(command: list[str], *, cwd: Path = ROOT, expected: int = 0) -> subprocess.CompletedProcess[str]:
27
27
  completed = subprocess.run(
28
28
  command,
29
29
  cwd=cwd,
@@ -32,7 +32,7 @@ def run(command: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[
32
32
  stderr=subprocess.PIPE,
33
33
  check=False,
34
34
  )
35
- if completed.returncode != 0:
35
+ if completed.returncode != expected:
36
36
  raise RuntimeError(f"Command failed: {' '.join(command)}\n{completed.stdout}\n{completed.stderr}")
37
37
  return completed
38
38
 
@@ -61,12 +61,16 @@ def build(output: Path, *, source: Path = ROOT) -> str:
61
61
 
62
62
 
63
63
  def inspect_gem(path: Path) -> None:
64
+ editorial_files = ["scripts/editorial_check.py", "src/unaltraweb_mcp/editorial.py", "src/unaltraweb_mcp/editorial_sources.py"]
65
+ inspection_files = editorial_files + ["scripts/image_background_check.py", "src/unaltraweb_mcp/image_backgrounds.py",
66
+ "src/unaltraweb_mcp/image_probe.py", "src/unaltraweb_mcp/processes.py"]
64
67
  with tarfile.open(path, mode="r") as package:
65
68
  data_member = package.extractfile("data.tar.gz")
66
69
  if data_member is None:
67
70
  raise RuntimeError("Built gem has no data.tar.gz payload.")
68
71
  with tarfile.open(fileobj=io.BytesIO(data_member.read()), mode="r:gz") as payload:
69
72
  names = set(payload.getnames())
73
+ editorial_bytes = {name: payload.extractfile(name).read() for name in inspection_files}
70
74
  required = {
71
75
  "LICENSE",
72
76
  "README.md",
@@ -97,6 +101,7 @@ def inspect_gem(path: Path) -> None:
97
101
  "src/unaltraweb_mcp/component-contract.json",
98
102
  "src/unaltraweb_mcp/component-contract.schema.json",
99
103
  "src/unaltraweb_mcp/docker_mount.py",
104
+ *inspection_files,
100
105
  }
101
106
  missing = sorted(required - names)
102
107
  if missing:
@@ -104,6 +109,31 @@ def inspect_gem(path: Path) -> None:
104
109
  unexpected_data = sorted(name for name in names if name.startswith("_data/") and not name.startswith("_data/i18n/"))
105
110
  if unexpected_data:
106
111
  raise RuntimeError(f"Built gem contains non-runtime data files: {unexpected_data}")
112
+ # Exercise only the actual gem payload with isolated Python: no factory,
113
+ # wheel, inherited PYTHONPATH, source checkout or MCP dependency may help.
114
+ with tempfile.TemporaryDirectory(prefix="unaltraweb-gem-editorial-") as temporary:
115
+ core = Path(temporary) / "core"
116
+ for name, content in editorial_bytes.items():
117
+ target = core / name
118
+ target.parent.mkdir(parents=True, exist_ok=True)
119
+ target.write_bytes(content)
120
+ site = Path(temporary) / "site"
121
+ (site / "_pages/en").mkdir(parents=True)
122
+ (site / "_config.yml").write_text("unaltraweb:\n site_profile: unaltreselfie\n", encoding="utf-8")
123
+ page = site / "_pages/en/about.md"
124
+ page.write_text("I study spatial data.\n", encoding="utf-8")
125
+ command = [sys.executable, "-I", str(core / editorial_files[0]), "--project", str(site)]
126
+ if not json.loads(run(command, cwd=site).stdout)["ok"]:
127
+ raise RuntimeError("Gem-native editorial check rejected publication copy.")
128
+ page.write_text("As requested, I have added the biography.\n", encoding="utf-8")
129
+ if json.loads(run(command, cwd=site, expected=1).stdout)["ok"]:
130
+ raise RuntimeError("Gem-native editorial gate accepted chat-dependent copy.")
131
+ (site / "assets").mkdir()
132
+ (site / "assets/test.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="2" fill="blue"/></svg>', encoding="utf-8")
133
+ image_command = [sys.executable, "-I", str(core / "scripts/image_background_check.py"), "--project", str(site), "--source", "assets/test.svg"]
134
+ inspection = json.loads(run(image_command, cwd=site).stdout)
135
+ if not inspection["ok"] or inspection["images"][0]["state"] != "transparent" or not inspection["warnings"]:
136
+ raise RuntimeError(f"Gem-native SVG background inspection failed: {inspection}")
107
137
 
108
138
 
109
139
  def main() -> int:
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
 
14
14
 
15
15
  ROOT = Path(__file__).resolve().parents[1]
16
- IMAGE = os.environ.get("DOCKER_IMAGE", "ghcr.io/dosquartsdedocs/unaltraweb:0.4.0")
16
+ IMAGE = os.environ.get("DOCKER_IMAGE", "ghcr.io/dosquartsdedocs/unaltraweb:0.5.0")
17
17
  SELECTOR = "v2026.09"
18
18
  EPOCH = "946684800"
19
19
 
@@ -75,6 +75,10 @@ def main() -> int:
75
75
  "unaltraweb_mcp/component-contract.schema.json",
76
76
  "unaltraweb_mcp/manual_pdf_preview.py",
77
77
  "unaltraweb_mcp/manual_release.py",
78
+ "unaltraweb_mcp/editorial.py",
79
+ "unaltraweb_mcp/editorial_sources.py",
80
+ "unaltraweb_mcp/image_backgrounds.py",
81
+ "unaltraweb_mcp/image_probe.py",
78
82
  "unaltraweb_mcp/scaffolds/common/AGENTS.md.tmpl",
79
83
  "unaltraweb_mcp/scaffolds/common/Makefile.tmpl",
80
84
  "unaltraweb_mcp/scaffolds/common/README.md.tmpl",
@@ -266,6 +270,9 @@ def main() -> int:
266
270
  if set(manifest["files"]) != expected_managed:
267
271
  raise RuntimeError(f"generated {profile} scaffold baseline is incomplete: {manifest['files']}")
268
272
  sites[profile] = profile_site
273
+ checked = json.loads(run([str(cli), "--project", str(profile_site), "mcp", "prose-check"], cwd=temp).stdout)
274
+ if not checked["ok"] or checked["profile"] != profile:
275
+ raise RuntimeError(f"Package-only editorial check failed for {profile}: {checked}")
269
276
 
270
277
  site = sites["unaltredocs"]
271
278
  if not (site / ".unaltraweb/scaffold.json").is_file():
@@ -295,6 +302,9 @@ def main() -> int:
295
302
  detected = json.loads(run([str(cli), "--project", str(site), "mcp", "detect-site"], cwd=temp).stdout)
296
303
  if not detected["is_unaltraweb_site"]:
297
304
  raise RuntimeError(f"package-only inspection failed from clean wheel: {detected}")
305
+ backgrounds = json.loads(run([str(cli), "--project", str(site), "mcp", "image-background-check"], cwd=temp).stdout)
306
+ if not backgrounds["ok"] or not backgrounds["read_only"]:
307
+ raise RuntimeError(f"Factory-free background inspection failed: {backgrounds}")
298
308
  project_doctor = json.loads(run([str(cli), "doctor", "--project", str(site)], cwd=temp).stdout)
299
309
  if not project_doctor["ok"] or project_doctor["project"]["profile"] != "unaltredocs":
300
310
  raise RuntimeError(f"project doctor failed from clean wheel: {project_doctor}")
@@ -312,6 +322,24 @@ def main() -> int:
312
322
  scaffold = json.loads(run([str(cli), "--project", str(site), "mcp", "scaffold-sync"], cwd=temp).stdout)
313
323
  if not scaffold["ok"] or not scaffold["dry_run"]:
314
324
  raise RuntimeError(f"wheel scaffold sync dry-run failed: {scaffold}")
325
+ context = json.loads(run([str(cli), "--project", str(site), "mcp", "site-context"], cwd=temp).stdout)
326
+ if context["update_status"]["state"] != "current" or context["update_status"]["can_apply"]:
327
+ raise RuntimeError(f"wheel consumer update advisory failed: {context['update_status']}")
328
+ prepared = json.loads(run([str(cli), "--project", str(site), "mcp", "editorial-review-prepare"], cwd=temp).stdout)
329
+ report = {"id": "wheel-review", "source_digest": prepared["source_digest"], "reviewer": "Wheel smoke", "findings": []}
330
+ recorded = json.loads(run([
331
+ str(cli), "--project", str(site), "mcp", "editorial-review-record",
332
+ "--report-json", json.dumps(report), "--expected-revision", "0",
333
+ ], cwd=temp).stdout)
334
+ editorial = json.loads(run([str(cli), "--project", str(site), "mcp", "editorial-status"], cwd=temp).stdout)
335
+ if recorded["revision"] != 1 or editorial["reviews"]["wheel-review"]["stale"]:
336
+ raise RuntimeError("Factory-free wheel did not preserve a fresh editorial review.")
337
+ confirmed = json.loads(run([
338
+ str(cli), "--project", str(site), "mcp", "scaffold-sync", "--apply", "--confirm-sync",
339
+ "--expected-plan-sha256", scaffold["plan_sha256"],
340
+ ], cwd=temp).stdout)
341
+ if not confirmed["ok"] or not confirmed["applied"]:
342
+ raise RuntimeError(f"wheel reviewed scaffold synchronization failed: {confirmed}")
315
343
 
316
344
  factory_error = run([str(cli), "--project", str(site), "mcp", "prompts"], cwd=temp, expected=1)
317
345
  if "requires the unaltraweb factory checkout" not in factory_error.stderr:
@@ -10,7 +10,7 @@ If --project is omitted, MCP_CONSUMER_WORKSPACE is used, then the current direct
10
10
  USAGE
11
11
  }
12
12
 
13
- image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.4.0}"
13
+ image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.5.0}"
14
14
  project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
15
15
 
16
16
  while [ "$#" -gt 0 ]; do
@@ -71,6 +71,23 @@ def make_value(path: Path, variable: str) -> str:
71
71
  return match.group(1).strip('"\'') if match else ""
72
72
 
73
73
 
74
+ def component_version_errors(contract: dict[str, Any]) -> list[str]:
75
+ """Retain the real version of already-published, digest-pinned workers."""
76
+ version = str(contract["release"]["version"])
77
+ errors = []
78
+ reusable_workers = {"compute_python", "compute_r", "web_capture"}
79
+ for component_id in ["gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf"]:
80
+ selected = contract["components"][component_id]
81
+ if (component_id in reusable_workers and selected["kind"] == "container"
82
+ and selected["release_status"] == "released"):
83
+ # Digest/repository/version-tag semantics remain independently checked
84
+ # by component_contract_semantic_errors; this is not a mutable fallback.
85
+ continue
86
+ if str(selected["version"]) != version:
87
+ errors.append(f"{component_id} version does not match release {version}")
88
+ return errors
89
+
90
+
74
91
  def validate(root: Path = ROOT) -> list[str]:
75
92
  errors: list[str] = []
76
93
  contract = distribution_contract()
@@ -87,9 +104,7 @@ def validate(root: Path = ROOT) -> list[str]:
87
104
  errors.append("release-candidates.json must be excluded from Docker build contexts")
88
105
  if __version__ != version:
89
106
  errors.append(f"wheel version {__version__} != contract version {version}")
90
- for component_id in ["gem", "wheel", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf"]:
91
- if str(contract["components"][component_id]["version"]) != version:
92
- errors.append(f"{component_id} version does not match release {version}")
107
+ errors.extend(component_version_errors(contract))
93
108
  included = {name for name, item in contract["components"].items() if item["included_in_wheel"]}
94
109
  if included != {"wheel"}:
95
110
  errors.append(f"wheel must not bundle external components: {sorted(included)}")
@@ -2,16 +2,16 @@
2
2
  "$schema": "component-contract.schema.json",
3
3
  "schema_version": 1,
4
4
  "release": {
5
- "version": "0.4.0",
6
- "tag": "v0.4.0"
5
+ "version": "0.5.0",
6
+ "tag": "v0.5.0"
7
7
  },
8
8
  "consumer_integration": {
9
9
  "schema_version": 1,
10
10
  "core_repository": "https://github.com/dosquartsdedocs/unaltraweb.git",
11
- "core_sha": "f7ac29070167917dec6b314ba0a1ba9db089e7ec",
11
+ "core_sha": "02af70001bf8085af860fd57f8d7e75ec96a89c7",
12
12
  "site_deploy_workflow": "dosquartsdedocs/unaltraweb/.github/workflows/site-deploy.yml",
13
- "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:bb3e373f8a512495eeed684c23ad904d298c2e20a6dc3ade0d0a60c7ec9a9f11",
14
- "vegavisuals_sha": "44a2753ffbb7c0694a459db9744342f69c6e78b9"
13
+ "manual_pdf_image": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:9e0b3a45753c170b795e9a9d6df61580085c113436beac5bf6c8de69b6562097",
14
+ "vegavisuals_sha": "68c0b231402ae9485cc34ce530dc5239cb0ec194"
15
15
  },
16
16
  "receipt_contract": {
17
17
  "schema_version": 1,
@@ -28,7 +28,7 @@
28
28
  "role": "Modular control, scaffolding, and offline inspection plane for unaltraweb sites.",
29
29
  "package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
30
30
  "factory_required_commands": ["factory-dir"],
31
- "package_only_mcp": ["bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "html-audit", "http-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
31
+ "package_only_mcp": ["bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "editorial-policy", "editorial-publication-check", "editorial-review-prepare", "editorial-review-record", "editorial-review-resolve", "editorial-status", "html-audit", "http-check", "image-background-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "prose-check", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
32
32
  "factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
33
33
  "not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals"]
34
34
  },
@@ -36,10 +36,10 @@
36
36
  "gem": {
37
37
  "kind": "gem",
38
38
  "name": "unaltraweb",
39
- "version": "0.4.0",
40
- "release": "v0.4.0",
39
+ "version": "0.5.0",
40
+ "release": "v0.5.0",
41
41
  "release_status": "ready",
42
- "reference": "unaltraweb (= 0.4.0)",
42
+ "reference": "unaltraweb (= 0.5.0)",
43
43
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
44
44
  "included_in_wheel": false,
45
45
  "features": ["jekyll-core", "site-build"]
@@ -47,10 +47,10 @@
47
47
  "wheel": {
48
48
  "kind": "python-wheel",
49
49
  "name": "unaltraweb-mcp",
50
- "version": "0.4.0",
51
- "release": "v0.4.0",
50
+ "version": "0.5.0",
51
+ "release": "v0.5.0",
52
52
  "release_status": "ready",
53
- "reference": "unaltraweb-mcp==0.4.0",
53
+ "reference": "unaltraweb-mcp==0.5.0",
54
54
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
55
55
  "included_in_wheel": true,
56
56
  "features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane"]
@@ -58,10 +58,10 @@
58
58
  "runtime": {
59
59
  "kind": "container",
60
60
  "name": "unaltraweb runtime",
61
- "version": "0.4.0",
62
- "release": "v0.4.0",
61
+ "version": "0.5.0",
62
+ "release": "v0.5.0",
63
63
  "release_status": "ready",
64
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.4.0",
64
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.5.0",
65
65
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
66
66
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
67
67
  "included_in_wheel": false,
@@ -70,10 +70,10 @@
70
70
  "mcp": {
71
71
  "kind": "container",
72
72
  "name": "unaltraweb MCP runtime",
73
- "version": "0.4.0",
74
- "release": "v0.4.0",
73
+ "version": "0.5.0",
74
+ "release": "v0.5.0",
75
75
  "release_status": "ready",
76
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.4.0",
76
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.5.0",
77
77
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
78
78
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
79
79
  "included_in_wheel": false,
@@ -118,10 +118,10 @@
118
118
  "manual_pdf": {
119
119
  "kind": "container",
120
120
  "name": "unaltraweb manual PDF worker",
121
- "version": "0.4.0",
122
- "release": "v0.4.0",
123
- "release_status": "ready",
124
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:0.4.0",
121
+ "version": "0.5.0",
122
+ "release": "v0.5.0",
123
+ "release_status": "released",
124
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:9e0b3a45753c170b795e9a9d6df61580085c113436beac5bf6c8de69b6562097",
125
125
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf",
126
126
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
127
127
  "included_in_wheel": false,
@@ -130,10 +130,10 @@
130
130
  "diavisuals": {
131
131
  "kind": "companion",
132
132
  "name": "diavisuals",
133
- "version": "0.3.1",
134
- "release": "v0.3.1",
133
+ "version": "0.4.0",
134
+ "release": "v0.4.0",
135
135
  "release_status": "released",
136
- "reference": "https://github.com/dosquartsdedocs/diavisuals.git@v0.3.1",
136
+ "reference": "https://github.com/dosquartsdedocs/diavisuals/releases/download/v0.4.0/diavisuals-0.4.0-py3-none-any.whl#sha256=bfedcc9e2554f25ce4a1c33e556f352210848fccb8da800d1c3900dd86c48c93",
137
137
  "repository": "https://github.com/dosquartsdedocs/diavisuals",
138
138
  "included_in_wheel": false,
139
139
  "features": ["mermaid", "plantuml"]
@@ -141,10 +141,10 @@
141
141
  "vegavisuals": {
142
142
  "kind": "companion",
143
143
  "name": "vegavisuals",
144
- "version": "0.3.1",
145
- "release": "v0.3.1",
144
+ "version": "0.4.0",
145
+ "release": "v0.4.0",
146
146
  "release_status": "released",
147
- "reference": "https://github.com/dosquartsdedocs/vegavisuals.git@v0.3.1",
147
+ "reference": "https://github.com/dosquartsdedocs/vegavisuals/releases/download/v0.4.0/vegavisuals-0.4.0-py3-none-linux_x86_64.whl#sha256=b52ffa743643dd6b5e0320e7a9aa0cd500ea06262b7d5098c0c3f94a379bc0ea",
148
148
  "repository": "https://github.com/dosquartsdedocs/vegavisuals",
149
149
  "included_in_wheel": false,
150
150
  "features": ["vega", "vega-lite"]