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:
1 parent
053a6a6723
commit
d9445b5888
126 files changed
+10345
-1401
No files matched your search
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an offline gallery, including a 360x180 Natural Earth land bitmap.
|
||||
|
||||
Python 3.10+, standard library only. No network and no JS dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CARDS = ROOT / 'assets/cards'
|
||||
NAMES = ['particle-ring', 'flow-object', 'dot-globe', 'line-ribbons', 'showcase',
|
||||
'ui-slice', 'terminal', 'metric', 'beam-graph', 'keycaps', 'promo-band',
|
||||
'vector-space', 'file-mosaic', 'stream-window', 'portrait-story', 'capture-notes',
|
||||
'answer-lens', 'responsive-preview', 'deploy-preview']
|
||||
|
||||
|
||||
def land_mask() -> None:
|
||||
with zipfile.ZipFile(CARDS / 'shared/ne_110m_land.zip') as archive:
|
||||
data = archive.read('ne_110m_land.shp')
|
||||
polygons = []
|
||||
offset = 100
|
||||
while offset < len(data):
|
||||
length = struct.unpack_from('>i', data, offset + 4)[0] * 2
|
||||
record = data[offset + 8:offset + 8 + length]
|
||||
offset += 8 + length
|
||||
if struct.unpack_from('<i', record)[0] != 5:
|
||||
continue
|
||||
parts, count = struct.unpack_from('<ii', record, 36)
|
||||
starts = list(struct.unpack_from(f'<{parts}i', record, 44)) + [count]
|
||||
coords = list(struct.iter_unpack('<dd', record[44 + parts * 4:]))
|
||||
polygons.extend(coords[starts[i]:starts[i + 1]] for i in range(parts))
|
||||
width, height = 360, 180
|
||||
bits = bytearray(math.ceil(width * height / 8))
|
||||
# Scanline even/odd fill within each ring. Land data contains exterior rings.
|
||||
for y in range(height):
|
||||
latitude = 90 - (y + .5) * 180 / height
|
||||
for ring in polygons:
|
||||
hits = []
|
||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||
if (y1 > latitude) != (y2 > latitude):
|
||||
hits.append(x1 + (latitude - y1) * (x2 - x1) / (y2 - y1))
|
||||
hits.sort()
|
||||
for lo, hi in zip(hits[::2], hits[1::2]):
|
||||
start = max(0, math.ceil((lo + 180) / 360 * width - .5))
|
||||
end = min(width, math.ceil((hi + 180) / 360 * width - .5))
|
||||
for x in range(start, end):
|
||||
i = y * width + x
|
||||
bits[i // 8] |= 1 << (i % 8)
|
||||
encoded = base64.b64encode(bits).decode()
|
||||
(CARDS / 'shared/land-mask.js').write_text(
|
||||
'// Generated from Natural Earth 110m land (public domain).\n'
|
||||
f'export const LAND_WIDTH = {width};\nexport const LAND_HEIGHT = {height};\n'
|
||||
f'export const LAND_BITS = "{encoded}";\n', encoding='utf-8', newline='\n')
|
||||
|
||||
|
||||
def bundle(path: Path) -> str:
|
||||
source = path.read_text(encoding='utf-8')
|
||||
exports = re.findall(r'export\s+(?:const|let|function|class)\s+(\w+)', source)
|
||||
def replace_import(match: re.Match) -> str:
|
||||
target = (path.parent / match[2]).resolve().relative_to(CARDS).as_posix()
|
||||
return f'const {{{match[1]}}} = modules["{target}"];'
|
||||
source = re.sub(r'import\s*\{([^}]+)\}\s*from\s*[\'"]([^\'"]+)[\'"];?', replace_import, source)
|
||||
source = re.sub(r'\bexport\s+(?=const|let|function|class)', '', source)
|
||||
if re.search(r'^\s*(import|export)\b', source, re.M):
|
||||
raise ValueError(f'Unsupported module syntax: {path}')
|
||||
key = path.relative_to(CARDS).as_posix()
|
||||
return f'modules["{key}"] = (() => {{\n{source}\nreturn {{{", ".join(exports)}}};\n}})();'
|
||||
|
||||
|
||||
def utf8_output() -> None:
|
||||
"""管道另一头按 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) -> None:
|
||||
utf8_output()
|
||||
parser = argparse.ArgumentParser(
|
||||
description='维护卡片库时用:把 assets/cards/ 下各个卡片原型重新打包成 assets/cards/gallery.html。',
|
||||
epilog='不带参数运行。它会直接改写 Skill 目录里的 gallery.html,所以只在改过卡片原型的源文件之后运行;'
|
||||
'做设计时不需要它,直接打开 assets/cards/gallery.html 或读各原型目录里的 card.html、card.css。')
|
||||
parser.parse_args(argv)
|
||||
land_mask()
|
||||
html = (CARDS / 'gallery.template.html').read_text(encoding='utf-8')
|
||||
for name in NAMES:
|
||||
html = html.replace('{{' + name + '}}', (CARDS / name / 'card.html').read_text(encoding='utf-8'))
|
||||
css = '\n'.join((CARDS / p).read_text(encoding='utf-8') for p in
|
||||
['shared/shell.css', 'gallery.css'] + [f'{n}/card.css' for n in NAMES] + ['shared/finish.css'])
|
||||
paths = [CARDS / 'gallery-palette.js', CARDS / 'gallery-catalog.js', CARDS / 'shared/content.js', CARDS / 'shared/runtime.js', CARDS / 'shared/land-mask.js', CARDS / 'line-ribbons/surface.js', CARDS / 'flow-object/material.js'] + [CARDS / n / 'card.js' for n in NAMES]
|
||||
js = '(() => {\nconst modules = {};\n' + '\n'.join(bundle(p) for p in paths)
|
||||
js += '\n' + (CARDS / 'gallery-content.js').read_text(encoding='utf-8') + '\n' + (CARDS / 'gallery.js').read_text(encoding='utf-8') + '\n})();'
|
||||
html = html.replace('{{styles}}', css).replace('{{scripts}}', js.replace('</script', '<\\/script'))
|
||||
# Local image slots are embedded at build time, preserving file:// portability.
|
||||
def embed_image(match: re.Match) -> str:
|
||||
path = (CARDS / match[1]).resolve()
|
||||
path.relative_to(CARDS)
|
||||
mime = {'.jpg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp'}[path.suffix]
|
||||
return 'src="data:' + mime + ';base64,' + base64.b64encode(path.read_bytes()).decode() + '"'
|
||||
html = re.sub(r'src="((?:showcase|portrait-story)/images/[^"<>]+)"', embed_image, html)
|
||||
if re.search(r'\{\{[\w-]+\}\}', html):
|
||||
raise ValueError('Unresolved template slot')
|
||||
(CARDS / 'gallery.html').write_text(html, encoding='utf-8', newline='\n')
|
||||
print(f'Built assets/cards/gallery.html · {len(html.encode()):,} bytes · {len(NAMES)} templates')
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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)
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查本 Skill 有没有新版本,有就自动更新。
|
||||
"""检查本 Skill 有没有新版本;只有显式传入 --update 才更新。
|
||||
|
||||
由使用 Skill 触发,最多每 10 分钟联网检查一次,网络失败后一小时可重试。
|
||||
只读取 ui.oiloil.org 公开的版本列表,不上传项目内容。
|
||||
发现新版本时用 npx github:oil-oil/oil-cli 原地更新本目录:
|
||||
发现新版本时默认提示;用户要求更新后,用固定提交的 CLI 原地更新本目录:
|
||||
成功打印一行“已自动更新”,不能自动更新时打印一行提示,条件不变时每天最多提示一次;
|
||||
没有新版本、网络失败或在开发目录(含 .git)里运行时什么也不输出。
|
||||
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 只提示不自动更新。
|
||||
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 强制只提示。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -24,7 +26,13 @@ from typing import NamedTuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
API = os.environ.get("OIL_API", "https://ui.oiloil.org").rstrip("/")
|
||||
CLI = "github:oil-oil/oil-cli"
|
||||
# 固定经过检查的 CLI:无 npm 依赖,下载包必须通过 SHA-256 与归档路径校验。
|
||||
CLI = "github:oil-oil/oil-cli#b6fb811d42a72d327e5060518347b125d9a098ca"
|
||||
UPDATE_ENV_KEYS = {
|
||||
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
|
||||
"SYSTEMROOT", "WINDIR", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_MESSAGES",
|
||||
"OIL_API", "OIL_TOKEN",
|
||||
}
|
||||
DAY = 24 * 3600
|
||||
# 发版后用户下一次使用就能更新;版本列表有服务端缓存,频繁检查的成本很低
|
||||
CHECK_INTERVAL = 10 * 60
|
||||
@@ -104,18 +112,8 @@ def fetch(name: str) -> dict | None:
|
||||
entry = json.load(response).get("skills", {}).get(name)
|
||||
if not isinstance(entry, dict) or not re.fullmatch(r"\d+\.\d+\.\d+", str(entry.get("latest", ""))):
|
||||
return None
|
||||
history = entry.get("history") or []
|
||||
notes = next((h.get("notes", "") for h in history if h.get("version") == entry["latest"]), "")
|
||||
# 免费版的版本信息带公开下载地址,付费版没有
|
||||
return {"latest": entry["latest"], "notes": notes, "free": bool(entry.get("download_url"))}
|
||||
|
||||
|
||||
def headline(notes: str) -> str:
|
||||
for line in notes.splitlines():
|
||||
line = re.sub(r"^[#>*\-\s]+", "", line).strip().rstrip("。.;;,,")
|
||||
if line:
|
||||
return line if len(line) <= 60 else line[:59] + "…"
|
||||
return ""
|
||||
return {"latest": entry["latest"], "free": bool(entry.get("download_url"))}
|
||||
|
||||
|
||||
def file_signature(path: str | Path | None) -> list:
|
||||
@@ -128,14 +126,19 @@ def file_signature(path: str | Path | None) -> list:
|
||||
return [str(path)]
|
||||
|
||||
|
||||
def update_context() -> tuple[str, str | None, bool]:
|
||||
def update_environment() -> dict[str, str]:
|
||||
"""只给已获准运行的 CLI 传必要路径、语言与其自身授权,不传无关密钥或注入选项。"""
|
||||
return {**{k: v for k, v in os.environ.items() if k.upper() in UPDATE_ENV_KEYS}, "CI": "1"}
|
||||
|
||||
|
||||
def update_context(approved: bool = False) -> tuple[str, str | None, bool]:
|
||||
"""只存指纹;登录配置变化或补齐依赖时不沿用旧冷却。"""
|
||||
npx = shutil.which("npx")
|
||||
node = shutil.which("node")
|
||||
node_version = ""
|
||||
if node:
|
||||
if node and approved:
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=3)
|
||||
result = subprocess.run([node, "--version"], env=update_environment(), capture_output=True, text=True, timeout=3, encoding="utf-8")
|
||||
if result.returncode == 0:
|
||||
node_version = result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
@@ -144,7 +147,7 @@ def update_context() -> tuple[str, str | None, bool]:
|
||||
ready = bool(npx and version and int(version.group(1)) >= 18)
|
||||
context = [API, file_signature(config_path()), os.environ.get("OIL_TOKEN", ""),
|
||||
file_signature(npx), file_signature(node), node_version,
|
||||
bool(os.environ.get("OIL_NO_AUTO_UPDATE"))]
|
||||
bool(os.environ.get("OIL_NO_AUTO_UPDATE")), approved, CLI]
|
||||
fingerprint = hashlib.sha256(json.dumps(context).encode()).hexdigest()
|
||||
return fingerprint, npx, ready
|
||||
|
||||
@@ -166,19 +169,19 @@ def cli_error(stdout: str, stderr: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool) -> UpdateResult:
|
||||
if os.environ.get("OIL_NO_AUTO_UPDATE"):
|
||||
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool, approved: bool = False) -> UpdateResult:
|
||||
if not approved or os.environ.get("OIL_NO_AUTO_UPDATE"):
|
||||
return UpdateResult(False, "manual")
|
||||
if not (free or logged_in()):
|
||||
return UpdateResult(False, "unauthorized")
|
||||
if not ready:
|
||||
return UpdateResult(False, "dependencies")
|
||||
# CI=1 让命令行在没登录时直接失败,不会停下来等浏览器确认
|
||||
env = {**os.environ, "CI": "1"}
|
||||
env = update_environment()
|
||||
try:
|
||||
result = subprocess.run([npx, "-y", CLI, "update", name, "--path", str(ROOT), "--json"], env=env,
|
||||
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=UPDATE_TIMEOUT, check=False)
|
||||
timeout=UPDATE_TIMEOUT, check=False, encoding="utf-8", errors="replace")
|
||||
except FileNotFoundError:
|
||||
return UpdateResult(False, "dependencies")
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -200,15 +203,14 @@ def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool
|
||||
|
||||
|
||||
def english() -> bool:
|
||||
return (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("en")
|
||||
# 只有中文环境用中文,其他语言和未设置时都用英文;宿主会再按对话语言转述。
|
||||
return not (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("zh")
|
||||
|
||||
|
||||
def update_command(name: str) -> str:
|
||||
# 双引号方便直接复制,也保护 POSIX shell 中的变量与命令替换。
|
||||
path = str(ROOT)
|
||||
if os.name != "nt":
|
||||
path = re.sub(r'([\\"$`])', r'\\\1', path)
|
||||
return f'npx {CLI} update {name} --path "{path}"'
|
||||
# 从同一入口执行,保留固定 CLI 和环境白名单;不让宿主自行拼接 npx 命令。
|
||||
args = [sys.executable, str(ROOT / "scripts" / "check_update.py"), "--update"]
|
||||
return subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args)
|
||||
|
||||
|
||||
def product_name(name: str) -> str:
|
||||
@@ -232,7 +234,7 @@ def notice(name: str, current: str, latest: str, detail: str, reason: str) -> st
|
||||
if reason == "unauthorized":
|
||||
return intro + f"Authorization has expired or is missing. Run npx {CLI} login, then run {command}."
|
||||
if reason == "inactive":
|
||||
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/pro/ to get updates, then run {command}."
|
||||
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/en/pro/ to get updates, then run {command}."
|
||||
if reason == "dependencies":
|
||||
return intro + f"Automatic updates need Node.js 18 or later. Install it, then run {command}."
|
||||
return intro + f"To update, run {command}."
|
||||
@@ -246,7 +248,18 @@ def notice(name: str, current: str, latest: str, detail: str, reason: str) -> st
|
||||
return intro + f"在终端运行 {command} 即可更新。"
|
||||
|
||||
|
||||
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() -> int:
|
||||
utf8_output()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--update", action="store_true", help="Explicitly authorize updating this installation")
|
||||
approved = parser.parse_args().update
|
||||
if os.environ.get("OIL_NO_UPDATE_CHECK") or (ROOT / ".git").exists():
|
||||
return 0
|
||||
skill = read_skill()
|
||||
@@ -255,8 +268,11 @@ def main() -> int:
|
||||
name, current = skill
|
||||
path = state_path(name)
|
||||
# 原来的共享文件可能包含失败与提示记录,迁移时只保留公开缓存。
|
||||
cache = {key: value for key, value in load(path).items()
|
||||
if key in {"checked_at", "latest", "notes", "free", "fetch_failed_at"}}
|
||||
previous_cache = load(path)
|
||||
cache = {key: value for key, value in previous_cache.items()
|
||||
if key in {"checked_at", "latest", "free", "fetch_failed_at"}}
|
||||
if cache != previous_cache:
|
||||
save(path, cache)
|
||||
install_path = installation_state_path(name)
|
||||
state = load(install_path)
|
||||
now = time.time()
|
||||
@@ -281,15 +297,14 @@ def main() -> int:
|
||||
latest = cache.get("latest")
|
||||
if not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+", latest) or parse(latest) <= parse(current):
|
||||
return 0
|
||||
summary = headline(cache.get("notes", ""))
|
||||
detail = (f": {summary}" if english() else f":{summary}") if summary else ""
|
||||
context, npx, ready = update_context()
|
||||
detail = ""
|
||||
context, npx, ready = update_context(approved)
|
||||
|
||||
reason = state.get("auto_failed_reason", "failed")
|
||||
cooldown = RETRY_AFTER_FAILURE if reason == "network" else DAY
|
||||
if (state.get("auto_failed_version") != latest or state.get("auto_failed_context") != context
|
||||
or now - float(state.get("auto_failed_at", 0)) >= cooldown):
|
||||
result = auto_update(name, latest, bool(cache.get("free")), npx, ready)
|
||||
result = auto_update(name, latest, bool(cache.get("free")), npx, ready, approved)
|
||||
reason = result.reason
|
||||
if reason == "updated":
|
||||
save(install_path, {})
|
||||
|
||||
@@ -9,14 +9,14 @@ marker="$state_base/oil/update-python-missing"
|
||||
for interpreter in python3 python; do
|
||||
if command -v "$interpreter" >/dev/null 2>&1 && "$interpreter" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then
|
||||
rmdir "$marker" 2>/dev/null
|
||||
exec "$interpreter" "$script_dir/check_update.py"
|
||||
exec "$interpreter" "$script_dir/check_update.py" "$@"
|
||||
fi
|
||||
done
|
||||
# mkdir 的独占创建同时防止连续加载与并发加载重复提醒。
|
||||
missing_python_notice() {
|
||||
case ${LC_ALL:-${LC_MESSAGES:-$LANG}} in
|
||||
en*|EN*) printf '%s\n' 'Automatic updates need Python 3. This update check did not run.' ;;
|
||||
*) printf '%s\n' '自动更新需要 Python 3,本次没有检查更新。' ;;
|
||||
zh*|ZH*) printf '%s\n' '版本检查需要 Python 3,本次没有检查更新。' ;;
|
||||
*) printf '%s\n' 'Version checks need Python 3. This update check did not run.' ;;
|
||||
esac
|
||||
}
|
||||
mkdir -p "$state_base/oil" 2>/dev/null
|
||||
|
||||
+1593
-209
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user