@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.
- package/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /package/public/{screenshot.png → images/screenshot.png} +0 -0
package/docs/DIGITAL_GARDEN.md
CHANGED
|
@@ -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/
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
flow: {
|
|
63
63
|
enabled: true,
|
|
64
|
-
name: { en: "
|
|
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.
|
package/docs/deployment.md
CHANGED
|
@@ -268,29 +268,46 @@ sudo certbot renew --dry-run
|
|
|
268
268
|
|
|
269
269
|
### Step 4: Automate Deployment (Optional)
|
|
270
270
|
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
278
|
+
# macOS
|
|
279
|
+
brew install hudochenkov/sshpass/sshpass
|
|
280
|
+
|
|
281
|
+
# Linux
|
|
282
|
+
apt install sshpass
|
|
283
|
+
```
|
|
276
284
|
|
|
277
|
-
|
|
278
|
-
REMOTE_DIR="/var/www/amytis"
|
|
285
|
+
Configure your server in `.env.local` (gitignored):
|
|
279
286
|
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
rsync -avz --delete out/ "$SERVER:$REMOTE_DIR/"
|
|
294
|
+
Then deploy with a single command:
|
|
285
295
|
|
|
286
|
-
|
|
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
|
-
|
|
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 (
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hutusi/amytis",
|
|
3
|
-
"version": "1.
|
|
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
|
|
Binary file
|
|
Binary file
|