edgepress 2026.10.12 → 2026.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +12 -2
  2. package/config.yml +171 -171
  3. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
  4. package/content/assets/edgepress-markdown-guide.svg +21 -21
  5. package/content/guides/markdown-media.md +38 -0
  6. package/content/guides/product-demos.md +30 -30
  7. package/content/pages/deployment/en.json +1 -1
  8. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  9. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  10. package/languages/base/en.json +98 -98
  11. package/languages/packs/zh-CN.json +98 -98
  12. package/package.json +90 -90
  13. package/src/assets.js +187 -186
  14. package/src/browser-audit.js +158 -158
  15. package/src/build.js +293 -291
  16. package/src/cli.js +364 -364
  17. package/src/content.js +233 -233
  18. package/src/data-saver.js +3 -3
  19. package/src/generators.js +414 -420
  20. package/src/i18n.js +86 -86
  21. package/src/markdown.js +195 -160
  22. package/src/page-check.js +1 -1
  23. package/src/plugin-api.js +77 -77
  24. package/src/report-visuals.js +642 -642
  25. package/src/revision-cards.js +30 -30
  26. package/src/rss.js +100 -0
  27. package/src/theme.js +4 -4
  28. package/src/video-metadata.js +36 -0
  29. package/static/edgepress/fonts/faces.css +2217 -2217
  30. package/static/edgepress/media-viewer.js +67 -10
  31. package/static/edgepress/project-demo.js +83 -83
  32. package/static/edgepress/search.js +84 -84
  33. package/tests/assets-runtime.test.mjs +26 -26
  34. package/tests/audit.test.mjs +142 -142
  35. package/tests/brand-regression.test.mjs +110 -110
  36. package/tests/document-package.test.mjs +2 -2
  37. package/tests/embed-syntax.test.mjs +68 -68
  38. package/tests/image-viewer.test.mjs +36 -36
  39. package/tests/markdown-media.test.mjs +119 -0
  40. package/tests/oembed-layout.test.mjs +6 -1
  41. package/tests/project-demo.test.mjs +73 -73
  42. package/tests/release-version.test.mjs +28 -0
  43. package/tests/rss.test.mjs +54 -0
  44. package/tests/video-metadata.test.mjs +28 -0
  45. package/tests/writing.test.mjs +83 -83
  46. package/themes/atelier/assets/style.css +340 -340
  47. package/themes/default/assets/style.css +371 -371
  48. package/themes/default/layouts/partials/head.html +1 -1
  49. package/themes/folio/assets/style.css +515 -515
  50. package/themes/folio/theme.json +5 -5
  51. package/themes/signal/assets/style.css +332 -332
  52. package/tools/README.md +10 -10
  53. package/tools/assets/generate-brand-assets.py +160 -160
  54. package/tools/assets/video-preview.mjs +15 -0
  55. package/tools/recordings/demo-workbench.mjs +53 -53
  56. package/tools/recordings/desktop-recording.mjs +70 -70
  57. package/tools/recordings/vm-desktop.mjs +59 -59
package/tools/README.md CHANGED
@@ -1,14 +1,14 @@
1
- # Development tools
2
-
3
- Tests and their media fixtures live in `../tests/`. The `.mjs` extension identifies Node ES modules; it does not make a script temporary.
4
-
5
- | Directory | Purpose |
6
- | --- | --- |
1
+ # Development tools
2
+
3
+ Tests and their media fixtures live in `../tests/`. The `.mjs` extension identifies Node ES modules; it does not make a script temporary.
4
+
5
+ | Directory | Purpose |
6
+ | --- | --- |
7
7
  | `assets/` | Icons, licensed font subsets and source asset derivatives |
8
- | `recordings/` | Real guest desktop demonstrations using FFmpeg, Playwright and guest X11 tools |
9
- | `verification/` | Browser and asset verification commands |
10
- | `reports/` | Current generated review evidence; `page-check.pdf` comes from `npm run check` |
11
-
8
+ | `recordings/` | Real guest desktop demonstrations using FFmpeg, Playwright and guest X11 tools |
9
+ | `verification/` | Browser and asset verification commands |
10
+ | `reports/` | Current generated review evidence; `page-check.pdf` comes from `npm run check` |
11
+
12
12
  Run commands from the project root. Raw recordings and temporary manifests use ignored `tools/.recordings/`; intermediate verification output uses ignored `tools/.verification/`. Replaced historical reports are archived outside the checkout.
13
13
 
14
14
  `npm run fonts:sync` vendors pinned Noto WOFF2 Unicode subsets from Fontsource, verifies archive integrity, preserves OFL licenses and records file hashes under `static/edgepress/fonts/`. Normal builds use these committed files offline. The operator site's existing framework checkout also carries these assets; synchronize that directory when deliberately updating the font release.
