oil-ui-pro 0.15.4 → 0.22.2(上游 ui.oiloil.org 正式包)+ 记 P0-98 跨盘 rename 坑

- oil-ui-pro 升到 0.22.2:references 13→28 份(新增 16、删 imagery.md)、assets 增 cards/(22 组)与 fonts/、SKILL.md 顶层五步结构未改名。
- 升级路径:官方 `npx github:oil-oil/oil-cli update` 稳定失败(error=replace、报「检查目录权限」)。
  真身为 EXDEV —— 解压在 os.tmpdir()(C 盘)、技能目录在 E 盘,Windows 上 rename 不能跨盘;
  CLI 的 catch 把非 CliError 原生异常一律兜底成 replaceFailed,真错误一个字不吐。
  做法=借其 prepare() 只做下载+校验,再 cp 到同盘 stage、rename 交换;官方命令复验 up_to_date。
- 回归:product-planning 按名引用的 visual-review.md/tools.md/design-direction.md/style-explorer.md 全部健在;命名闸门 2061|0|27。
- session-mechanism/references/pitfalls.md 新增 P0-98(含通则:更新器把原生错误兜底成指错方向的人话)。
This commit is contained in:
admin committed 2026-10-14 10:21:46 +08:00
1 parent 053a6a6723
commit d9445b5888
126 files changed
+10345 -1401

No files matched your search

