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.
- checksums.yaml +4 -4
- data/Makefile +5 -5
- data/README.md +19 -7
- data/_plugins/figure_captions.rb +47 -10
- data/_sass/_documentation.scss +7 -5
- data/_sass/_manual.scss +7 -0
- data/docs/_documentation/en/02-tools.md +4 -4
- data/docs/_documentation/en/03-usage.md +61 -0
- data/docs/_documentation/en/13-unaltremanual.md +1 -1
- data/docs/_documentation/en/20-syntax.md +14 -0
- data/docs/_documentation/en/25-caption-credits.md +120 -0
- data/docs/_documentation/en/26-image-backgrounds.md +103 -0
- data/docs/_documentation/en/31-template.md +1 -1
- data/docs/_documentation/en/32-development.md +1 -1
- data/docs/_documentation/en/40-distribution.md +25 -5
- data/docs/_documentation/en/42-docker-image.md +7 -7
- data/docs/_documentation/en/43-workspace-path-policies.md +232 -0
- data/docs/_documentation/en/44-editorial-review.md +237 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +10 -5
- data/docs/agents/action-prompts/22-manual-style-audit.txt +3 -1
- data/docs/agents/manual-authoring-components.md +38 -0
- data/docs/agents/mcp-contract.md +102 -10
- data/docs/agents/visual-companions-0.4.0.md +74 -0
- data/docs/assets/img/caption-credits-demo.svg +19 -0
- data/scripts/editorial_check.py +12 -0
- data/scripts/image_background_check.py +12 -0
- data/scripts/manual/build_pdf.py +94 -16
- data/scripts/manual/filters/figure-captions.lua +65 -10
- data/scripts/manual/templates/manual.tex +2 -1
- data/scripts/test_gem_build.py +32 -2
- data/scripts/test_reproducible_jekyll_build.py +1 -1
- data/scripts/test_wheel_install.py +28 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
- data/scripts/validate_distribution.py +18 -3
- data/src/unaltraweb_mcp/component-contract.json +28 -28
- data/src/unaltraweb_mcp/editorial.py +495 -0
- data/src/unaltraweb_mcp/editorial_sources.py +504 -0
- data/src/unaltraweb_mcp/image_backgrounds.py +334 -0
- data/src/unaltraweb_mcp/image_probe.py +149 -0
- data/src/unaltraweb_mcp/processes.py +146 -0
- 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())
|
data/scripts/manual/build_pdf.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
961
|
-
|
|
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"{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
|
-
|
|
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"{attributes}",
|
|
1042
1118
|
])
|
|
1043
1119
|
separator = r"\hfill" if column_index < row_size - 1 else ""
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
11
|
+
table.insert(result, pandoc.Space())
|
|
16
12
|
end
|
|
17
13
|
if block.content then
|
|
18
|
-
append_all(
|
|
14
|
+
append_all(result, block.content)
|
|
19
15
|
else
|
|
20
|
-
table.insert(
|
|
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
|
-
|
|
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},
|
data/scripts/test_gem_build.py
CHANGED
|
@@ -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 !=
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
6
|
-
"tag": "v0.
|
|
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": "
|
|
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:
|
|
14
|
-
"vegavisuals_sha": "
|
|
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.
|
|
40
|
-
"release": "v0.
|
|
39
|
+
"version": "0.5.0",
|
|
40
|
+
"release": "v0.5.0",
|
|
41
41
|
"release_status": "ready",
|
|
42
|
-
"reference": "unaltraweb (= 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.
|
|
51
|
-
"release": "v0.
|
|
50
|
+
"version": "0.5.0",
|
|
51
|
+
"release": "v0.5.0",
|
|
52
52
|
"release_status": "ready",
|
|
53
|
-
"reference": "unaltraweb-mcp==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.
|
|
62
|
-
"release": "v0.
|
|
61
|
+
"version": "0.5.0",
|
|
62
|
+
"release": "v0.5.0",
|
|
63
63
|
"release_status": "ready",
|
|
64
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb: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.
|
|
74
|
-
"release": "v0.
|
|
73
|
+
"version": "0.5.0",
|
|
74
|
+
"release": "v0.5.0",
|
|
75
75
|
"release_status": "ready",
|
|
76
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp: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.
|
|
122
|
-
"release": "v0.
|
|
123
|
-
"release_status": "
|
|
124
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-manual-pdf:
|
|
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.
|
|
134
|
-
"release": "v0.
|
|
133
|
+
"version": "0.4.0",
|
|
134
|
+
"release": "v0.4.0",
|
|
135
135
|
"release_status": "released",
|
|
136
|
-
"reference": "https://github.com/dosquartsdedocs/diavisuals
|
|
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.
|
|
145
|
-
"release": "v0.
|
|
144
|
+
"version": "0.4.0",
|
|
145
|
+
"release": "v0.4.0",
|
|
146
146
|
"release_status": "released",
|
|
147
|
-
"reference": "https://github.com/dosquartsdedocs/vegavisuals
|
|
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"]
|