@@ -1,160 +1,160 @@
1
- """Build EdgePress brand derivatives. Functional icons are sourced separately from Lucide.
2
-
3
- Requires Pillow. The mascot PNG passed on the command line stays untouched as
4
- the generated master; this script only makes derivative assets from it.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import shutil
11
- from pathlib import Path
12
-
13
- from PIL import Image, ImageDraw, ImageFont
14
-
15
-
16
- INK = (55, 65, 59, 255)
17
- PALE = (247, 240, 225)
18
- ACCENT = (228, 128, 99)
19
- def alpha_bbox(image: Image.Image):
20
- alpha = image.getchannel("A").point(lambda value: 255 if value > 8 else 0)
21
- return alpha.getbbox()
22
-
23
-
24
- def contained(image: Image.Image, size: tuple[int, int], padding: int = 0) -> Image.Image:
25
- image = image.copy()
26
- image.thumbnail((size[0] - padding * 2, size[1] - padding * 2), Image.Resampling.LANCZOS)
27
- layer = Image.new("RGBA", size, (0, 0, 0, 0))
28
- layer.alpha_composite(image, ((size[0] - image.width) // 2, (size[1] - image.height) // 2))
29
- return layer
30
-
31
-
32
- def font(size: int, bold: bool = False, cjk: bool = False):
33
- candidates = (
34
- [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"] if cjk else
35
- [r"C:\Windows\Fonts\seguisb.ttf", r"C:\Windows\Fonts\segoeui.ttf"] if bold else
36
- [r"C:\Windows\Fonts\segoeui.ttf", r"C:\Windows\Fonts\arial.ttf"]
37
- )
38
- for candidate in candidates:
39
- if Path(candidate).exists():
40
- return ImageFont.truetype(candidate, size=size)
41
- return ImageFont.load_default()
42
-
43
-
44
- def wrap_text(draw: ImageDraw.ImageDraw, text: str, font_face, max_width: int) -> list[str]:
45
- words = text.split()
46
- lines: list[str] = []
47
- current = ""
48
- for word in words:
49
- candidate = word if not current else current + " " + word
50
- if current and draw.textlength(candidate, font=font_face) > max_width:
51
- lines.append(current)
52
- current = word
53
- else:
54
- current = candidate
55
- if current:
56
- lines.append(current)
57
- return lines
58
-
59
-
60
- def share_image(master: Image.Image, brand: str) -> Image.Image:
61
- canvas = Image.new("RGBA", (1200, 630), (250, 247, 238, 255))
62
- draw = ImageDraw.Draw(canvas)
63
- if brand == "edgepress":
64
- title = "EdgePress"
65
- title_font = font(64, bold=True)
66
- body_font = font(31)
67
- body_lines = ["Build for the edge.", "Publish with clarity."]
68
- detail = "Markdown posts, editable pages, themes, and Workers."
69
- art_size, art_x = (600, 540), 600
70
- elif brand == "blackbear":
71
- title = "技述|JS.GRIPE"
72
- title_font = font(51, bold=True, cjk=True)
73
- body_font = font(27)
74
- body_lines = ["Technical field notes,", "research, and open source."]
75
- detail = "Practice and ideas from JS.GRIPE."
76
- art_size, art_x = (580, 580), 620
77
- else:
78
- title = "技术网"
79
- title_font = font(74, bold=True, cjk=True)
80
- body_font = font(31, cjk=True)
81
- body_lines = ["记录服务器运维、技术学习", "与问题解决过程。"]
82
- detail = "可复现、可回看的技术笔记"
83
- art_size, art_x = (580, 580), 620
84
-
85
- mascot = contained(master, art_size, 0)
86
- canvas.alpha_composite(mascot, (art_x + (art_size[0] - mascot.width) // 2, (630 - mascot.height) // 2))
87
- draw = ImageDraw.Draw(canvas)
88
- draw.rounded_rectangle((76, 92, 124, 100), radius=4, fill=ACCENT)
89
- draw.text((76, 129), title, font=title_font, fill=(36, 56, 45, 255))
90
- y = 235 if brand == "edgepress" else 225
91
- for line_text in body_lines:
92
- draw.text((78, y), line_text, font=body_font, fill=(49, 94, 73, 255))
93
- y += 58 if brand == "edgepress" else 50
94
- detail_font = font(23, cjk=brand == "panda")
95
- for line_text in wrap_text(draw, detail, detail_font, 540):
96
- draw.text((80, y + 20), line_text, font=detail_font, fill=(99, 111, 100, 255))
97
- y += 35
98
- return canvas.convert("RGB")
99
-
100
-
101
- def generate(master_path: Path, avatar_path: Path, home_path: Path, output_dir: Path, icons_dir: Path | None, favicon_dir: Path | None, brand: str):
102
- output_dir.mkdir(parents=True, exist_ok=True)
103
- if icons_dir:
104
- icons_dir.mkdir(parents=True, exist_ok=True)
105
- if favicon_dir:
106
- favicon_dir.mkdir(parents=True, exist_ok=True)
107
- master_path = master_path.resolve()
108
- master_target = output_dir / "mascot.png"
109
- if master_path != master_target.resolve():
110
- shutil.copyfile(master_path, master_target)
111
- master = Image.open(master_target).convert("RGBA")
112
-
113
- mascot = master.copy()
114
- mascot.thumbnail((768, 1152), Image.Resampling.LANCZOS)
115
- mascot.save(output_dir / "mascot.webp", format="WEBP", quality=88, method=4)
116
-
117
- avatar_master = Image.open(avatar_path).convert("RGBA")
118
- avatar = contained(avatar_master.crop(alpha_bbox(avatar_master)), (512, 512), 20)
119
- avatar.save(output_dir / "avatar.png", optimize=True)
120
- if home_path.resolve() != (output_dir / "home-hero.png").resolve():
121
- shutil.copyfile(home_path, output_dir / "home-hero.png")
122
- home = Image.open(home_path).convert("RGBA")
123
- home.save(output_dir / "home-hero.webp", format="WEBP", quality=88, method=4)
124
- small_home = home.copy()
125
- small_home.thumbnail((768, 512), Image.Resampling.LANCZOS)
126
- small_home.save(output_dir / "home-hero-768.webp", format="WEBP", quality=88, method=4)
127
- share = share_image(home, brand)
128
- share.save(output_dir / "og-image.png", optimize=True)
129
- share.save(output_dir / "og-image.webp", format="WEBP", quality=88, method=6)
130
-
131
- if icons_dir:
132
- raise ValueError("Use npm run icons:sync for licensed Lucide SVG icons; omit --icons-dir here")
133
-
134
- if favicon_dir:
135
- favicon = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
136
- ImageDraw.Draw(favicon).ellipse((4, 4, 508, 508), fill=(246, 236, 215, 255))
137
- favicon.alpha_composite(contained(avatar, (512, 512), 18))
138
- for size in (16, 32, 48, 64):
139
- favicon.resize((size, size), Image.Resampling.LANCZOS).save(favicon_dir / f"favicon-{size}.png", optimize=True)
140
- favicon.save(favicon_dir / "favicon.ico", format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
141
- touch = Image.new("RGBA", (180, 180), (244, 235, 218, 255))
142
- touch.alpha_composite(avatar.resize((168, 168), Image.Resampling.LANCZOS), (6, 6))
143
- touch.save(favicon_dir / "apple-touch-icon.png", optimize=True)
144
-
145
-
146
- def main():
147
- parser = argparse.ArgumentParser()
148
- parser.add_argument("--master", required=True, type=Path)
149
- parser.add_argument("--avatar-master", required=True, type=Path)
150
- parser.add_argument("--home-master", required=True, type=Path)
151
- parser.add_argument("--output-dir", required=True, type=Path)
152
- parser.add_argument("--icons-dir", type=Path)
153
- parser.add_argument("--favicon-dir", type=Path)
154
- parser.add_argument("--brand", required=True, choices=("panda", "blackbear", "edgepress"))
155
- args = parser.parse_args()
156
- generate(args.master, args.avatar_master, args.home_master, args.output_dir, args.icons_dir, args.favicon_dir, args.brand)
157
-
158
-
159
- if __name__ == "__main__":
160
- main()
1
+ """Build EdgePress brand derivatives. Functional icons are sourced separately from Lucide.
2
+
3
+ Requires Pillow. The mascot PNG passed on the command line stays untouched as
4
+ the generated master; this script only makes derivative assets from it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from PIL import Image, ImageDraw, ImageFont
14
+
15
+
16
+ INK = (55, 65, 59, 255)
17
+ PALE = (247, 240, 225)
18
+ ACCENT = (228, 128, 99)
19
+ def alpha_bbox(image: Image.Image):
20
+ alpha = image.getchannel("A").point(lambda value: 255 if value > 8 else 0)
21
+ return alpha.getbbox()
22
+
23
+
24
+ def contained(image: Image.Image, size: tuple[int, int], padding: int = 0) -> Image.Image:
25
+ image = image.copy()
26
+ image.thumbnail((size[0] - padding * 2, size[1] - padding * 2), Image.Resampling.LANCZOS)
27
+ layer = Image.new("RGBA", size, (0, 0, 0, 0))
28
+ layer.alpha_composite(image, ((size[0] - image.width) // 2, (size[1] - image.height) // 2))
29
+ return layer
30
+
31
+
32
+ def font(size: int, bold: bool = False, cjk: bool = False):
33
+ candidates = (
34
+ [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"] if cjk else
35
+ [r"C:\Windows\Fonts\seguisb.ttf", r"C:\Windows\Fonts\segoeui.ttf"] if bold else
36
+ [r"C:\Windows\Fonts\segoeui.ttf", r"C:\Windows\Fonts\arial.ttf"]
37
+ )
38
+ for candidate in candidates:
39
+ if Path(candidate).exists():
40
+ return ImageFont.truetype(candidate, size=size)
41
+ return ImageFont.load_default()
42
+
43
+
44
+ def wrap_text(draw: ImageDraw.ImageDraw, text: str, font_face, max_width: int) -> list[str]:
45
+ words = text.split()
46
+ lines: list[str] = []
47
+ current = ""
48
+ for word in words:
49
+ candidate = word if not current else current + " " + word
50
+ if current and draw.textlength(candidate, font=font_face) > max_width:
51
+ lines.append(current)
52
+ current = word
53
+ else:
54
+ current = candidate
55
+ if current:
56
+ lines.append(current)
57
+ return lines
58
+
59
+
60
+ def share_image(master: Image.Image, brand: str) -> Image.Image:
61
+ canvas = Image.new("RGBA", (1200, 630), (250, 247, 238, 255))
62
+ draw = ImageDraw.Draw(canvas)
63
+ if brand == "edgepress":
64
+ title = "EdgePress"
65
+ title_font = font(64, bold=True)
66
+ body_font = font(31)
67
+ body_lines = ["Build for the edge.", "Publish with clarity."]
68
+ detail = "Markdown posts, editable pages, themes, and Workers."
69
+ art_size, art_x = (600, 540), 600
70
+ elif brand == "blackbear":
71
+ title = "技述|JS.GRIPE"
72
+ title_font = font(51, bold=True, cjk=True)
73
+ body_font = font(27)
74
+ body_lines = ["Technical field notes,", "research, and open source."]
75
+ detail = "Practice and ideas from JS.GRIPE."
76
+ art_size, art_x = (580, 580), 620
77
+ else:
78
+ title = "技术网"
79
+ title_font = font(74, bold=True, cjk=True)
80
+ body_font = font(31, cjk=True)
81
+ body_lines = ["记录服务器运维、技术学习", "与问题解决过程。"]
82
+ detail = "可复现、可回看的技术笔记"
83
+ art_size, art_x = (580, 580), 620
84
+
85
+ mascot = contained(master, art_size, 0)
86
+ canvas.alpha_composite(mascot, (art_x + (art_size[0] - mascot.width) // 2, (630 - mascot.height) // 2))
87
+ draw = ImageDraw.Draw(canvas)
88
+ draw.rounded_rectangle((76, 92, 124, 100), radius=4, fill=ACCENT)
89
+ draw.text((76, 129), title, font=title_font, fill=(36, 56, 45, 255))
90
+ y = 235 if brand == "edgepress" else 225
91
+ for line_text in body_lines:
92
+ draw.text((78, y), line_text, font=body_font, fill=(49, 94, 73, 255))
93
+ y += 58 if brand == "edgepress" else 50
94
+ detail_font = font(23, cjk=brand == "panda")
95
+ for line_text in wrap_text(draw, detail, detail_font, 540):
96
+ draw.text((80, y + 20), line_text, font=detail_font, fill=(99, 111, 100, 255))
97
+ y += 35
98
+ return canvas.convert("RGB")
99
+
100
+
101
+ def generate(master_path: Path, avatar_path: Path, home_path: Path, output_dir: Path, icons_dir: Path | None, favicon_dir: Path | None, brand: str):
102
+ output_dir.mkdir(parents=True, exist_ok=True)
103
+ if icons_dir:
104
+ icons_dir.mkdir(parents=True, exist_ok=True)
105
+ if favicon_dir:
106
+ favicon_dir.mkdir(parents=True, exist_ok=True)
107
+ master_path = master_path.resolve()
108
+ master_target = output_dir / "mascot.png"
109
+ if master_path != master_target.resolve():
110
+ shutil.copyfile(master_path, master_target)
111
+ master = Image.open(master_target).convert("RGBA")
112
+
113
+ mascot = master.copy()
114
+ mascot.thumbnail((768, 1152), Image.Resampling.LANCZOS)
115
+ mascot.save(output_dir / "mascot.webp", format="WEBP", quality=88, method=4)
116
+
117
+ avatar_master = Image.open(avatar_path).convert("RGBA")
118
+ avatar = contained(avatar_master.crop(alpha_bbox(avatar_master)), (512, 512), 20)
119
+ avatar.save(output_dir / "avatar.png", optimize=True)
120
+ if home_path.resolve() != (output_dir / "home-hero.png").resolve():
121
+ shutil.copyfile(home_path, output_dir / "home-hero.png")
122
+ home = Image.open(home_path).convert("RGBA")
123
+ home.save(output_dir / "home-hero.webp", format="WEBP", quality=88, method=4)
124
+ small_home = home.copy()
125
+ small_home.thumbnail((768, 512), Image.Resampling.LANCZOS)
126
+ small_home.save(output_dir / "home-hero-768.webp", format="WEBP", quality=88, method=4)
127
+ share = share_image(home, brand)
128
+ share.save(output_dir / "og-image.png", optimize=True)
129
+ share.save(output_dir / "og-image.webp", format="WEBP", quality=88, method=6)
130
+
131
+ if icons_dir:
132
+ raise ValueError("Use npm run icons:sync for licensed Lucide SVG icons; omit --icons-dir here")
133
+
134
+ if favicon_dir:
135
+ favicon = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
136
+ ImageDraw.Draw(favicon).ellipse((4, 4, 508, 508), fill=(246, 236, 215, 255))
137
+ favicon.alpha_composite(contained(avatar, (512, 512), 18))
138
+ for size in (16, 32, 48, 64):
139
+ favicon.resize((size, size), Image.Resampling.LANCZOS).save(favicon_dir / f"favicon-{size}.png", optimize=True)
140
+ favicon.save(favicon_dir / "favicon.ico", format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
141
+ touch = Image.new("RGBA", (180, 180), (244, 235, 218, 255))
142
+ touch.alpha_composite(avatar.resize((168, 168), Image.Resampling.LANCZOS), (6, 6))
143
+ touch.save(favicon_dir / "apple-touch-icon.png", optimize=True)
144
+
145
+
146
+ def main():
147
+ parser = argparse.ArgumentParser()
148
+ parser.add_argument("--master", required=True, type=Path)
149
+ parser.add_argument("--avatar-master", required=True, type=Path)
150
+ parser.add_argument("--home-master", required=True, type=Path)
151
+ parser.add_argument("--output-dir", required=True, type=Path)
152
+ parser.add_argument("--icons-dir", type=Path)
153
+ parser.add_argument("--favicon-dir", type=Path)
154
+ parser.add_argument("--brand", required=True, choices=("panda", "blackbear", "edgepress"))
155
+ args = parser.parse_args()
156
+ generate(args.master, args.avatar_master, args.home_master, args.output_dir, args.icons_dir, args.favicon_dir, args.brand)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1,15 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+ import {writeFile} from 'node:fs/promises';
4
+ import {basename, resolve} from 'node:path';
5
+ const run = promisify(execFile);
6
+ const file = process.argv[2];
7
+ if (!file || !/\.(?:mp4|webm|ogv|ogg)$/i.test(file)) throw new Error('Usage: node tools/assets/video-preview.mjs path/to/video.mp4 [frame-seconds] (requires FFmpeg)');
8
+ const video = resolve(file), poster = video + '.poster.jpg';
9
+ const {stdout} = await run('ffprobe', ['-v','error','-select_streams','v:0','-show_entries','format=duration:stream=width,height','-of','json',video]);
10
+ const info = JSON.parse(stdout), duration = Number(info.format.duration), {width, height} = info.streams[0];
11
+ const time = Number(process.argv[3] ?? Math.min(1, duration / 2));
12
+ if (!(Number.isFinite(time) && time >= 0 && time < duration)) throw new Error('Preview time must be inside the video');
13
+ await run('ffmpeg', ['-v','error','-y','-ss',String(time),'-i',video,'-frames:v','1','-vf','scale=min(960\\,iw):-2','-q:v','3',poster]);
14
+ await writeFile(video + '.json', JSON.stringify({duration, width, height, poster: basename(poster)}, null, 2) + '\n');
15
+ console.log('Created ' + basename(poster) + ' and ' + basename(video) + '.json');
@@ -1,57 +1,57 @@
1
- // Recording-only workbench. Commands execute the real standalone EdgePress CLI;
2
- // Geany edits the actual generated file. Nothing is installed into production.
3
- import assert from 'node:assert/strict';
4
- import {readFile,writeFile,mkdir,rm,cp} from 'node:fs/promises';
5
- import {resolve,sep,extname} from 'node:path';
6
- import {spawn} from 'node:child_process';
7
- import {pathToFileURL} from 'node:url';
1
+ // Recording-only workbench. Commands execute the real standalone EdgePress CLI;
2
+ // Geany edits the actual generated file. Nothing is installed into production.
3
+ import assert from 'node:assert/strict';
4
+ import {readFile,writeFile,mkdir,rm,cp} from 'node:fs/promises';
5
+ import {resolve,sep,extname} from 'node:path';
6
+ import {spawn} from 'node:child_process';
7
+ import {pathToFileURL} from 'node:url';
8
8
  import {guest,guestPath,typeCommand,editFile,openApp,activate} from './vm-desktop.mjs';
9
- import {parse,stringify} from 'yaml';
10
-
11
- export async function workbench({artifact,packageRoot,photoPath,locale,context,embedding=false}){
12
- const upstream=resolve(packageRoot),root=resolve(artifact,(embedding?'embedding-example-':'edgepress-example-')+locale);
13
- assert.ok(root.startsWith(resolve(artifact)+sep));await rm(root,{recursive:true,force:true});await mkdir(root,{recursive:true});
14
- const cli=resolve(upstream,'src/cli.js');
15
- async function run(args){return new Promise((done,reject)=>{const child=spawn(process.execPath,[cli,...args],{cwd:root,windowsHide:true,env:{...process.env,WRANGLER_SEND_METRICS:'false'}});let output='';child.stdout.on('data',value=>output+=value);child.stderr.on('data',value=>output+=value);child.once('error',reject);child.once('exit',code=>code===0?done(output):reject(Error(output)));});}
16
- await run(['init']);
9
+ import {parse,stringify} from 'yaml';
10
+
11
+ export async function workbench({artifact,packageRoot,photoPath,locale,context,embedding=false}){
12
+ const upstream=resolve(packageRoot),root=resolve(artifact,(embedding?'embedding-example-':'edgepress-example-')+locale);
13
+ assert.ok(root.startsWith(resolve(artifact)+sep));await rm(root,{recursive:true,force:true});await mkdir(root,{recursive:true});
14
+ const cli=resolve(upstream,'src/cli.js');
15
+ async function run(args){return new Promise((done,reject)=>{const child=spawn(process.execPath,[cli,...args],{cwd:root,windowsHide:true,env:{...process.env,WRANGLER_SEND_METRICS:'false'}});let output='';child.stdout.on('data',value=>output+=value);child.stderr.on('data',value=>output+=value);child.once('error',reject);child.once('exit',code=>code===0?done(output):reject(Error(output)));});}
16
+ await run(['init']);
17
17
  const chinese=locale!=='en',title=embedding?(chinese?'散步中的瞬间':'Photos from our walk'):(chinese?'海边的下午':'An afternoon by the water');
18
- const settings=parse(await readFile(resolve(root,'config.yml'),'utf8'));settings.site.title=chinese?'我的写作空间':'My writing space';settings.site.description=chinese?'用文字和照片记录生活。':'Stories and photographs from everyday life.';settings.site.navigation=settings.site.navigation.filter(item=>['home','posts','search'].includes(item.key));settings.site.footerNavigation=[];settings.plugins.consent.services=[];await writeFile(resolve(root,'config.yml'),stringify(settings));
19
- await writeFile(resolve(root,'edgepress.config.mjs'),'export default '+JSON.stringify({paths:{content:'content',static:'static',theme:'themes/default',output:'dist',cache:'.recording-cache'},permalink:'/:year/:month/:slug/',i18n:{defaultLocale:locale,languagePacks:[chinese?'en':'zh-CN']},plugins:['plugins/consent/index.js']})+';\n');
20
- if(chinese){await cp(resolve(root,'languages/packs/zh-CN.json'),resolve(root,'languages/base/zh-CN.json'));await cp(resolve(root,'languages/base/en.json'),resolve(root,'languages/packs/en.json'));}
21
- // Use a small, real writing site so the recording starts at its article list.
22
- await rm(resolve(root,'content/posts'),{recursive:true,force:true});await mkdir(resolve(root,'content/posts'),{recursive:true});
23
- await rm(resolve(root,'content/pages'),{recursive:true,force:true});await mkdir(resolve(root,'content/pages/home'),{recursive:true});
24
- await writeFile(resolve(root,'content/pages/home',locale.toLowerCase()+'.json'),JSON.stringify({title:settings.site.title,lang:locale,slug:'home',homepage:true,blocks:[{columns:1,cells:[[{type:'hero',eyebrow:chinese?'生活里的小事':'Notes from everyday life',title:settings.site.title,text:settings.site.description}]]},{columns:1,cells:[[{type:'post-list',title:chinese?'最近的文章':'Recent stories',category:'uncategorized',count:3}]]}]},null,2)+'\n');
25
- if(embedding){settings.plugins.consent.services=[{id:'ishare',provider:'oembed',backendUrl:'https://ishare.js.gripe',enabled:true,name:chinese?'爱分享':'ishare',purpose:chinese?'在你同意后显示作者分享的图片与视频。':'Show the author’s shared photos and videos after you agree.',dataCategories:chinese?'请求的媒体和 IP 地址。':'Requested media and IP address.',recipient:'ishare',retention:chinese?'直到发布者删除。':'Until the publisher deletes it.',privacyUrl:'https://ishare.js.gripe/privacy/'}];await writeFile(resolve(root,'config.yml'),stringify(settings));}
26
- await mkdir(resolve(root,'content/assets/photos'),{recursive:true});await cp(photoPath,resolve(root,'content/assets/photos/seaside.jpg'));
27
- let file=null,port;
28
- const terminalTitle='EdgePress '+locale;
29
- const {loadConfig}=await import(pathToFileURL(resolve(upstream,'src/config.js')));const {readDocuments}=await import(pathToFileURL(resolve(upstream,'src/content.js')));
30
- const rc=resolve(root,'.recording-bashrc');
31
- const shellRoot="'"+guestPath(root).replaceAll("'","'\\''")+"'";
32
- await writeFile(rc,'export PATH=/opt/recording/node_modules/.bin:/usr/local/bin:/usr/bin:/bin\ncd -- '+shellRoot+'\nexport WRANGLER_SEND_METRICS=false\nexport PS1="❯ "\n');
33
- await guest(['ln','-s','/opt/recording/node_modules',guestPath(resolve(root,'node_modules'))]);
18
+ const settings=parse(await readFile(resolve(root,'config.yml'),'utf8'));settings.site.title=chinese?'我的写作空间':'My writing space';settings.site.description=chinese?'用文字和照片记录生活。':'Stories and photographs from everyday life.';settings.site.navigation=settings.site.navigation.filter(item=>['home','posts','search'].includes(item.key));settings.site.footerNavigation=[];settings.plugins.consent.services=[];await writeFile(resolve(root,'config.yml'),stringify(settings));
19
+ await writeFile(resolve(root,'edgepress.config.mjs'),'export default '+JSON.stringify({paths:{content:'content',static:'static',theme:'themes/default',output:'dist',cache:'.recording-cache'},permalink:'/:year/:month/:slug/',i18n:{defaultLocale:locale,languagePacks:[chinese?'en':'zh-CN']},plugins:['plugins/consent/index.js']})+';\n');
20
+ if(chinese){await cp(resolve(root,'languages/packs/zh-CN.json'),resolve(root,'languages/base/zh-CN.json'));await cp(resolve(root,'languages/base/en.json'),resolve(root,'languages/packs/en.json'));}
21
+ // Use a small, real writing site so the recording starts at its article list.
22
+ await rm(resolve(root,'content/posts'),{recursive:true,force:true});await mkdir(resolve(root,'content/posts'),{recursive:true});
23
+ await rm(resolve(root,'content/pages'),{recursive:true,force:true});await mkdir(resolve(root,'content/pages/home'),{recursive:true});
24
+ await writeFile(resolve(root,'content/pages/home',locale.toLowerCase()+'.json'),JSON.stringify({title:settings.site.title,lang:locale,slug:'home',homepage:true,blocks:[{columns:1,cells:[[{type:'hero',eyebrow:chinese?'生活里的小事':'Notes from everyday life',title:settings.site.title,text:settings.site.description}]]},{columns:1,cells:[[{type:'post-list',title:chinese?'最近的文章':'Recent stories',category:'uncategorized',count:3}]]}]},null,2)+'\n');
25
+ if(embedding){settings.plugins.consent.services=[{id:'ishare',provider:'oembed',backendUrl:'https://ishare.js.gripe',enabled:true,name:chinese?'爱分享':'ishare',purpose:chinese?'在你同意后显示作者分享的图片与视频。':'Show the author’s shared photos and videos after you agree.',dataCategories:chinese?'请求的媒体和 IP 地址。':'Requested media and IP address.',recipient:'ishare',retention:chinese?'直到发布者删除。':'Until the publisher deletes it.',privacyUrl:'https://ishare.js.gripe/privacy/'}];await writeFile(resolve(root,'config.yml'),stringify(settings));}
26
+ await mkdir(resolve(root,'content/assets/photos'),{recursive:true});await cp(photoPath,resolve(root,'content/assets/photos/seaside.jpg'));
27
+ let file=null,port;
28
+ const terminalTitle='EdgePress '+locale;
29
+ const {loadConfig}=await import(pathToFileURL(resolve(upstream,'src/config.js')));const {readDocuments}=await import(pathToFileURL(resolve(upstream,'src/content.js')));
30
+ const rc=resolve(root,'.recording-bashrc');
31
+ const shellRoot="'"+guestPath(root).replaceAll("'","'\\''")+"'";
32
+ await writeFile(rc,'export PATH=/opt/recording/node_modules/.bin:/usr/local/bin:/usr/bin:/bin\ncd -- '+shellRoot+'\nexport WRANGLER_SEND_METRICS=false\nexport PS1="❯ "\n');
33
+ await guest(['ln','-s','/opt/recording/node_modules',guestPath(resolve(root,'node_modules'))]);
34
34
  await openApp(['xterm','-T',terminalTitle,'-fa','Monospace','-fs','16','-bg','#17212c','-fg','#e8edf2','-geometry','106x30+60+40','-e','bash','--rcfile',guestPath(rc)],terminalTitle);
35
- await new Promise(done=>setTimeout(done,800));
36
- async function findPost(){const documents=await readDocuments(await loadConfig(root));const article=documents.find(item=>item.kind==='posts'&&item.title===title);if(article)file=article.file;return article;}
37
- async function createPost(){await run(['new','post',title]);const article=await findPost();assert.ok(article);return file;}
38
- async function newPost(){await typeCommand(root,terminalTitle,"edgepress new post '"+title+"'");for(let attempt=0;attempt<30;attempt++){const article=await findPost();if(article)return;await new Promise(done=>setTimeout(done,300));}throw Error('CLI did not create the post');}
39
- async function editPost(body){assert.ok(file);const front=(await readFile(file,'utf8')).split('---')[1];await editFile(root,file,'---'+front+'---\n\n'+body+'\n');}
40
- async function startServer({omitWait=task=>task()}={}){
41
- const {createServer}=await import('node:net');const socket=createServer();await new Promise(done=>socket.listen(0,'127.0.0.1',done));port=socket.address().port;await new Promise(done=>socket.close(done));
42
- await typeCommand(root,terminalTitle,'edgepress server --port '+port);
43
- await new Promise(done=>setTimeout(done,1600));
44
- await omitWait(async()=>{for(let attempt=0;attempt<180;attempt++){try{const response=await fetch('http://127.0.0.1:'+port+'/',{signal:AbortSignal.timeout(1200)});if(response.ok)return;}catch{}await new Promise(done=>setTimeout(done,500));}throw Error('Guest preview did not become ready');});
45
- }
46
- async function dispose(){for(const title of [terminalTitle,'Geany']){try{const ids=await guest(['xdotool','search','--onlyvisible','--name',title]);for(const id of ids.split(/\s+/)){const pid=await guest(['xdotool','getwindowpid',id]);if(/^\d+$/.test(pid))await guest(['kill','-TERM',pid]);}}catch{}}}
47
- const content=chinese?'## 海边的下午\n\n走到海边时,雨刚停。潮水很轻,远处的山还藏在云里。\n\n![雨后的海岸](/photos/seaside.jpg)\n\n下次还想沿着这条路走远一点。':'## An afternoon by the water\n\nThe rain had just stopped. Small waves reached the shore, and the mountains were still tucked into the clouds.\n\n![The coast after the rain](/photos/seaside.jpg)\n\nNext time, I want to follow this path a little farther.';
48
- await context.route('https://edgepress-demo.test/**',async route=>{
49
- try{
50
- assert.ok(port,'Start the real guest preview before opening the website');
51
- const request=route.request(),url=new URL(request.url()),response=await fetch('http://127.0.0.1:'+port+url.pathname+url.search);
52
- const headers=Object.fromEntries(response.headers);delete headers['content-encoding'];delete headers['content-length'];
53
- return await route.fulfill({status:response.status,headers,body:Buffer.from(await response.arrayBuffer())});
54
- }catch(error){if(!route.request().isNavigationRequest())return route.abort();throw error;}
55
- });
35
+ await new Promise(done=>setTimeout(done,800));
36
+ async function findPost(){const documents=await readDocuments(await loadConfig(root));const article=documents.find(item=>item.kind==='posts'&&item.title===title);if(article)file=article.file;return article;}
37
+ async function createPost(){await run(['new','post',title]);const article=await findPost();assert.ok(article);return file;}
38
+ async function newPost(){await typeCommand(root,terminalTitle,"edgepress new post '"+title+"'");for(let attempt=0;attempt<30;attempt++){const article=await findPost();if(article)return;await new Promise(done=>setTimeout(done,300));}throw Error('CLI did not create the post');}
39
+ async function editPost(body){assert.ok(file);const front=(await readFile(file,'utf8')).split('---')[1];await editFile(root,file,'---'+front+'---\n\n'+body+'\n');}
40
+ async function startServer({omitWait=task=>task()}={}){
41
+ const {createServer}=await import('node:net');const socket=createServer();await new Promise(done=>socket.listen(0,'127.0.0.1',done));port=socket.address().port;await new Promise(done=>socket.close(done));
42
+ await typeCommand(root,terminalTitle,'edgepress server --port '+port);
43
+ await new Promise(done=>setTimeout(done,1600));
44
+ await omitWait(async()=>{for(let attempt=0;attempt<180;attempt++){try{const response=await fetch('http://127.0.0.1:'+port+'/',{signal:AbortSignal.timeout(1200)});if(response.ok)return;}catch{}await new Promise(done=>setTimeout(done,500));}throw Error('Guest preview did not become ready');});
45
+ }
46
+ async function dispose(){for(const title of [terminalTitle,'Geany']){try{const ids=await guest(['xdotool','search','--onlyvisible','--name',title]);for(const id of ids.split(/\s+/)){const pid=await guest(['xdotool','getwindowpid',id]);if(/^\d+$/.test(pid))await guest(['kill','-TERM',pid]);}}catch{}}}
47
+ const content=chinese?'## 海边的下午\n\n走到海边时,雨刚停。潮水很轻,远处的山还藏在云里。\n\n![雨后的海岸](/photos/seaside.jpg)\n\n下次还想沿着这条路走远一点。':'## An afternoon by the water\n\nThe rain had just stopped. Small waves reached the shore, and the mountains were still tucked into the clouds.\n\n![The coast after the rain](/photos/seaside.jpg)\n\nNext time, I want to follow this path a little farther.';
48
+ await context.route('https://edgepress-demo.test/**',async route=>{
49
+ try{
50
+ assert.ok(port,'Start the real guest preview before opening the website');
51
+ const request=route.request(),url=new URL(request.url()),response=await fetch('http://127.0.0.1:'+port+url.pathname+url.search);
52
+ const headers=Object.fromEntries(response.headers);delete headers['content-encoding'];delete headers['content-length'];
53
+ return await route.fulfill({status:response.status,headers,body:Buffer.from(await response.arrayBuffer())});
54
+ }catch(error){if(!route.request().isNavigationRequest())return route.abort();throw error;}
55
+ });
56
56
  return {title,content,createPost,newPost,editPost,startServer,dispose,activateTerminal:()=>activate(terminalTitle),async publishedPath(){const article=await findPost();assert.ok(article);return article.path;}};
57
- }
57
+ }