@hutusi/amytis 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -26,7 +26,7 @@ You can link between any content (Posts, Notes, Flows) using double-bracket synt
26
26
  - **Cross-Type Linking:**
27
27
  - If a slug matches a Note, it links to `/notes/[slug]`
28
28
  - If it matches a Post, it links to `/posts/[slug]`
29
- - If it matches a Flow, it links to `/flows/[slug]`
29
+ - If it matches a Flow date slug, it links to `/flows/YYYY/MM/DD`
30
30
 
31
31
  ### 3. Backlinks
32
32
  At the bottom of every Note, Amytis automatically generates a "Linked References" section. This lists every other page that links *to* the current note, along with a context snippet showing how it was referenced.
@@ -46,27 +46,29 @@ Flows are a stream-style collection of daily notes, micro-blogging, or imported
46
46
 
47
47
  ## How to Use
48
48
 
49
- 1. **Create a Note**: Run `bun run new-note "My Concept"` (or `bun run new "My Concept" --note`).
49
+ 1. **Create a Note**: Run `bun run new-note "My Concept"`.
50
50
  2. **Create a Flow**: Run `bun run new-flow`.
51
51
  3. **Link to it**: In a blog post, note, or flow, type `[[my-concept]]` or `[[2026-02-27]]`.
52
52
  4. **Explore**: Visit `/notes` or `/flows` to see your collection, or `/graph` to see the connections.
53
53
 
54
54
  ## Configuration
55
55
 
56
- In `site.config.ts`, you can configure the graph visualization:
56
+ Flows are controlled by `site.config.ts`, while notes and the graph are always routed when corresponding content exists:
57
57
 
58
58
  ```typescript
59
59
  export const siteConfig = {
60
60
  // ...
61
61
  features: {
62
- graph: {
62
+ flow: {
63
63
  enabled: true,
64
- name: { en: "Graph", zh: "知识图谱" },
64
+ name: { en: "Flow", zh: "随笔" },
65
65
  },
66
- notes: {
67
- enabled: true,
68
- name: { en: "Notes", zh: "笔记" },
69
- }
70
66
  }
71
67
  };
72
68
  ```
69
+
70
+ Related settings that shape the digital-garden experience live alongside that flag:
71
+
72
+ - `flows.recentCount`: controls how many flow entries appear on the homepage.
73
+ - `pagination.flows` and `pagination.notes`: control listing page sizes.
74
+ - `homepage.sections`: lets you enable, disable, or reorder homepage sections such as recent flows.
@@ -268,29 +268,46 @@ sudo certbot renew --dry-run
268
268
 
269
269
  ### Step 4: Automate Deployment (Optional)
270
270
 
271
- Create a deploy script on your local machine at `scripts/deploy.sh`:
271
+ Amytis includes a built-in deploy command for Linux servers running nginx.
272
+
273
+ #### Using `bun run deploy`
274
+
275
+ First install `sshpass` on your local machine:
272
276
 
273
277
  ```bash
274
- #!/bin/bash
275
- set -e
278
+ # macOS
279
+ brew install hudochenkov/sshpass/sshpass
280
+
281
+ # Linux
282
+ apt install sshpass
283
+ ```
276
284
 
277
- SERVER="user@server"
278
- REMOTE_DIR="/var/www/amytis"
285
+ Configure your server in `.env.local` (gitignored):
279
286
 
280
- echo "Building..."
281
- bun run build
287
+ ```env
288
+ DEPLOY_HOST=192.168.1.1
289
+ DEPLOY_USER=root
290
+ DEPLOY_PASSWORD=yourpassword
291
+ DEPLOY_PATH=/var/www/amytis
292
+ ```
282
293
 
283
- echo "Deploying to $SERVER..."
284
- rsync -avz --delete out/ "$SERVER:$REMOTE_DIR/"
294
+ Then deploy with a single command:
285
295
 
286
- echo "Done! Site deployed."
296
+ ```bash
297
+ bun run build
298
+ bun run deploy
287
299
  ```
288
300
 
301
+ You can also pass values as flags to override `.env.local`:
302
+
289
303
  ```bash
290
- chmod +x scripts/deploy.sh
291
- ./scripts/deploy.sh
304
+ bun run deploy --host 1.2.3.4 --user root --password mypass --path /var/www/amytis
292
305
  ```
