hd-garden 0.14.8 → 0.14.11

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.
@@ -1,20 +1,40 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef } from "react";
1
4
  import { splitTitle, titleLineScale } from "../../ui/garden/title.mjs";
5
+ import { mountArticleTitle } from "../../ui/garden/article-title-fit.mjs";
6
+ import fitRuntime from "../../ui/garden/feature-title-fit.mjs?raw";
7
+ import articleRuntime from "../../ui/garden/article-title-fit.mjs?raw";
8
+
9
+ // Preserve one authored runtime across SSR/browser builds; function.toString()
10
+ // would be changed independently by server and client optimizers.
11
+ const ARTICLE_TITLE_BOOT = `(()=>{${fitRuntime.replace(/^export /gm, "")}
12
+ ${articleRuntime.replace(/^import\s+[^;]+;\s*/u, "").replace(/^export /gm, "")}
13
+ mountArticleTitle(document.currentScript.parentElement);})();`;
2
14
 
3
15
  export function ArticleTitle({ title, className = "" }: { title: string; className?: string }) {
16
+ const heading = useRef<HTMLHeadingElement>(null);
4
17
  const split = splitTitle(title);
18
+ useLayoutEffect(() => mountArticleTitle(heading.current?.parentElement), [title]);
5
19
  if (!split.split) {
6
20
  return <h1 className={`p-name${className ? ` ${className}` : ""}`}>{title}</h1>;
7
21
  }
8
22
  const scale = titleLineScale(split.lines);
9
23
  return (
10
- <h1 className={`p-name h1-br${className ? ` ${className}` : ""}`} style={{ ["--l" as string]: scale }}>
11
- <span className="h1-line">{split.lines[0]}</span>
12
- <span className="vh">{split.separator}</span>
13
- <span className="h1-line">{split.lines[1]}</span>
14
- </h1>
24
+ <>
25
+ <h1
26
+ id="at"
27
+ ref={heading}
28
+ data-article-title=""
29
+ className={`p-name h1-br${className ? ` ${className}` : ""}`}
30
+ style={{ ["--l" as string]: scale }}
31
+ >
32
+ <span className="h1-line">{split.lines[0]}</span>
33
+ <span className="vh">{split.separator}</span>
34
+ <span className="h1-line">{split.lines[1]}</span>
35
+ </h1>
36
+ <style data-article-title-rules="">{""}</style>
37
+ <script data-article-title-boot="" dangerouslySetInnerHTML={{ __html: ARTICLE_TITLE_BOOT }} />
38
+ </>
15
39
  );
16
40
  }
17
-
18
- export function originalUrl(entry: { original?: unknown }) {
19
- return typeof entry.original === "string" && /^https?:\/\//iu.test(entry.original) ? entry.original : "";
20
- }
@@ -2,7 +2,8 @@ import Link from "next/link";
2
2
  import { CoverImage } from "./CoverImage";
3
3
  import { ArticleMeasure } from "./ArticleMeasure";
4
4
  import { ArticleReferences } from "./ArticleReferences";
5
- import { ArticleTitle, originalUrl } from "./ArticleTitle";
5
+ import { ArticleTitle } from "./ArticleTitle";
6
+ import { originalUrl } from "../../ui/garden/title.mjs";
6
7
  import { MarkdownArticle } from "./MarkdownArticle";
7
8
  import { TableOfContents } from "./TableOfContents";
8
9
  import { Comments } from "./Comments";
@@ -15,7 +15,7 @@ import { usePathname } from "next/navigation";
15
15
  import { nestHeadings, expandedTocSections, toggleTocSection, type TocHeading, type TocNode, type TocExpansion } from "../lib/toc-expansion";
16
16
  import { createPortal } from "react-dom";
17
17
  import { TOC_PANEL_ID, TOC_CONTROL_ID, TOC_CLOSED_ID } from "../lib/reading-navigation";
18
- import { X } from "@phosphor-icons/react";
18
+ import { CaretUp, CaretDown, X } from "@phosphor-icons/react";
19
19
  import { ui, type UiLang } from "../lib/i18n";
20
20
 
21
21
  type TocProps = { headings: TocHeading[]; label?: string; lang?: UiLang };