+221 -61
View File
@@ -26,19 +26,30 @@ PREVIEW_CSP = (
)
def text_field(obj: dict, name: str, *, default: str | None = None) -> str:
class ManifestError(ValueError):
"""Every problem found in one pass, so the manifest can be fixed in one edit."""
def __init__(self, problems: list[str]):
self.problems = problems
if len(problems) == 1:
super().__init__(problems[0])
else:
super().__init__(f"manifest 有 {len(problems)} 处要改:\n" + "\n".join(f" {i}. {p}" for i, p in enumerate(problems, 1)))
def text_field(obj: dict, name: str, *, default: str | None = None, where: str = "") -> str:
value = obj.get(name, default)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{name} 必须是非空字符串")
raise ValueError(f"{where}{name} 必须是非空字符串")
return value.strip()
def text_list(obj: dict, name: str) -> list[str]:
def text_list(obj: dict, name: str, *, where: str = "") -> list[str]:
values = obj.get(name)
if not isinstance(values, list) or not values:
raise ValueError(f"{name} 必须是非空字符串数组")
raise ValueError(f"{where}{name} 必须是非空字符串数组")
if any(not isinstance(v, str) or not v.strip() for v in values):
raise ValueError(f"{name} 的每项必须是非空字符串")
raise ValueError(f"{where}{name} 的每项必须是非空字符串")
return [v.strip() for v in values]
@@ -161,11 +172,11 @@ def embed_local_files(content: str, base: Path, root: Path, used: set[Path]) ->
"""Inline images, fonts and videos referenced relative to the candidate, as long as they stay inside the manifest folder."""
def data_url(ref: str) -> str | None:
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
target = local_file(ref, base, root)
if target is None:
return None
target = (base / ref.split("?")[0].split("#")[0]).resolve()
mime = EMBEDDABLE.get(target.suffix.lower())
if not mime or not target.is_file() or not target.is_relative_to(root):
if not mime:
return None
used.add(target)
return f"data:{mime};base64," + base64.b64encode(target.read_bytes()).decode("ascii")
@@ -190,7 +201,9 @@ def local_file(ref: str, base: Path, root: Path) -> Path | None:
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
return None
target = (base / ref.split("?")[0].split("#")[0]).resolve()
return target if target.is_file() and target.is_relative_to(root) else None
if not target.is_relative_to(root):
raise ValueError(f"引用了 manifest 目录以外的文件 {ref};把文件复制进 manifest 目录或它的子目录,再更新相对路径")
return target if target.is_file() else None
def inline_local_code(content: str, base: Path, root: Path, used: set[Path], interactive: bool) -> str:
@@ -266,81 +279,172 @@ def prepare_image(path: Path) -> str:
LOOPBACK = {"localhost", "127.0.0.1", "::1"}
def local_url(value: str, identifier: str) -> str:
def local_url(value: str, label: str) -> str:
"""Live candidates only point at a dev server on this machine."""
try:
parts = urlsplit(value)
parts.port # Reject malformed ports before generating CSP origins.
except ValueError:
raise ValueError(f"{identifier}: url 必须是有效的本机 http(s) 地址") from None
raise ValueError(f"{label}必须是有效的本机 http(s) 地址,收到的是 {value}") from None
if parts.scheme not in ("http", "https") or (parts.hostname or "").lower() not in LOOPBACK or parts.username or parts.password:
raise ValueError(f"{identifier}: url 只能是本机开发服务器地址,例如 http://localhost:5173/orders")
raise ValueError(f"{label}只能是本机开发服务器地址,例如 http://localhost:5173/orders;收到的是 {value}")
return value
HEX_COLOR = re.compile(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
def native_viewport(value, where: str) -> dict:
"""Normalize a fixed canvas without changing manifests that omit it."""
dimensions = None
if isinstance(value, str):
match = re.fullmatch(r"\s*(\d+)\s*[xX×]\s*(\d+)\s*", value)
if match:
dimensions = [int(part) for part in match.groups()]
elif isinstance(value, dict):
dimensions = [value.get("width"), value.get("height")]
if dimensions and all(isinstance(n, (int, float)) and not isinstance(n, bool)
and 0 < n <= 2**53 - 1 and n == int(n) for n in dimensions):
return dict(zip(("width", "height"), map(int, dimensions)))
raise ValueError(f'{where}nativeViewport 必须是正整数宽高;收到的是 {json.dumps(value, ensure_ascii=False)};'
'例如 "1920x1080"、"1920×1080" 或 {"width":1920,"height":1080}')
def read_manifest(path: Path) -> dict:
if not path.is_file():
raise ValueError(f"找不到 manifest:{path}。先在任务目录里写好 manifest.json,字段见 references/style-explorer.md 的“准备输入”")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"manifest 不是合法的 JSON:第 {exc.lineno} 行第 {exc.colno} 列,{exc.msg}") from None
if not isinstance(raw, dict):
raise ValueError("manifest 的最外层必须是一个对象,里面有 schemaVersion、project、brief 和 candidates")
return raw
def load_manifest(path: Path) -> tuple[dict, set[Path]]:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
raise ValueError("manifest.schemaVersion 必须为 1")
data = {"schemaVersion": 1, "project": text_field(raw, "project"),
"brief": text_field(raw, "brief"), "round": text_field(raw, "round", default="01")}
raw = read_manifest(path)
problems: list[str] = []
def attempt(check):
"""Run one check; keep its message and carry on so the rest still gets checked."""
try:
return check()
except ValueError as exc:
problems.append(str(exc))
return None
if raw.get("schemaVersion") != 1:
problems.append("manifest.schemaVersion 必须为 1")
data = {"schemaVersion": 1}
for name, default in (("project", None), ("brief", None), ("round", "01")):
data[name] = attempt(lambda: text_field(raw, name, default=default, where="manifest."))
if "lang" in raw:
if raw["lang"] not in ("zh", "en"):
problems.append('manifest.lang 只能是 "zh" 或 "en"')
data["lang"] = raw["lang"]
if "serve" in raw:
serve = raw["serve"]
if not isinstance(serve, dict):
raise ValueError("serve 必须是对象")
if not isinstance(serve.get("command"), str) or not serve["command"].strip():
raise ValueError("serve.command 必须是非空字符串")
if "cwd" in serve and (not isinstance(serve["cwd"], str) or not Path(serve["cwd"]).is_absolute()):
raise ValueError("serve.cwd 必须是绝对路径")
if "url" in serve:
if not isinstance(serve["url"], str) or not serve["url"].strip():
raise ValueError("serve.url 必须是非空的本机 http(s) 地址")
local_url(serve["url"], "serve")
problems.append("serve 必须是对象,例如 {\"command\": \"pnpm dev\", \"cwd\": \"/项目的绝对路径\", \"url\": \"http://localhost:3000\"}")
else:
if not isinstance(serve.get("command"), str) or not serve["command"].strip():
problems.append("serve.command 必须是非空字符串")
if "cwd" in serve and (not isinstance(serve["cwd"], str) or not Path(serve["cwd"]).is_absolute()):
problems.append("serve.cwd 必须是绝对路径")
if "url" in serve:
if not isinstance(serve["url"], str) or not serve["url"].strip():
problems.append("serve.url 必须是非空的本机 http(s) 地址")
else:
attempt(lambda: local_url(serve["url"], "serve.url "))
data["serve"] = serve
candidates = raw.get("candidates")
if not isinstance(candidates, list) or not candidates:
raise ValueError("candidates 至少需要一个候选")
problems.append("candidates 至少需要一个候选")
candidates = []
seen = set()
inputs = {path, TEMPLATE.resolve()}
output = []
for candidate in candidates:
viewport = None
if "nativeViewport" in raw:
affected = "、".join(str(c.get("id", i)) for i, c in enumerate(candidates, 1)
if isinstance(c, dict) and "nativeViewport" not in c)
where = f"manifest(用于候选 {affected}):" if affected else "manifest."
viewport = attempt(lambda: native_viewport(raw["nativeViewport"], where))
for index, candidate in enumerate(candidates, 1):
if not isinstance(candidate, dict):
raise ValueError("每个候选必须是对象")
identifier = text_field(candidate, "id")
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", identifier) or identifier in seen:
raise ValueError(f"候选 id 无效或重复: {identifier}")
seen.add(identifier)
problems.append(f"第 {index} 个候选必须是对象")
continue
before = len(problems)
identifier = candidate.get("id")
if not isinstance(identifier, str) or not identifier.strip():
problems.append(f"第 {index} 个候选缺少 id")
identifier = None
else:
identifier = identifier.strip()
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", identifier):
problems.append(f'候选 id "{identifier}" 无效:只用小写字母、数字、连字符和下划线,以字母或数字开头,例如 "a" 或 "dir-2"')
elif identifier in seen:
problems.append(f'候选 id "{identifier}" 重复了,每个候选要用不同的 id')
seen.add(identifier)
where = f"候选 {identifier}:" if identifier else f"第 {index} 个候选:"
candidate_viewport = (attempt(lambda: native_viewport(candidate["nativeViewport"], where))
if "nativeViewport" in candidate else viewport)
kind = candidate.get("kind", "html")
if kind not in ("html", "image", "url"):
raise ValueError(f"未知候选 kind: {kind}")
problems.append(f'{where}kind 只能是 "html"、"image" 或 "url",收到的是 {json.dumps(kind, ensure_ascii=False)}')
kind = None
baseline = candidate.get("baseline", False)
interactive = candidate.get("interactive", False)
if not isinstance(baseline, bool) or not isinstance(interactive, bool):
raise ValueError(f"{identifier}: baseline 和 interactive 必须是 true 或 false")
if interactive and kind != "html":
raise ValueError(f"{identifier}: interactive 只用于 html 候选")
problems.append(f"{where}baseline 和 interactive 必须是 true 或 false")
elif interactive and kind not in ("html", None):
problems.append(f"{where}interactive 只用于 html 候选")
source = url = None
if kind == "url":
source = None
url = local_url(text_field(candidate, "url"), identifier)
else:
source = (path.parent / text_field(candidate, "source")).resolve()
if not source.is_relative_to(path.parent) or not source.is_file():
raise ValueError(f"候选 source 必须是 manifest 目录内可读文件: {identifier}")
inputs.add(source)
colors = text_list(candidate, "palette")
if any(not re.fullmatch(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", c) for c in colors):
raise ValueError(f"{identifier}: palette 需要十六进制颜色")
url = attempt(lambda: local_url(text_field(candidate, "url", where=where), f"{where}url "))
elif kind:
name = attempt(lambda: text_field(candidate, "source", where=where))
if name:
source = (path.parent / name).resolve()
if not source.is_relative_to(path.parent):
problems.append(f"{where}source 必须在 manifest 所在的目录里,收到的是 {name};把文件复制进 manifest 目录或它的子目录,再更新相对路径")
source = None
elif not source.is_file():
problems.append(f"{where}找不到 source 文件 {name}(按 manifest 所在的目录 {path.parent} 找)")
source = None
else:
inputs.add(source)
colors = attempt(lambda: text_list(candidate, "palette", where=where))
if colors:
wrong = [c for c in colors if not HEX_COLOR.fullmatch(c)]
if wrong:
problems.append(f'{where}palette 要写十六进制颜色,例如 "#1a2b3c";这几项不是:{"、".join(wrong)}')
texts = {name: attempt(lambda: text_field(candidate, name, where=where)) for name in ("name", "concept", "typography")}
traits = attempt(lambda: text_list(candidate, "traits", where=where))
if len(problems) > before:
continue
try:
content = url if kind == "url" else prepare_html(source, interactive, path.parent, inputs) if kind == "html" else prepare_image(source)
except ValueError as exc:
message = str(exc)
if message.startswith(f"{source.name}: "):
message = message[len(source.name) + 2:]
problems.append(f"{where}{source.name} {message}" if source else f"{where}{message}")
continue
output.append({
"id": identifier,
**{name: text_field(candidate, name) for name in ("name", "concept", "typography")},
"palette": colors, "traits": text_list(candidate, "traits"), "kind": kind,
"content": url if kind == "url" else prepare_html(source, interactive, path.parent, inputs) if kind == "html" else prepare_image(source),
"id": identifier, **texts,
"palette": colors, "traits": traits, "kind": kind,
"content": content,
"sourceLabel": url if kind == "url" else source.name,
"baseline": baseline,
"interactive": interactive,
**({"nativeViewport": candidate_viewport} if candidate_viewport else {}),
})
if sum(c["baseline"] for c in output) > 1:
raise ValueError("最多只能有一个基线候选")
problems.append("最多只能有一个基线候选(baseline: true)")
if problems:
raise ManifestError(problems)
# The current version always sits first so every direction is read against it.
output.sort(key=lambda c: not c["baseline"])
data["candidates"] = output
@@ -355,8 +459,11 @@ def build(manifest: Path, output: Path, *, force: bool = False) -> dict:
if output in inputs or output.is_relative_to(SKILL_ROOT):
raise ValueError("输出不能覆盖输入或写进 Skill 安装目录")
if output.exists() and not force:
raise FileExistsError("输出已存在;使用新路径,或明确加 --force 更新")
template = TEMPLATE.read_text(encoding="utf-8")
raise FileExistsError(f"输出已存在:{output}。要更新它就加 --force,要保留它就用 --output 换一个路径")
template = embed_local_files(TEMPLATE.read_text(encoding="utf-8"), TEMPLATE.parent, SKILL_ROOT, inputs)
skill_file = SKILL_ROOT / "SKILL.md"
skill = skill_file.read_text(encoding="utf-8") if skill_file.is_file() else ""
data["edition"] = "pro" if re.search(r"^name:\s*oil-ui-pro\s*$", skill, re.MULTILINE) else "open"
if template.count(MARKER) != 1:
raise ValueError("模板数据入口缺失或重复")
if template.count(CONNECT_CSP) != 1:
@@ -392,17 +499,70 @@ def build(manifest: Path, output: Path, *, force: bool = False) -> dict:
return {"output": str(output), "candidates": len(data["candidates"]), "fingerprint": data["fingerprint"], "bytes": output.stat().st_size}
def main() -> int:
HELP_EPILOG = """\
用法示例:
python3 build_explorer.py <manifest 所在目录>/manifest.json
生成 <manifest 所在目录>/style-explorer.html;已有同名文件时加 --force 覆盖,
或用 --output 换一个文件名。
manifest.json 最少要有:
{"schemaVersion": 1, "project": "<项目名>", "brief": "<所有候选共同的内容与任务>",
"candidates": [{"id": "a", "name": "<方向名>", "concept": "<一句体验意图>",
"typography": "<实际使用的字体关系>", "palette": ["#112233", "#f5f5f5"],
"traits": ["<看得见的特征>"], "source": "a.html"}]}
可选字段:
lang 顶层。"zh" 或 "en",只决定对比页外壳的语言;省略时按浏览器语言。
round 顶层。轮次,例如 "01",会出现在用户复制的选择里。
nativeViewport 固定画布的原生视口。写在顶层对所有候选生效,写在候选里只对它生效。
认 "1920x1080"、"1920×1080" 和 {"width": 1920, "height": 1080}。
省略时用默认视口:桌面 1280×900,手机 390×844。
kind 候选。"html"(默认)、"image"(source 指向 PNG、JPEG 或 WebP),
或 "url"(用 url 字段代替 source,指向本机开发服务器上的页面,
只认 localhost、127.0.0.1 和 ::1)。
serve 顶层。有 url 候选时写下启动方式:
{"command": "<启动命令>", "cwd": "<项目的绝对路径>", "url": "<服务地址>"}
interactive 候选。true 表示可操作的小样:单个 HTML,脚本内联。它不能联网,
也不能加载外部文件;浏览器存储在页面关闭后清空;alert、confirm
这类系统弹窗不会出现,需要确认时用页面内的对话框。
baseline 候选。true 表示现状,排在第一位,编号显示为“现状”,每轮最多一个。
候选文件的要求:
候选和它引用的图片、字体、视频,都放在 manifest 所在的目录或它的子目录里,
用相对于候选文件的路径引用,生成器会把它们内嵌进对比页;不引用网络资源。
HTML 候选是带 head 的完整文档,CSS 写在页面里;不运行脚本也要显示出内容
(interactive 和 url 候选除外)。
不认 iframe、object、embed,也不认 srcset 和 <picture><source>:
改成一张用相对路径引用的 <img>,或者直接用截图做图片候选。
id 用小写字母、数字、连字符或下划线,不重复;palette 用十六进制色值。
需要 Python 3.10 或更新版本。产物不能写进 Skill 目录。Skill 更新以后,
已经生成的对比页不会跟着变,用原来的 manifest 加 --force 重新生成。
写错时会把 manifest 里所有要改的地方一次列出来,旧的对比页不会被改动。
成功时在标准输出打印一行 JSON:output、candidates、fingerprint、bytes。"""
def utf8_output() -> None:
"""管道另一头的 Agent 按 UTF-8 读;控制台的代码页可能连中文都没有。"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure") and not stream.isatty():
stream.reconfigure(encoding="utf-8")
def main(argv: list[str] | None = None) -> int:
utf8_output()
if sys.version_info < (3, 10):
print("需要 Python 3.10 或更新版本", file=sys.stderr)
return 2
parser = argparse.ArgumentParser(description="把本地候选与 manifest 组装成独立风格对比 HTML")
parser.add_argument("manifest", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--force", action="store_true", help="明确允许原子更新已有输出")
args = parser.parse_args()
parser = argparse.ArgumentParser(description="把 manifest 里列出的候选页面组装成一个可以离线打开的风格对比页",
epilog=HELP_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("manifest", type=Path, help="manifest.json 的路径,候选文件和它放在同一个目录或子目录里")
parser.add_argument("--output", type=Path, help="输出的 HTML 路径;不写时是 manifest 旁边的 style-explorer.html")
parser.add_argument("--force", action="store_true", help="输出文件已经存在时覆盖它")
args = parser.parse_args(argv)
output = args.output or args.manifest.resolve().parent / "style-explorer.html"
try:
print(json.dumps(build(args.manifest, args.output, force=args.force), ensure_ascii=False))
print(json.dumps(build(args.manifest, output, force=args.force), ensure_ascii=False))
return 0
except (OSError, ValueError, TypeError) as exc:
print(f"未生成对比页:{exc}", file=sys.stderr)