293
306
 
307
+ The script builds the `out/` directory, uploads it via rsync, and automatically reloads nginx.
308
+
309
+ #### Using CI/CD
310
+
294
311
  Or use a CI/CD pipeline — add a job in your GitHub Actions workflow:
295
312
 
296
313
  ```yaml
@@ -323,5 +340,6 @@ Or use a CI/CD pipeline — add a job in your GitHub Actions workflow:
323
340
  |---|---|---|
324
341
  | Build | `bun run build` | Output in `out/` |
325
342
  | GitHub Pages | Push to `main` | With GitHub Actions workflow |
326
- | Linux (upload) | `rsync -avz --delete out/ user@server:/var/www/amytis/` | After local build |
343
+ | Linux (one command) | `bun run deploy` | Requires `.env.local` with server config |
344
+ | Linux (manual upload) | `rsync -avz --delete out/ user@server:/var/www/amytis/` | After local build |
327
345
  | Linux (on server) | `bun install && bun run build` | Clone repo on server |
package/eslint.config.mjs CHANGED
@@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
14
14
  "next-env.d.ts",
15
15
  // Generated at build time — not authored code
16
16
  "public/pagefind/**",
17
+ // Package compiled output — not authored code
18
+ "packages/*/dist/**",
17
19
  ]),
18
20
  ]);
19
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hutusi/amytis",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "A high-performance digital garden and blog engine with Next.js 16 and Tailwind CSS v4",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,10 +30,11 @@
30
30
  "import-book": "bun scripts/import-book.ts",
31
31
  "sync-book": "bun scripts/sync-book-chapters.ts",
32
32
  "series-draft": "bun scripts/series-draft.ts",
33
+ "deploy": "bun scripts/deploy.ts",
33
34
  "start": "next start",
34
35
  "lint": "eslint",
35
- "test": "bun test src tests/unit tests/tooling && bun run test:int",
36
- "test:unit": "bun test src tests/unit",
36
+ "test": "bun test src tests/unit tests/tooling packages/create-amytis/src && bun run test:int",
37
+ "test:unit": "bun test src tests/unit packages/create-amytis/src",
37
38
  "test:int": "bun test tests/integration",
38
39
  "test:e2e": "bun test tests/e2e"
39
40
  },
@@ -44,6 +45,7 @@
44
45
  "github-slugger": "^2.0.0",
45
46
  "gray-matter": "^4.0.3",
46
47
  "image-size": "^2.0.2",
48
+ "katex": "^0.16.33",
47
49
  "mermaid": "^11.12.3",
48
50
  "next": "16.1.6",
49
51
  "next-image-export-optimizer": "^1.20.1",
@@ -56,8 +58,12 @@
56
58
  "rehype-katex": "^7.0.1",
57
59
  "rehype-raw": "^7.0.0",
58
60
  "rehype-slug": "^6.0.0",
61
+ "rehype-stringify": "^10.0.1",
59
62
  "remark-gfm": "^4.0.1",
60
63
  "remark-math": "^6.0.0",
64
+ "remark-parse": "^11.0.0",
65
+ "remark-rehype": "^11.1.2",
66
+ "unified": "^11.0.5",
61
67
  "unist-util-visit": "^5.1.0",
62
68
  "zod": "^4.3.6"
63
69
  },
@@ -65,7 +71,9 @@
65
71
  "@tailwindcss/postcss": "^4.1.18",
66
72
  "@types/bun": "^1.3.9",
67
73
  "@types/d3": "^7.4.3",
74
+ "@types/hast": "^3.0.4",
68
75
  "@types/image-size": "^0.8.0",
76
+ "@types/mdast": "^4.0.4",
69
77
  "@types/node": "^24.10.13",
70
78
  "@types/react": "^19.2.14",
71
79
  "@types/react-dom": "^19.2.3",
@@ -85,5 +93,6 @@
85
93
  "trustedDependencies": [
86
94
  "sharp",
87
95
  "unrs-resolver"
88
- ]
96
+ ],
97
+ "workspaces": ["packages/*"]
89
98
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "create-amytis",
3
+ "version": "0.1.0",
4
+ "description": "Create a new Amytis digital garden",
5
+ "license": "MIT",
6
+ "bin": { "create-amytis": "./dist/index.js" },
7
+ "scripts": {
8
+ "build": "bun build src/index.ts --outfile dist/index.js --target node --sourcemap=none",
9
+ "dev": "bun src/index.ts",
10
+ "test": "bun test src"
11
+ },
12
+ "files": ["dist"]
13
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { patchSiteConfig, patchPackageJson } from "./index";
6
+
7
+ // Minimal site.config.ts that mirrors the real file's patchable fields.
8
+ // Inner backticks and `${` are escaped so they appear literally in the string.
9
+ const SAMPLE_CONFIG = `\
10
+ const siteConfig = {
11
+ title: { en: "Amytis", zh: "Amytis" },
12
+ description: { en: "Amytis — an elegant open-source framework for building your personal digital garden.", zh: "Amytis — 优雅的开源数字花园框架。" },
13
+ footerText: { en: \`© \${new Date().getFullYear()} Amytis. All rights reserved.\`, zh: \`© \${new Date().getFullYear()} Amytis. 保留所有权利。\` },
14
+ nav: [],
15
+ };
16
+ export default siteConfig;
17
+ `;
18
+
19
+ const SAMPLE_PKG = JSON.stringify(
20
+ {
21
+ name: "@hutusi/amytis",
22
+ version: "1.11.0",
23
+ private: false,
24
+ repository: { type: "git", url: "git+https://github.com/hutusi/amytis.git" },
25
+ bugs: { url: "https://github.com/hutusi/amytis/issues" },
26
+ homepage: "https://github.com/hutusi/amytis#readme",
27
+ dependencies: { next: "16.1.6" },
28
+ },
29
+ null,
30
+ 2
31
+ ) + "\n";
32
+
33
+ function makeTmpDir(): string {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), "create-amytis-test-"));
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // patchSiteConfig
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe("patchSiteConfig", () => {
42
+ const tmpDirs: string[] = [];
43
+
44
+ afterAll(() => {
45
+ for (const dir of tmpDirs) {
46
+ fs.rmSync(dir, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ function setup(): string {
51
+ const dir = makeTmpDir();
52
+ tmpDirs.push(dir);
53
+ fs.writeFileSync(path.join(dir, "site.config.ts"), SAMPLE_CONFIG);
54
+ return dir;
55
+ }
56
+
57
+ test("replaces title in both en and zh locales", () => {
58
+ const dir = setup();
59
+ patchSiteConfig(dir, "My Awesome Blog", "Some description");
60
+ const result = fs.readFileSync(path.join(dir, "site.config.ts"), "utf8");
61
+ expect(result).toContain(`title: { en: "My Awesome Blog", zh: "My Awesome Blog" }`);
62
+ expect(result).not.toContain(`en: "Amytis"`);
63
+ });
64
+
65
+ test("replaces description in both en and zh locales", () => {
66
+ const dir = setup();
67
+ patchSiteConfig(dir, "My Blog", "Notes from the garden");
68
+ const result = fs.readFileSync(path.join(dir, "site.config.ts"), "utf8");
69
+ expect(result).toContain(
70
+ `description: { en: "Notes from the garden", zh: "Notes from the garden" }`
71
+ );
72
+ expect(result).not.toContain("elegant open-source framework");
73
+ });
74
+
75
+ test("replaces footerText with new title", () => {
76
+ const dir = setup();
77
+ patchSiteConfig(dir, "GardenSite", "My description");
78
+ const result = fs.readFileSync(path.join(dir, "site.config.ts"), "utf8");
79
+ expect(result).toContain("GardenSite. All rights reserved.");
80
+ expect(result).toContain("GardenSite. 保留所有权利。");
81
+ expect(result).not.toContain("Amytis. All rights reserved.");
82
+ });
83
+
84
+ test("handles title with special characters (quotes, apostrophes)", () => {
85
+ const dir = setup();
86
+ patchSiteConfig(dir, `Ada's "Blog"`, "A description");
87
+ const result = fs.readFileSync(path.join(dir, "site.config.ts"), "utf8");
88
+ // JSON.stringify will escape the quotes safely
89
+ expect(result).toContain(`"Ada's \\"Blog\\""`);
90
+ });
91
+
92
+ test("preserves unrelated config fields", () => {
93
+ const dir = setup();
94
+ patchSiteConfig(dir, "My Blog", "My description");
95
+ const result = fs.readFileSync(path.join(dir, "site.config.ts"), "utf8");
96
+ expect(result).toContain("nav: []");
97
+ expect(result).toContain("export default siteConfig");
98
+ });
99
+
100
+ test("does not throw when site.config.ts is missing", () => {
101
+ const dir = makeTmpDir();
102
+ tmpDirs.push(dir);
103
+ // file intentionally absent
104
+ expect(() => patchSiteConfig(dir, "Title", "Desc")).not.toThrow();
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // patchPackageJson
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("patchPackageJson", () => {
113
+ const tmpDirs: string[] = [];
114
+
115
+ afterAll(() => {
116
+ for (const dir of tmpDirs) {
117
+ fs.rmSync(dir, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ function setup(): string {
122
+ const dir = makeTmpDir();
123
+ tmpDirs.push(dir);
124
+ fs.writeFileSync(path.join(dir, "package.json"), SAMPLE_PKG);
125
+ return dir;
126
+ }
127
+
128
+ function readPkg(dir: string): Record<string, unknown> {
129
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as Record<
130
+ string,
131
+ unknown
132
+ >;
133
+ }
134
+
135
+ test("sets name to project name", () => {
136
+ const dir = setup();
137
+ patchPackageJson(dir, "my-garden");
138
+ expect(readPkg(dir).name).toBe("my-garden");
139
+ });
140
+
141
+ test("sets private to true", () => {
142
+ const dir = setup();
143
+ patchPackageJson(dir, "my-garden");
144
+ expect(readPkg(dir).private).toBe(true);
145
+ });
146
+
147
+ test("removes repository field", () => {
148
+ const dir = setup();
149
+ patchPackageJson(dir, "my-garden");
150
+ expect(readPkg(dir).repository).toBeUndefined();
151
+ });
152
+
153
+ test("removes bugs field", () => {
154
+ const dir = setup();
155
+ patchPackageJson(dir, "my-garden");
156
+ expect(readPkg(dir).bugs).toBeUndefined();
157
+ });
158
+
159
+ test("removes homepage field", () => {
160
+ const dir = setup();
161
+ patchPackageJson(dir, "my-garden");
162
+ expect(readPkg(dir).homepage).toBeUndefined();
163
+ });
164
+
165
+ test("preserves version and dependencies", () => {
166
+ const dir = setup();
167
+ patchPackageJson(dir, "my-garden");
168
+ const pkg = readPkg(dir);
169
+ expect(pkg.version).toBe("1.11.0");
170
+ expect(pkg.dependencies).toEqual({ next: "16.1.6" });
171
+ });
172
+
173
+ test("output is valid JSON with trailing newline", () => {
174
+ const dir = setup();
175
+ patchPackageJson(dir, "my-garden");
176
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
177
+ expect(() => JSON.parse(raw)).not.toThrow();
178
+ expect(raw.endsWith("\n")).toBe(true);
179
+ });
180
+
181
+ test("does not throw when package.json is missing", () => {
182
+ const dir = makeTmpDir();
183
+ tmpDirs.push(dir);
184
+ // file intentionally absent
185
+ expect(() => patchPackageJson(dir, "my-garden")).not.toThrow();
186
+ });
187
+ });
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as https from "https";
6
+ import * as readline from "readline";
7
+ import { execSync } from "child_process";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function prompt(question: string, defaultVal: string = ""): Promise<string> {
14
+ const rl = readline.createInterface({
15
+ input: process.stdin,
16
+ output: process.stdout,
17
+ });
18
+ const display = defaultVal ? `${question} (${defaultVal}): ` : `${question}: `;
19
+ return new Promise((resolve) => {
20
+ rl.question(display, (answer) => {
21
+ rl.close();
22
+ resolve(answer.trim() || defaultVal);
23
+ });
24
+ });
25
+ }
26
+
27
+ function fetchJson(url: string): Promise<Record<string, unknown>> {
28
+ return new Promise((resolve, reject) => {
29
+ https
30
+ .get(url, { headers: { "User-Agent": "create-amytis-cli" } }, (res) => {
31
+ if (res.statusCode === 301 || res.statusCode === 302) {
32
+ fetchJson(res.headers.location!).then(resolve).catch(reject);
33
+ return;
34
+ }
35
+ if (res.statusCode !== 200) {
36
+ reject(new Error(`GitHub API returned ${res.statusCode} for ${url}`));
37
+ return;
38
+ }
39
+ let data = "";
40
+ res.on("data", (chunk) => (data += chunk));
41
+ res.on("end", () => {
42
+ try {
43
+ resolve(JSON.parse(data) as Record<string, unknown>);
44
+ } catch {
45
+ reject(new Error("Failed to parse GitHub API response"));
46
+ }
47
+ });
48
+ })
49
+ .on("error", reject);
50
+ });
51
+ }
52
+
53
+ function downloadFile(url: string, dest: string): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ const doGet = (targetUrl: string) => {
56
+ https
57
+ .get(targetUrl, { headers: { "User-Agent": "create-amytis-cli" } }, (res) => {
58
+ if (res.statusCode === 301 || res.statusCode === 302) {
59
+ doGet(res.headers.location!);
60
+ return;
61
+ }
62
+ if (res.statusCode !== 200) {
63
+ reject(new Error(`Download failed with status ${res.statusCode}`));
64
+ return;
65
+ }
66
+ const file = fs.createWriteStream(dest);
67
+ res.pipe(file);
68
+ file.on("finish", () => file.close(() => resolve()));
69
+ file.on("error", (err) => {
70
+ fs.unlink(dest, () => {});
71
+ reject(err);
72
+ });
73
+ })
74
+ .on("error", reject);
75
+ };
76
+ doGet(url);
77
+ });
78
+ }
79
+
80
+ function extractTarball(tarPath: string, outDir: string): void {
81
+ // Extract into a temp dir, then move the inner folder out
82
+ const tmpDir = `${outDir}.__tmp__`;
83
+ fs.mkdirSync(tmpDir, { recursive: true });
84
+ execSync(`tar xzf "${tarPath}" -C "${tmpDir}"`);
85
+
86
+ // The tarball unpacks to a single top-level dir like "amytis-1.2.0/"
87
+ const entries = fs.readdirSync(tmpDir);
88
+ if (entries.length !== 1) {
89
+ throw new Error(`Unexpected tarball structure: ${entries.join(", ")}`);
90
+ }
91
+ const innerDir = path.join(tmpDir, entries[0]);
92
+ fs.renameSync(innerDir, outDir);
93
+ fs.rmdirSync(tmpDir);
94
+ fs.unlinkSync(tarPath);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Patch helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function escapeTemplateLiteral(str: string): string {
102
+ return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
103
+ }
104
+
105
+ export function patchSiteConfig(projectDir: string, title: string, description: string): void {
106
+ const configPath = path.join(projectDir, "site.config.ts");
107
+ if (!fs.existsSync(configPath)) {
108
+ console.warn(" Warning: site.config.ts not found, skipping patch");
109
+ return;
110
+ }
111
+
112
+ let src = fs.readFileSync(configPath, "utf8");
113
+
114
+ // title: { en: "...", zh: "..." }
115
+ src = src.replace(
116
+ /title:\s*\{\s*en:\s*"[^"]*",\s*zh:\s*"[^"]*"\s*\}/,
117
+ `title: { en: ${JSON.stringify(title)}, zh: ${JSON.stringify(title)} }`
118
+ );
119
+
120
+ // description: { en: "...", zh: "..." }
121
+ src = src.replace(
122
+ /description:\s*\{\s*en:\s*"[^"]*",\s*zh:\s*"[^"]*"\s*\}/,
123
+ `description: { en: ${JSON.stringify(description)}, zh: ${JSON.stringify(description)} }`
124
+ );
125
+
126
+ // footerText — replace "Amytis" occurrences in template literals with project title
127
+ const safeTitle = escapeTemplateLiteral(title);
128
+ src = src.replace(
129
+ /footerText:\s*\{\s*en:\s*`[^`]*`,\s*zh:\s*`[^`]*`\s*\}/,
130
+ `footerText: { en: \`© \${new Date().getFullYear()} ${safeTitle}. All rights reserved.\`, zh: \`© \${new Date().getFullYear()} ${safeTitle}. 保留所有权利。\` }`
131
+ );
132
+
133
+ fs.writeFileSync(configPath, src, "utf8");
134
+ }
135
+
136
+ export function patchPackageJson(projectDir: string, projectName: string): void {
137
+ const pkgPath = path.join(projectDir, "package.json");
138
+ if (!fs.existsSync(pkgPath)) {
139
+ console.warn(" Warning: package.json not found, skipping patch");
140
+ return;
141
+ }
142
+
143
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
144
+
145
+ pkg["name"] = projectName;
146
+ pkg["private"] = true;
147
+ delete pkg["repository"];
148
+ delete pkg["bugs"];
149
+ delete pkg["homepage"];
150
+
151
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Main
156
+ // ---------------------------------------------------------------------------
157
+
158
+ async function main(): Promise<void> {
159
+ console.log("\nCreate Amytis — scaffold a new digital garden\n");
160
+
161
+ // 1. Project name
162
+ let projectName = process.argv[2] ?? "";
163
+ if (!projectName) {
164
+ projectName = await prompt("Project name", "my-blog");
165
+ }
166
+
167
+ const targetDir = path.resolve(process.cwd(), projectName);
168
+ if (fs.existsSync(targetDir)) {
169
+ console.error(`Error: directory "${projectName}" already exists.`);
170
+ process.exit(1);
171
+ }
172
+
173
+ // 2. Fetch latest release tag
174
+ console.log("\nFetching latest Amytis release...");
175
+ const release = await fetchJson(
176
+ "https://api.github.com/repos/hutusi/amytis/releases/latest"
177
+ );
178
+ const tag = release["tag_name"] as string;
179
+ if (!tag) throw new Error("Could not determine latest release tag");
180
+ console.log(` Found: ${tag}`);
181
+
182
+ // 3. Download tarball
183
+ const tarUrl = `https://github.com/hutusi/amytis/archive/refs/tags/${tag}.tar.gz`;
184
+ const tarDest = path.join(process.cwd(), `amytis-${tag}.tar.gz`);
185
+ console.log("Downloading tarball...");
186
+ await downloadFile(tarUrl, tarDest);
187
+
188
+ // 4. Extract
189
+ console.log("Extracting...");
190
+ extractTarball(tarDest, targetDir);
191
+ console.log(` Scaffolded: ${targetDir}`);
192
+
193
+ // 5-6. Prompt for site metadata
194
+ const siteTitle = await prompt("\nSite title", projectName);
195
+ const siteDescription = await prompt(
196
+ "Site description",
197
+ "My digital garden"
198
+ );
199
+
200
+ // 7. Patch site.config.ts
201
+ console.log("\nPatching site.config.ts...");
202
+ patchSiteConfig(targetDir, siteTitle, siteDescription);
203
+
204
+ // 8. Patch package.json
205
+ console.log("Patching package.json...");
206
+ patchPackageJson(targetDir, projectName);
207
+
208
+ // 9. Run bun install
209
+ console.log("Installing dependencies (bun install)...");
210
+ execSync("bun install", { cwd: targetDir, stdio: "inherit" });
211
+
212
+ // 10. Success message
213
+ console.log(`
214
+ Done! Your new Amytis site is ready.
215
+
216
+ cd ${projectName}
217
+ bun dev # start the dev server at http://localhost:3000
218
+ bun run build # production build
219
+
220
+ Edit site.config.ts to customise navigation, themes, and more.
221
+ Happy gardening!
222
+ `);
223
+ }
224
+
225
+ if (import.meta.main) {
226
+ main().catch((err) => {
227
+ console.error("\nError:", (err as Error).message);
228
+ process.exit(1);
229
+ });
230
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src"],
13
+ "exclude": ["src/**/*.test.ts"]
14
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file