@@ -457,6 +457,18 @@ function ContentsSession({
457
457
  >
458
458
  <div className="toc-header">
459
459
  <p>{label}</p>
460
+ {tree.some((node) => node.children.length > 0) ? <div className="toc-mode-controls">
461
+ <button
462
+ type="button"
463
+ data-toc-mode-toggle=""
464
+ aria-controls={listId}
465
+ aria-label={expansion.mode === "follow" ? t.tocExpandAll : t.tocResumeFollow}
466
+ title={expansion.mode === "follow" ? t.tocExpandAll : t.tocResumeFollow}
467
+ onClick={() => setExpansion({ mode: expansion.mode === "follow" ? "all" : "follow" })}
468
+ >
469
+ {expansion.mode === "follow" ? <CaretDown size={14} aria-hidden="true" /> : <CaretUp size={14} aria-hidden="true" />}
470
+ </button>
471
+ </div> : null}
460
472
  <button
461
473
  ref={mobileCloseRef}
462
474
  className="toc-mobile-close"
@@ -470,11 +482,6 @@ function ContentsSession({
470
482
  <X size={18} aria-hidden="true" />
471
483
  </a>
472
484
  </div>
473
- {tree.some((node) => node.children.length > 0) ? <div className="toc-mode-controls" role="group" aria-label={t.tocDisplay}>
474
- <button type="button" data-toc-follow="" aria-controls={listId} aria-pressed={expansion.mode === "follow"} onClick={() => setExpansion({ mode: "follow" })}>{t.tocFollow}</button>
475
- <button type="button" data-toc-expand-all="" aria-controls={listId} aria-pressed={expansion.mode === "all"} onClick={() => setExpansion({ mode: "all" })}>{t.tocExpandAll}</button>
476
- <span className="toc-mode-status" role="status">{expansion.mode === "manual" ? t.tocManual : ""}</span>
477
- </div> : null}
478
485
  <ol id={listId} ref={listRef} className="toc-list" role="list">
479
486
  {tree.map((node, index) => renderNode(node, [index]))}
480
487
  </ol>
package/app/globals.css CHANGED
@@ -1735,6 +1735,8 @@ button {
1735
1735
  }
1736
1736
 
1737
1737
  .toc-header > p {
1738
+ flex: 1 1 auto;
1739
+ min-width: 0;
1738
1740
  margin: 0;
1739
1741
  color: var(--accent);
1740
1742
  font-size: 12px;
@@ -1810,12 +1812,9 @@ button {
1810
1812
 
1811
1813
  .toc-list > .toc-section > .toc-item-row > a { font-weight: 600; }
1812
1814
  .toc-sublist > .toc-section > .toc-item-row > a { font-weight: 400; }
1813
- .toc-mode-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; flex: 0 0 auto; margin-bottom: 10px; }
1814
- .toc-mode-controls button { min-height: 32px; padding: 4px 0; border: 0; background: transparent; color: var(--muted); font: inherit; cursor: pointer; }
1815
- .toc-mode-controls button[aria-pressed="true"] { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 4px; }
1815
+ .toc-mode-controls { display: flex; align-items: center; flex: 0 0 auto; margin-left: auto; }
1816
+ .toc-mode-controls button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; background: transparent; color: var(--muted); cursor: pointer; touch-action: manipulation; }
1816
1817
  .toc-mode-controls button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
1817
- .toc-mode-status { color: var(--muted); }
1818
- .toc-mode-status:empty { display: none; }
1819
1818
 
1820
1819
  .toc a:hover {
1821
1820
  color: var(--ink);
@@ -3872,7 +3871,6 @@ html[data-theme="dark"] .search-footer {
3872
3871
  panel, because the post layout's desktop rule overrides the panel's
3873
3872
  padding on post pages but not on column pages. */
3874
3873
  .toc .toc-header,
3875
- .toc .toc-mode-controls,
3876
3874
  .toc .toc-list {
3877
3875
  padding-left: max(20px, env(safe-area-inset-left));
3878
3876
  padding-right: max(20px, env(safe-area-inset-right));
@@ -3880,7 +3878,7 @@ html[data-theme="dark"] .search-footer {
3880
3878
 
3881
3879
  .toc .toc-item-row > a { display: flex; align-items: center; min-height: 40px; padding-block: 4px; }
3882
3880
  .toc .toc-section-toggle { min-width: 40px; min-height: 40px; }
3883
- .toc .toc-mode-controls button { min-height: 44px; }
3881
+ .toc .toc-mode-controls button { width: 44px; height: 44px; }
3884
3882
 
3885
3883
  .toc .toc-list .toc-section {
3886
3884
  margin: 2px 0;
package/app/lib/i18n.ts CHANGED
@@ -38,10 +38,8 @@ const zh = {
38
38
  columnNewestFirst: (title: string | undefined) => `${title},从新到旧`,
39
39
  toc: "本篇目录",
40
40
  tocShort: "目录",
41
- tocDisplay: "目录展开方式",
42
- tocFollow: "跟随阅读",
41
+ tocResumeFollow: "恢复跟读",
43
42
  tocExpandAll: "全部展开",
44
- tocManual: "手动调整",
45
43
  tocPage: "本页目录",
46
44
  open: "打开",
47
45
  close: "关闭",
@@ -300,10 +298,8 @@ const en: typeof zh = {
300
298
  columnNewestFirst: (title: string | undefined) => `${title}, newest first`,
301
299
  toc: "Contents",
302
300
  tocShort: "TOC",
303
- tocDisplay: "Contents display",
304
- tocFollow: "Follow reading",
301
+ tocResumeFollow: "Resume following",
305
302
  tocExpandAll: "Expand all",
306
- tocManual: "Manual selection",
307
303
  tocPage: "On this page",
308
304
  open: "Open ",
309
305
  close: "Close ",
@@ -5,7 +5,7 @@
5
5
  "author": "hedon",
6
6
  "authorUrl": "https://github.com/hedon954",
7
7
  "repo": "hedon954/garden",
8
- "version": "0.14.8",
8
+ "version": "0.14.11",
9
9
  "minAppVersion": "1.5.0",
10
10
  "minCoreVersion": "2.10.52",
11
11
  "platforms": [
@@ -2532,6 +2532,15 @@
2532
2532
  min-width: 0;
2533
2533
  }
2534
2534
 
2535
+ /* Columnized centered rows stretch labels to 100% width, so align-items
2536
+ can center arrows but not text. Center direct labels only; explicit
2537
+ start/end and nested cards or prose stay as authored. */
2538
+ .g-compose-row:not([data-wrap="nowrap"])[data-align="center"] > .g-compose-h:not([data-align="start"]):not([data-align="end"]),
2539
+ .g-compose-row:not([data-wrap="nowrap"])[data-align="center"] > .g-compose-text:not([data-align="start"]):not([data-align="end"]),
2540
+ .g-compose-row:not([data-wrap="nowrap"])[data-align="center"] > .g-compose-caption-inline:not([data-align="start"]):not([data-align="end"]) {
2541
+ text-align: center;
2542
+ }
2543
+
2535
2544
  .g-compose-arrow[data-dir="right"] {
2536
2545
  transform: none;
2537
2546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.14.8",
3
+ "version": "0.14.11",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """Safe checks by default; explicit execute mode requires current release authorization."""
3
+ import argparse
4
+ import base64
5
+ import fcntl
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+ import urllib.error
15
+ import urllib.request
16
+ import zipfile
17
+
18
+ PHASES = ('test', 'pack', 'linux', 'tag-plan', 'published', 'blog-check', 'deployed')
19
+ ENGINE = 'hedon954/garden'
20
+ BLOG = 'hedon954/garden-blog'
21
+ PAGES = 'hedon954/hedon954.github.io'
22
+
23
+
24
+ def require(ok, message):
25
+ if not ok:
26
+ raise RuntimeError(message)
27
+
28
+
29
+ def digest(data):
30
+ return hashlib.sha256(data).hexdigest()
31
+
32
+
33
+ def command(args, cwd):
34
+ return subprocess.check_output(args, cwd=cwd, stderr=subprocess.PIPE).decode().rstrip('\n')
35
+
36
+
37
+ def git(root, *args):
38
+ return command(['git', *args], root)
39
+
40
+
41
+ def atomic_json(path, data):
42
+ temporary = path.with_suffix('.tmp')
43
+ temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
44
+ temporary.replace(path)
45
+
46
+
47
+ def snapshot(root):
48
+ names = git(root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z').split('\0')
49
+ result = {}
50
+ for name in sorted(set(filter(None, names))):
51
+ p = root / name
52
+ result[name] = ('link:' + os.readlink(p) if p.is_symlink() else
53
+ digest(p.read_bytes()) if p.is_file() else 'MISSING')
54
+ return result
55
+
56
+
57
+ def protect(before, after, allowed=()):
58
+ changed = [p for p in before.keys() | after.keys()
59
+ if p not in allowed and before.get(p) != after.get(p)]
60
+ require(not changed, 'Protected files changed; preserve and review manually: ' + ', '.join(sorted(changed)))
61
+
62
+
63
+ def canonical_remote(url):
64
+ return url.removesuffix('.git').replace('git@github.com:', 'https://github.com/')
65
+
66
+
67
+ def validate_layout(root, blog):
68
+ require(canonical_remote(git(root, 'remote', 'get-url', 'origin')) == 'https://github.com/' + BLOG,
69
+ 'origin must be garden-blog; never infer the push remote')
70
+ require(canonical_remote(git(root, 'remote', 'get-url', 'upstream')) == 'https://github.com/' + ENGINE,
71
+ 'upstream must be garden engine')
72
+ require(git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir') ==
73
+ git(blog, 'rev-parse', '--path-format=absolute', '--git-common-dir'), 'Expected existing shared worktrees')
74
+
75
+
76
+ def preflight(root, blog):
77
+ validate_layout(root, blog)
78
+ version = json.loads((root / 'package.json').read_text())['version']
79
+ require(len(version.split('.')) == 3 and all(p.isdigit() for p in version.split('.')), 'Stable version required')
80
+ command(['node', 'scripts/sync-editor-version.mjs', '--check'], root)
81
+ lock = json.loads((root / 'package-lock.json').read_text())
82
+ require(lock['version'] == version == lock['packages']['']['version'], 'Root lock version mismatch')
83
+ return version, git(root, 'rev-parse', 'HEAD')
84
+
85
+
86
+ def http(url):
87
+ request = urllib.request.Request(url, headers={'Cache-Control': 'no-cache', 'User-Agent': 'Garden-release-check'})
88
+ try:
89
+ with urllib.request.urlopen(request, timeout=45) as response:
90
+ require(response.status == 200, 'HTTP not 200: ' + url)
91
+ return response.read()
92
+ except urllib.error.HTTPError:
93
+ raise
94
+ except (urllib.error.URLError, TimeoutError):
95
+ result = subprocess.run(['curl', '--location', '--silent', '--show-error', '--connect-timeout', '10',
96
+ '--max-time', '45', '--write-out', '\n%{http_code}', url], capture_output=True)
97
+ body, separator, status = result.stdout.rpartition(b'\n')
98
+ require(result.returncode == 0 and separator and status.isdigit(), 'Public HTTPS read failed; never infer absence')
99
+ code = int(status)
100
+ if code != 200:
101
+ raise urllib.error.HTTPError(url, code, 'HTTPS response', None, io.BytesIO(body))
102
+ return body
103
+
104
+
105
+ def registry(version):
106
+ try:
107
+ return json.loads(http('https://registry.npmjs.org/hd-garden/' + version))
108
+ except urllib.error.HTTPError as error:
109
+ if error.code == 404:
110
+ return None
111
+ raise
112
+
113
+
114
+ def api(path, root):
115
+ return json.loads(command(['gh', 'api', path], root))
116
+
117
+
118
+ def tar_files(data):
119
+ with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
120
+ return {m.name.removeprefix('package/'): archive.extractfile(m).read()
121
+ for m in archive.getmembers() if m.isfile()}
122
+
123
+
124
+ def package_audit(data, version, assets=None):
125
+ files = tar_files(data)
126
+ require(json.loads(files['package.json'])['version'] == version, 'npm version mismatch')
127
+ paths = [f'editors/dist/vscode/garden-ui-{version}.vsix', f'editors/dist/garden-typora-{version}.zip']
128
+ for p in paths:
129
+ require(p in files, 'Missing packaged editor: ' + p)
130
+ if assets is not None:
131
+ require(files[p] == assets[Path(p).name], 'Editor bytes mismatch: ' + p)
132
+ with zipfile.ZipFile(io.BytesIO(files[paths[0]])) as archive:
133
+ require(json.loads(archive.read('extension/package.json'))['version'] == version, 'VSIX version mismatch')
134
+ with zipfile.ZipFile(io.BytesIO(files[paths[1]])) as archive:
135
+ manifests = [p for p in archive.namelist() if p.endswith('/manifest.json')]
136
+ require(len(manifests) == 1 and json.loads(archive.read(manifests[0]))['version'] == version,
137
+ 'Typora version mismatch')
138
+ return {p: digest(files[p]) for p in paths}
139
+
140
+
141
+ def source_audit(root, data):
142
+ tracked = set(git(root, 'ls-files', '-z').split('\0'))
143
+ compared = 0
144
+ for name, content in tar_files(data).items():
145
+ if name in tracked:
146
+ require((root / name).read_bytes() == content, 'Packed source differs from clean commit: ' + name)
147
+ compared += 1
148
+ require(compared > 0, 'No source files verified in archive')
149
+ return compared
150
+
151
+
152
+ def linux_evidence(root, commit):
153
+ runs = api(f'repos/{ENGINE}/actions/runs?head_sha={commit}&per_page=100', root)['workflow_runs']
154
+ good = [r for r in runs if r['head_sha'] == commit and r.get('head_branch') == 'main'
155
+ and r.get('event') == 'push' and r.get('path') == '.github/workflows/verify.yml'
156
+ and r.get('status') == 'completed' and r.get('conclusion') == 'success']
157
+ require(good, 'No successful main Linux full-test workflow for this exact commit; do not tag')
158
+ run = max(good, key=lambda r: r['id'])
159
+ jobs = api(f'repos/{ENGINE}/actions/runs/{run["id"]}/jobs?per_page=100', root)['jobs']
160
+ job = next((j for j in jobs if j['name'] == 'verify'), None)
161
+ require(job and job['conclusion'] == 'success' and any(s.startswith('ubuntu') for s in job['labels']),
162
+ 'Linux verify job missing, skipped, or failed')
163
+ steps = {s['name']: s.get('conclusion') for s in job['steps']}
164
+ for name in ['Run npm test', 'A new site from garden init installs the packed engine and builds', 'Verify GitHub Pages export']:
165
+ require(steps.get(name) == 'success', 'Required CI step did not pass: ' + name)
166
+ return {'run': run['html_url'], 'commit': commit}
167
+
168
+
169
+ def tag_plan(root, version, commit):
170
+ proof = linux_evidence(root, commit)
171
+ require(registry(version) is None, 'Version already published: never overwrite or republish')
172
+ require(not git(root, 'ls-remote', '--tags', 'upstream', 'refs/tags/v' + version), 'Remote tag already exists')
173
+ local = command(['git', 'tag', '--list', 'v' + version], root)
174
+ require(not local, 'Local tag already exists; reconcile manually, never delete or overwrite')
175
+ require(git(root, 'ls-remote', 'upstream', 'refs/heads/main').split()[0] == commit,
176
+ 'upstream/main must equal the tested commit')
177
+ return {**proof, 'manualOnly': [f'git tag -a v{version} {commit} -m "Release {version}"',
178
+ f'git push upstream refs/tags/v{version}']}
179
+
180
+
181
+ def npm_archive(metadata):
182
+ require(metadata['dist']['tarball'].startswith('https://registry.npmjs.org/hd-garden/-/'), 'Unexpected npm artifact URL')
183
+ data = http(metadata['dist']['tarball'])
184
+ integrity = 'sha512-' + base64.b64encode(hashlib.sha512(data).digest()).decode()
185
+ require(integrity in metadata['dist']['integrity'].split(), 'Official npm SHA512 mismatch')
186
+ return data
187
+
188
+
189
+ def published(root, version, commit):
190
+ metadata = registry(version)
191
+ require(metadata and metadata.get('gitHead') == commit, 'Published gitHead mismatch or version absent')
192
+ remote = git(root, 'ls-remote', 'upstream', 'refs/tags/v' + version + '^{}')
193
+ require(remote and remote.split()[0] == commit, 'Annotated release tag does not resolve to source commit')
194
+ data = npm_archive(metadata)
195
+ release = api(f'repos/{ENGINE}/releases/tags/v{version}', root)
196
+ require(not release['draft'] and not release['prerelease'], 'Not a formal Release')
197
+ assets = {a['name']: http(a['browser_download_url']) for a in release['assets']
198
+ if a['name'] in [f'garden-ui-{version}.vsix', f'garden-typora-{version}.zip']}
199
+ hashes = package_audit(data, version, assets)
200
+ return {'gitHead': commit, 'npmSHA256': digest(data), 'sourceFiles': source_audit(root, data), 'release': release['html_url'], 'editorSHA256': hashes}
201
+
202
+
203
+ def consumer(blog, version):
204
+ require(json.loads((blog / 'package.json').read_text())['dependencies']['hd-garden'] == '^' + version,
205
+ 'First explicitly install hd-garden@^VERSION from registry; this tool never changes dependencies')
206
+ installed = blog / 'node_modules/hd-garden'
207
+ require(installed.is_dir() and not installed.is_symlink(), 'Consumer must not be a local file link')
208
+ lock = json.loads((blog / 'package-lock.json').read_text())['packages']['node_modules/hd-garden']
209
+ metadata = registry(version)
210
+ require(metadata and lock.get('version') == version and not lock.get('link')
211
+ and lock.get('resolved') == metadata['dist']['tarball']
212
+ and lock.get('integrity') == metadata['dist']['integrity'], 'Registry consumer lock mismatch')
213
+ require(json.loads((installed / 'package.json').read_text())['version'] == version, 'Installed version mismatch')
214
+ for name, data in tar_files(npm_archive(metadata)).items():
215
+ p = installed / name
216
+ require(p.resolve().is_relative_to(installed.resolve()) and p.is_file() and not p.is_symlink()
217
+ and p.read_bytes() == data, 'Installed registry bytes differ: ' + name)
218
+
219
+
220
+ def deployed(root, blog):
221
+ commit = git(blog, 'rev-parse', 'HEAD')
222
+ require(git(blog, 'ls-remote', 'origin', 'refs/heads/main').split()[0] == commit, 'Blog is not pushed normally yet')
223
+ def successful(repo, sha, path):
224
+ runs = api(f'repos/{repo}/actions/runs?head_sha={sha}&per_page=100', root)['workflow_runs']
225
+ good = [r for r in runs if r.get('head_sha') == sha and r.get('path') == path
226
+ and r.get('status') == 'completed' and r.get('conclusion') == 'success']
227
+ require(good, 'No successful deployment for ' + repo + ' at ' + sha)
228
+ return max(good, key=lambda r: r['id'])['html_url']
229
+ source_run = successful(BLOG, commit, '.github/workflows/pages.yml')
230
+ branch = api(f'repos/{PAGES}/branches/main', root)['commit']
231
+ require(branch['commit']['message'] == 'Deploy ' + commit, 'Public snapshot is not this blog commit')
232
+ pages_run = successful(PAGES, branch['sha'], 'dynamic/pages/pages-build-deployment')
233
+ tree = api(f'repos/{PAGES}/git/trees/{branch["sha"]}?recursive=1', root)
234
+ require(not tree.get('truncated'), 'Incomplete snapshot tree')
235
+ from html.parser import HTMLParser
236
+ from urllib.parse import unquote, urljoin, urlsplit
237
+ blobs = {x['path']: x['sha'] for x in tree['tree'] if x['type'] == 'blob'}
238
+ assets = set()
239
+ class Resources(HTMLParser):
240
+ def handle_starttag(self, tag, attributes):
241
+ a = dict(attributes)
242
+ value = a.get('src') if tag == 'script' else a.get('href') if tag == 'link' else None
243
+ if value:
244
+ u = urlsplit(urljoin('https://hedon.top/', value))
245
+ if u.netloc == 'hedon.top' and u.path.endswith(('.css', '.js')):
246
+ assets.add(unquote(u.path).lstrip('/'))
247
+ def check(path):
248
+ url = 'https://hedon.top/' + path.removesuffix('index.html')
249
+ data = http(url)
250
+ sha = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()
251
+ require(blobs.get(path) == sha, 'Production differs from new snapshot: ' + url)
252
+ return {'url': url, 'sha256': digest(data)}, data
253
+ pages = []
254
+ targets = ['index.html'] + [p for p in blobs if p.startswith('blog/') and p.endswith('/index.html') and 'inspired' in p.lower()]
255
+ require(len(targets) > 1, 'Expected actual Inspired article in snapshot')
256
+ for path in targets:
257
+ info, data = check(path); pages.append(info); Resources().feed(data.decode())
258
+ evidence = []
259
+ for path in sorted(assets):
260
+ info, _ = check(path); evidence.append(info)
261
+ require(evidence, 'No production CSS/JS discovered')
262
+ return {'sourceCI': source_run, 'pagesCI': pages_run, 'snapshot': branch['sha'], 'pages': pages, 'assets': evidence}
263
+
264
+
265
+ def run_steps(root, directory, steps):
266
+ evidence = []
267
+ for number, (cwd, args, extra_env) in enumerate(steps):
268
+ receipt = directory / f'{number}.json'
269
+ log = directory / f'{number}.log'
270
+ inputs = snapshot(cwd)
271
+ identity = {'cwd': str(cwd), 'argv': args, 'env': extra_env, 'inputs': inputs}
272
+ if receipt.exists():
273
+ saved = json.loads(receipt.read_text())
274
+ if saved['identity'] == identity and saved['exit'] == 0 and log.exists() and saved['logSHA256'] == digest(log.read_bytes()):
275
+ evidence.append(saved); continue
276
+ with log.open('wb') as stream:
277
+ result = subprocess.run(args, cwd=cwd, env={**os.environ, **extra_env}, stdout=stream, stderr=subprocess.STDOUT)
278
+ saved = {'identity': identity, 'exit': result.returncode, 'logSHA256': digest(log.read_bytes())}
279
+ atomic_json(receipt, saved)
280
+ protect(inputs, snapshot(cwd))
281
+ require(result.returncode == 0, f'Failed {args}; see {log}; no later phase executed')
282
+ evidence.append(saved)
283
+ return evidence
284
+
285
+
286
+ def main():
287
+ parser = argparse.ArgumentParser(description=__doc__)
288
+ parser.add_argument('phase', nargs='?', default='plan', choices=['plan', 'status', 'execute', *PHASES])
289
+ parser.add_argument('--blog', type=Path)
290
+ parser.add_argument('--run', action='store_true', help='run selected checks; execute mode additionally requires current approval and exact --confirm')
291
+ parser.add_argument('--version', help='target stable release version, execute mode only')
292
+ parser.add_argument('--confirm', help='exact phrase: release hd-garden VERSION; operator must have current approval')
293
+ parser.add_argument('--session', default='default', help='reviewed new source after a failed attempt may use a new session')
294
+ parser.add_argument('--retry-stage', choices=['version', 'source-push', 'tag-create', 'tag-push', 'vsix', 'blog-pin', 'blog-commit', 'blog-push'])
295
+ parser.add_argument('--wait-seconds', type=int, default=1200)
296
+ args = parser.parse_args()
297
+ root = Path(__file__).resolve().parent.parent
298
+ blog = (args.blog or root.parent / 'garden-blog').resolve()
299
+ if args.phase == 'execute':
300
+ sys.dont_write_bytecode = True
301
+ from release_executor import entry
302
+ print(json.dumps(entry(root, blog, args), ensure_ascii=False, indent=2)); return
303
+ version, commit = preflight(root, blog)
304
+ directory = root / '.garden/release' / commit
305
+ if args.phase in ('plan', 'status') or not args.run:
306
+ state = directory / 'state.json'
307
+ print(json.dumps({'version': version, 'commit': commit, 'phases': PHASES, 'requested': args.phase,
308
+ 'dryRun': True, 'recordedPhases': list(json.loads(state.read_text())['phases']) if state.exists() else [],
309
+ 'statePath': str(state),
310
+ 'externalWrites': 'Check modes never write remotely. execute --run requires current approval and exact confirmation.'}, indent=2))
311
+ return
312
+ require(not git(root, 'status', '--porcelain=v1'), 'Commit/review source first; checks must bind to clean engine HEAD')
313
+ directory.mkdir(parents=True, exist_ok=True)
314
+ with (directory / 'lock').open('w') as lock:
315
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
316
+ state_path = directory / 'state.json'
317
+ before = {'engine': snapshot(root), 'blog': snapshot(blog)}
318
+ state = json.loads(state_path.read_text()) if state_path.exists() else {'commit': commit, 'version': version, 'protected': before, 'phases': {}}
319
+ require(state['commit'] == commit and state['version'] == version, 'Stale evidence identity')
320
+ for name in before:
321
+ protect(state['protected'][name], before[name], ('package.json', 'package-lock.json') if name == 'blog' else ())
322
+ phase_dir = directory / args.phase
323
+ phase_dir.mkdir(exist_ok=True)
324
+ atomic_json(state_path, state)
325
+ if args.phase == 'test':
326
+ result = run_steps(root, phase_dir, [(root, ['npm', 'test'], {})])
327
+ elif args.phase == 'pack':
328
+ require((directory / 'test/0.json').exists(), 'Run test phase first')
329
+ test = json.loads((directory / 'test/0.json').read_text())
330
+ require(test['exit'] == 0 and test['identity']['inputs'] == snapshot(root)
331
+ and test['logSHA256'] == digest((directory / 'test/0.log').read_bytes()), 'Tests missing or stale')
332
+ archive_path = phase_dir / f'hd-garden-{version}.tgz'
333
+ old_pack = state['phases'].get('pack')
334
+ if not archive_path.exists() or (old_pack and (old_pack['npmSHA256'] != digest(archive_path.read_bytes()) or any(not (root / p).exists() or digest((root / p).read_bytes()) != h for p, h in old_pack['editorSHA256'].items()))):
335
+ (phase_dir / '0.json').unlink(missing_ok=True)
336
+ run_steps(root, phase_dir, [(root, ['npm', 'pack', '--json', '--pack-destination', str(phase_dir)], {})])
337
+ data = (phase_dir / f'hd-garden-{version}.tgz').read_bytes()
338
+ assets = {f'garden-ui-{version}.vsix': (root / f'editors/dist/vscode/garden-ui-{version}.vsix').read_bytes(),
339
+ f'garden-typora-{version}.zip': (root / f'editors/dist/garden-typora-{version}.zip').read_bytes()}
340
+ result = {'npmSHA256': digest(data), 'sourceFiles': source_audit(root, data), 'editorSHA256': package_audit(data, version, assets)}
341
+ elif args.phase == 'linux': result = linux_evidence(root, commit)
342
+ elif args.phase == 'tag-plan':
343
+ packed = state['phases'].get('pack')
344
+ archive = directory / 'pack' / f'hd-garden-{version}.tgz'
345
+ require(packed and archive.exists() and packed['npmSHA256'] == digest(archive.read_bytes()),
346
+ 'Pack proof missing or artifact changed; do not tag')
347
+ require(packed['editorSHA256'] == package_audit(archive.read_bytes(), version), 'Package evidence changed')
348
+ result = tag_plan(root, version, commit)
349
+ elif args.phase == 'published': result = published(root, version, commit)
350
+ elif args.phase == 'blog-check':
351
+ consumer(blog, version)
352
+ result = run_steps(root, phase_dir, [(blog, ['node', 'scripts/check-lockfile-registry.mjs'], {}),
353
+ (blog, ['./node_modules/.bin/garden', 'verify'], {}), (blog, ['npm', 'run', 'build'], {'STATIC_EXPORT': '1'}),
354
+ (blog, ['npm', 'run', 'pages:validate'], {}), (blog, ['./node_modules/.bin/garden', 'verify', '--rendered'], {})])
355
+ else: result = deployed(root, blog)
356
+ for name, path in [('engine', root), ('blog', blog)]: protect(before[name], snapshot(path))
357
+ state['phases'][args.phase] = result
358
+ atomic_json(state_path, state)
359
+ print(json.dumps({'phase': args.phase, 'commit': commit, 'version': version, 'evidence': {'verifiedSteps': len(result)} if isinstance(result, list) else result, 'statePath': str(state_path)}, indent=2))
360
+
361
+
362
+ if __name__ == '__main__':
363
+ try:
364
+ main()
365
+ except (RuntimeError, OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, KeyError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
366
+ print('STOP:', str(error), file=sys.stderr)
367
+ sys.exit(1)