@acms-subir/core 0.0.1 → 0.1.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/CHANGELOG.md +47 -0
- package/cli.mjs +255 -14
- package/integration.mjs +85 -3
- package/package.json +6 -5
- package/src/layouts/Site.astro +112 -0
- package/src/lib/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +167 -23
- package/src/lib/repo/sqlite.ts +3 -1
- package/src/lib/repo/types.ts +19 -3
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -0
- package/src/pages/admin/index.astro +22 -8
- package/src/pages/api/search.ts +44 -0
- package/src/pages/search.astro +83 -18
- package/src/styles/site.css +85 -0
- package/src/lib/repo/search.ts +0 -14
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @acms-subir/core
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d2d4761: Calendar card: embed a Google Calendar (or any ICS feed) as a native, themeable widget instead of an iframe. One card renders one view — month grid, week strip, or upcoming list — server-side at request time from the feed (5-minute cache, recurring events expanded, site timezone honored), with a small vanilla script for month/week navigation and an event-details popover. Paste an embed URL, calendar ID, or .ics link in the editor; add up to six color-coded feeds per card, plus an optional title/subtitle. Styling is driven by scoped `--cal-*` custom properties defaulting to theme tokens and stable `.pc-cal-*` class hooks, so themes and Settings → Code injection can restyle it freely.
|
|
8
|
+
- 769f9de: Code-first content config: `acms({ content })` renames the post collection per project
|
|
9
|
+
|
|
10
|
+
- `content.posts.{singular,plural,base}` changes every user-visible label
|
|
11
|
+
(site nav, admin sidebar, headings, buttons, feeds) and moves the public
|
|
12
|
+
routes off `/blog` — validated at build time, defaults unchanged.
|
|
13
|
+
- Tag archives moved to a standalone `/tag/[slug]` (configurable via
|
|
14
|
+
`content.tags.base`); `<postsBase>/tag/*` always 301s to the new location.
|
|
15
|
+
- When `base` is renamed, `/blog/*` (plus any `content.posts.redirectFrom`
|
|
16
|
+
entries) 301s to the new base — only on requests that would otherwise 404,
|
|
17
|
+
so rules never shadow real content.
|
|
18
|
+
- Internal identifiers (DB rows, backup format, `/admin/posts`) are never
|
|
19
|
+
renamed, so existing backups and sessions survive.
|
|
20
|
+
- Scaffolder: new "What do you call your posts?" prompt (`--content
|
|
21
|
+
articles|releases|notes`) writes the block into `astro.config.mjs`.
|
|
22
|
+
|
|
23
|
+
- 769f9de: Multiple content collections: `acms({ content: { collections } })`
|
|
24
|
+
|
|
25
|
+
- Each key in the `collections` map gets its own public routes at `base`,
|
|
26
|
+
admin section at `/admin/c/<key>`, sidebar entry, dashboard stat, and
|
|
27
|
+
feed/sitemap entries. `posts` is the built-in collection; `content.posts`
|
|
28
|
+
stays as shorthand for `collections.posts`. Labels default from the key.
|
|
29
|
+
- Migration v19 rebuilds the posts table: new `collection` column (existing
|
|
30
|
+
rows land in `posts`) and slug uniqueness becomes per-collection, so the
|
|
31
|
+
same slug can exist in different collections. Pre-v19 backups restore
|
|
32
|
+
cleanly.
|
|
33
|
+
- One shared tag registry; tag archives group entries by collection when
|
|
34
|
+
more than one is configured. `/rss.xml` stays the everything-feed with a
|
|
35
|
+
collection `<category>` per item.
|
|
36
|
+
- The editor gains a collection switcher (multi-collection sites only);
|
|
37
|
+
moving a published entry between collections leaves a 301. Post-list cards
|
|
38
|
+
can target a collection.
|
|
39
|
+
- `/admin/posts` and `/admin/posts/[id]` 308 to their `/admin/c/posts`
|
|
40
|
+
equivalents. Removing a collection key hides its routes but never deletes
|
|
41
|
+
rows — the dashboard shows a restore notice.
|
|
42
|
+
|
|
43
|
+
- 8e1f688: The update story: `acms update` now prints the release notes it crossed and deploy instructions matched to your target — `npm run deploy` for Cloudflare, `docker compose up -d --build` for Docker — plus a `--check` flag that exits 1 when behind, for CI or cron. Notes come from the `CHANGELOG.md` that now ships inside the package, so nothing depends on a git host or an API token. The admin dashboard shows a dismissible banner when a new release is out (checked at most daily, straight from the npm registry, fail-open), and `astro dev` logs a once-a-day nudge (opt out with `ACMS_NO_UPDATE_CHECK=1`). The scaffolder no longer freezes silently during `npm install` — the spinner animates with elapsed time — and its Next-steps box now mentions `npx acms update`.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- 19bf3c7: Enforce the documented "project-local routes win" rule at injection time. Astro registers injected and file-based routes at equal priority and serves the injected one on a collision (with a deprecation warning that this becomes a hard error), so the integration now skips injecting any route whose pattern the consumer already defines in `src/pages/` — checked as `<pattern>.astro`, `<pattern>.ts`, `<pattern>/index.astro`, or `<pattern>/index.ts`. Shell overrides of `/`, collection indexes, feeds, and any other public route now actually take effect.
|
package/cli.mjs
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `acms` — tiny maintenance CLI installed with @acms-subir/core.
|
|
3
|
-
// acms update
|
|
4
|
-
// acms
|
|
3
|
+
// acms update check the registry, install the latest core, print what's new
|
|
4
|
+
// acms update --check just report whether an update exists (exit 1 if behind)
|
|
5
|
+
// acms doctor sanity-check the project setup
|
|
6
|
+
// acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
|
|
7
|
+
// acms add docker retrofit self-hosting (Dockerfile, compose.yml)
|
|
5
8
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
10
|
import { createRequire } from 'node:module';
|
|
8
|
-
import { join } from 'node:path';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
9
12
|
|
|
10
13
|
const PKG = '@acms-subir/core';
|
|
11
14
|
const cmd = process.argv[2];
|
|
12
15
|
|
|
13
16
|
const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
14
|
-
const
|
|
17
|
+
const installedPkgJson = () => {
|
|
15
18
|
try {
|
|
16
|
-
|
|
17
|
-
return JSON.parse(readFileSync(require.resolve(`${PKG}/package.json`), 'utf8')).version;
|
|
19
|
+
return createRequire(join(process.cwd(), 'package.json')).resolve(`${PKG}/package.json`);
|
|
18
20
|
} catch { return null; }
|
|
19
21
|
};
|
|
22
|
+
const installedVersion = () => {
|
|
23
|
+
const p = installedPkgJson();
|
|
24
|
+
try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
|
|
25
|
+
};
|
|
20
26
|
const latestVersion = async () => {
|
|
21
|
-
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest
|
|
27
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, {
|
|
28
|
+
signal: AbortSignal.timeout(10_000),
|
|
29
|
+
});
|
|
22
30
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
23
31
|
return (await res.json()).version;
|
|
24
32
|
};
|
|
@@ -28,7 +36,33 @@ const semverLt = (a, b) => {
|
|
|
28
36
|
return false;
|
|
29
37
|
};
|
|
30
38
|
|
|
39
|
+
// Release notes come from the CHANGELOG that changesets writes into the
|
|
40
|
+
// package itself, read after the install from the freshly unpacked copy. No
|
|
41
|
+
// git-host API is involved on purpose: the repository lives on a private
|
|
42
|
+
// OneDev instance, so anything API-based would need a token on every site
|
|
43
|
+
// that runs this command.
|
|
44
|
+
const changelogFor = (pkgJsonPath) => (pkgJsonPath ? read(join(dirname(pkgJsonPath), 'CHANGELOG.md')) : '');
|
|
45
|
+
// Entries newer than `current` up to `latest`, oldest first. Changesets writes
|
|
46
|
+
// one "## X.Y.Z" heading per release.
|
|
47
|
+
const releaseNotes = (md, current, latest) => {
|
|
48
|
+
// split on every "## " heading, not just the version ones, so a hand-written
|
|
49
|
+
// "## Unreleased" section cannot get swallowed into the entry above it
|
|
50
|
+
const heads = [...md.matchAll(/^## +(.*)$/gm)];
|
|
51
|
+
return heads
|
|
52
|
+
.map((h, i) => ({
|
|
53
|
+
version: h[1].trim().match(/^\d+\.\d+\.\d+$/)?.[0],
|
|
54
|
+
body: md.slice(h.index + h[0].length, heads[i + 1]?.index ?? md.length).trim(),
|
|
55
|
+
}))
|
|
56
|
+
.filter((r) => r.version && semverLt(current, r.version) && !semverLt(latest, r.version))
|
|
57
|
+
.sort((a, b) => (semverLt(a.version, b.version) ? -1 : 1));
|
|
58
|
+
};
|
|
59
|
+
const deployTargets = () => ({
|
|
60
|
+
cloudflare: ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'].some((f) => existsSync(f)),
|
|
61
|
+
docker: ['compose.yml', 'compose.yaml', 'docker-compose.yml', 'Dockerfile'].some((f) => existsSync(f)),
|
|
62
|
+
});
|
|
63
|
+
|
|
31
64
|
if (cmd === 'update') {
|
|
65
|
+
const checkOnly = process.argv.includes('--check');
|
|
32
66
|
const current = installedVersion();
|
|
33
67
|
if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
|
|
34
68
|
let latest;
|
|
@@ -37,13 +71,217 @@ if (cmd === 'update') {
|
|
|
37
71
|
console.log(`✓ Already up to date (${current}).`);
|
|
38
72
|
process.exit(0);
|
|
39
73
|
}
|
|
74
|
+
if (checkOnly) {
|
|
75
|
+
console.log(`Update available: ${current} → ${latest}. Run: npx acms update`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
40
78
|
console.log(`Updating ${PKG} ${current} → ${latest}…`);
|
|
41
79
|
const res = spawnSync('npm', ['install', `${PKG}@latest`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
42
80
|
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
43
|
-
console.log(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
81
|
+
console.log(`\n✓ Updated to ${latest}.`);
|
|
82
|
+
const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
|
|
83
|
+
if (notes.length) {
|
|
84
|
+
console.log(`\nWhat's new:`);
|
|
85
|
+
for (const r of notes) {
|
|
86
|
+
console.log(`\n ${PKG} ${r.version}`);
|
|
87
|
+
if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
|
|
91
|
+
}
|
|
92
|
+
const t = deployTargets();
|
|
93
|
+
const steps = [
|
|
94
|
+
...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
|
|
95
|
+
...(t.docker ? [' git push, then on your server: docker compose up -d --build'] : []),
|
|
96
|
+
];
|
|
97
|
+
console.log([
|
|
98
|
+
'',
|
|
99
|
+
'Deploy when ready:',
|
|
100
|
+
...(steps.length ? steps : [' rebuild and redeploy your host']),
|
|
101
|
+
' (the database migrates itself on the first request — no manual steps)',
|
|
102
|
+
].join('\n'));
|
|
103
|
+
// the registry fetch leaves a keep-alive socket in undici's pool, which can
|
|
104
|
+
// hold the event loop open for seconds after there is nothing left to do
|
|
105
|
+
process.exit(0);
|
|
106
|
+
} else if (cmd === 'add') {
|
|
107
|
+
// Retrofit a deployment target onto an existing project. Both targets share
|
|
108
|
+
// one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
|
|
109
|
+
// "adding" one is only config files + deps. Content moves between backends
|
|
110
|
+
// with a backup ZIP (admin → Backup, then admin → Restore on the new host).
|
|
111
|
+
const what = process.argv[3];
|
|
112
|
+
if (!existsSync('package.json')) {
|
|
113
|
+
console.error("✗ No package.json here — run this from your site's folder.");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
117
|
+
const slug = (pkg.name || 'my-site').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'my-site';
|
|
118
|
+
const writeNew = (file, body) => {
|
|
119
|
+
if (existsSync(file)) { console.log(`• ${file} already exists — left untouched`); return false; }
|
|
120
|
+
writeFileSync(file, body);
|
|
121
|
+
console.log(`✓ wrote ${file}`);
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
const setScript = (name, value) => {
|
|
125
|
+
pkg.scripts ??= {};
|
|
126
|
+
if (pkg.scripts[name]) return;
|
|
127
|
+
pkg.scripts[name] = value;
|
|
128
|
+
console.log(`✓ added "${name}" script`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (what === 'cloudflare') {
|
|
132
|
+
writeNew('wrangler.jsonc', `{
|
|
133
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
134
|
+
"name": "${slug}",
|
|
135
|
+
"main": "./dist/_worker.js/index.js",
|
|
136
|
+
"compatibility_date": "2025-07-01",
|
|
137
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
138
|
+
"assets": {
|
|
139
|
+
"binding": "ASSETS",
|
|
140
|
+
"directory": "./dist"
|
|
141
|
+
},
|
|
142
|
+
"observability": {
|
|
143
|
+
"enabled": true
|
|
144
|
+
},
|
|
145
|
+
// Scheduled publishing: flips due scheduled posts to published every 5
|
|
146
|
+
// minutes, independent of site traffic.
|
|
147
|
+
"triggers": {
|
|
148
|
+
"crons": ["*/5 * * * *"]
|
|
149
|
+
},
|
|
150
|
+
// If the id below is still zeros, create the real database with
|
|
151
|
+
// npx wrangler d1 create ${slug}-db
|
|
152
|
+
// and paste the returned database_id here. Local dev (wrangler dev /
|
|
153
|
+
// astro dev) uses a local simulator and works with the placeholder.
|
|
154
|
+
"d1_databases": [
|
|
155
|
+
{
|
|
156
|
+
"binding": "DB",
|
|
157
|
+
"database_name": "${slug}-db",
|
|
158
|
+
"database_id": "00000000-0000-0000-0000-000000000000"
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
// If missing, create the real bucket with:
|
|
162
|
+
// npx wrangler r2 bucket create ${slug}-media
|
|
163
|
+
// Local dev uses a local simulator and works without creating anything.
|
|
164
|
+
"r2_buckets": [
|
|
165
|
+
{
|
|
166
|
+
"binding": "MEDIA",
|
|
167
|
+
"bucket_name": "${slug}-media"
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
`);
|
|
172
|
+
setScript('preview', 'npm run build && wrangler dev');
|
|
173
|
+
setScript('deploy', 'npm run build && wrangler deploy');
|
|
174
|
+
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
175
|
+
if (!pkg.devDependencies?.wrangler && !pkg.dependencies?.wrangler) {
|
|
176
|
+
console.log('Installing wrangler…');
|
|
177
|
+
const res = spawnSync('npm', ['install', '-D', 'wrangler', '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
178
|
+
if (res.status !== 0) console.error('✗ npm install -D wrangler failed — install it manually.');
|
|
179
|
+
}
|
|
180
|
+
if (process.argv.includes('--provision')) {
|
|
181
|
+
console.log(`Creating D1 database ${slug}-db…`);
|
|
182
|
+
const d1 = spawnSync('npx', ['--no-install', 'wrangler', 'd1', 'create', `${slug}-db`], { encoding: 'utf8' });
|
|
183
|
+
const id = (d1.stdout || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0];
|
|
184
|
+
if (d1.status === 0 && id) {
|
|
185
|
+
writeFileSync('wrangler.jsonc', readFileSync('wrangler.jsonc', 'utf8').replace('00000000-0000-0000-0000-000000000000', id));
|
|
186
|
+
console.log(`✓ D1 created (${id})`);
|
|
187
|
+
} else {
|
|
188
|
+
console.error(`✗ D1 creation failed — create it later with: npx wrangler d1 create ${slug}-db`);
|
|
189
|
+
}
|
|
190
|
+
const r2 = spawnSync('npx', ['--no-install', 'wrangler', 'r2', 'bucket', 'create', `${slug}-media`], { encoding: 'utf8' });
|
|
191
|
+
console.log(r2.status === 0 ? '✓ R2 bucket created' : `✗ R2 creation failed — create it later with: npx wrangler r2 bucket create ${slug}-media`);
|
|
192
|
+
}
|
|
193
|
+
console.log(`
|
|
194
|
+
✓ Cloudflare target ready. Next:
|
|
195
|
+
npx wrangler d1 create ${slug}-db # unless --provision did it; paste the id into wrangler.jsonc
|
|
196
|
+
npx wrangler secret put ADMIN_PASSWORD
|
|
197
|
+
npx wrangler secret put SESSION_SECRET
|
|
198
|
+
npm run deploy
|
|
199
|
+
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
200
|
+
} else if (what === 'docker') {
|
|
201
|
+
writeNew('Dockerfile', `# Self-hosted Node target. Build/run via compose.yml, or directly:
|
|
202
|
+
# docker build -t ${slug} . && docker run -p 4321:4321 -v acms_data:/app/data ${slug}
|
|
203
|
+
FROM node:22-slim AS build
|
|
204
|
+
WORKDIR /app
|
|
205
|
+
COPY package.json package-lock.json* *.tgz* ./
|
|
206
|
+
RUN npm install --no-audit --no-fund
|
|
207
|
+
# self-heal npm/cli#4828: a lockfile updated on macOS can omit the Linux
|
|
208
|
+
# platform binaries (rollup, libsql) while still "installing" cleanly
|
|
209
|
+
RUN node -e "require('rollup')" >/dev/null 2>&1 || \\
|
|
210
|
+
(rm -rf node_modules package-lock.json && npm install --no-audit --no-fund)
|
|
211
|
+
COPY . .
|
|
212
|
+
RUN DEPLOY_TARGET=node npm run build:node
|
|
213
|
+
|
|
214
|
+
FROM node:22-slim
|
|
215
|
+
# WORKDIR must stay stable: SQLITE_URL and the media store resolve ./data
|
|
216
|
+
# against the process CWD
|
|
217
|
+
WORKDIR /app
|
|
218
|
+
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
|
|
219
|
+
# the standalone bundle in dist/ contains all app code; the only runtime
|
|
220
|
+
# import left in node_modules is @libsql/client (dynamically imported so it
|
|
221
|
+
# stays out of the Workers bundle) — installing just that keeps the image
|
|
222
|
+
# ~300MB instead of ~2GB
|
|
223
|
+
RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
|
|
224
|
+
COPY --from=build /app/dist ./dist
|
|
225
|
+
# ./data holds the SQLite database and uploaded media — mount a volume here.
|
|
226
|
+
# Only data/ needs to be writable (a chown -R of /app would copy-on-write
|
|
227
|
+
# duplicate every node_modules file into a fresh layer)
|
|
228
|
+
RUN mkdir -p data && chown node:node data
|
|
229
|
+
USER node
|
|
230
|
+
EXPOSE 4321
|
|
231
|
+
CMD ["node", "./dist/server/entry.mjs"]
|
|
232
|
+
`);
|
|
233
|
+
writeNew('compose.yml', `# Self-hosting on your own server. A reverse proxy you already run (Coolify,
|
|
234
|
+
# Dokploy, Traefik, nginx, Caddy) terminates TLS and routes to port 4321.
|
|
235
|
+
# Secrets come from .env in this directory (compose reads it automatically).
|
|
236
|
+
services:
|
|
237
|
+
site:
|
|
238
|
+
build: .
|
|
239
|
+
restart: unless-stopped
|
|
240
|
+
expose:
|
|
241
|
+
- "4321"
|
|
242
|
+
# No reverse proxy? Publish the port directly instead:
|
|
243
|
+
# ports:
|
|
244
|
+
# - "4321:4321"
|
|
245
|
+
volumes:
|
|
246
|
+
# one volume covers everything: SQLite database + uploaded media
|
|
247
|
+
- data:/app/data
|
|
248
|
+
environment:
|
|
249
|
+
- HOST=0.0.0.0
|
|
250
|
+
- PORT=4321
|
|
251
|
+
- ADMIN_PASSWORD=\${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
|
252
|
+
- SESSION_SECRET=\${SESSION_SECRET:?set SESSION_SECRET in .env}
|
|
253
|
+
- SQLITE_URL=file:./data/acms.db
|
|
254
|
+
healthcheck:
|
|
255
|
+
# also serves as the scheduled-publish heartbeat: each probe is a real
|
|
256
|
+
# request, which triggers the middleware publish sweep
|
|
257
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
258
|
+
interval: 300s
|
|
259
|
+
start_period: 15s
|
|
260
|
+
|
|
261
|
+
volumes:
|
|
262
|
+
data:
|
|
263
|
+
`);
|
|
264
|
+
writeNew('.dockerignore', `node_modules
|
|
265
|
+
dist
|
|
266
|
+
.astro
|
|
267
|
+
data
|
|
268
|
+
.env
|
|
269
|
+
.dev.vars
|
|
270
|
+
.wrangler
|
|
271
|
+
.git
|
|
272
|
+
`);
|
|
273
|
+
setScript('build:node', 'DEPLOY_TARGET=node astro build');
|
|
274
|
+
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
275
|
+
console.log(`
|
|
276
|
+
✓ Docker target ready. Next (on your server):
|
|
277
|
+
set real ADMIN_PASSWORD / SESSION_SECRET in .env
|
|
278
|
+
docker compose up -d --build # or point Coolify/Dokploy/Railway at the repo
|
|
279
|
+
data lives in the volume at /app/data (SQLite + media)
|
|
280
|
+
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
281
|
+
} else {
|
|
282
|
+
console.error('Usage: acms add <cloudflare|docker> [--provision]');
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
47
285
|
} else if (cmd === 'doctor') {
|
|
48
286
|
let bad = 0;
|
|
49
287
|
const check = (ok, label, hint) => {
|
|
@@ -69,7 +307,10 @@ if (cmd === 'update') {
|
|
|
69
307
|
console.log(`acms — maintenance CLI for @acms-subir/core
|
|
70
308
|
|
|
71
309
|
Usage:
|
|
72
|
-
acms update
|
|
73
|
-
acms
|
|
310
|
+
acms update Update the CMS core to the latest release
|
|
311
|
+
acms update --check Report whether an update exists (exit 1 if behind)
|
|
312
|
+
acms doctor Check this project's setup
|
|
313
|
+
acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
|
|
314
|
+
acms add docker Add Docker self-hosting to this project`);
|
|
74
315
|
process.exit(cmd ? 1 : 0);
|
|
75
316
|
}
|
package/integration.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
|
|
4
5
|
// This file is loaded by Node while Astro evaluates the consumer's
|
|
@@ -27,7 +28,6 @@ const ROUTES = [
|
|
|
27
28
|
['/[...slug]', '[...slug].astro'],
|
|
28
29
|
['/author/[slug]', 'author/[slug].astro'],
|
|
29
30
|
['/links', 'links.astro'],
|
|
30
|
-
['/search', 'search.astro'],
|
|
31
31
|
['/go/[id]', 'go/[id].ts'],
|
|
32
32
|
['/media/[...key]', 'media/[...key].ts'],
|
|
33
33
|
['/embed/[pageId]/[blockId]', 'embed/[pageId]/[blockId].ts'],
|
|
@@ -180,6 +180,55 @@ function resolveI18n(i18n, contentBases) {
|
|
|
180
180
|
return { locales, defaultLocale };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Defaults + validation for acms({ search }). Search ships on; disabling it
|
|
184
|
+
// removes the /search page(s) and /api/search from the route table, and the
|
|
185
|
+
// layout skips the header trigger + modal (lib/search-config.ts).
|
|
186
|
+
function resolveSearch(search) {
|
|
187
|
+
if (search === undefined) return { enabled: true };
|
|
188
|
+
const unknown = Object.keys(search).filter((k) => k !== 'enabled');
|
|
189
|
+
if (unknown.length > 0) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`acms: unknown search key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — ` +
|
|
192
|
+
'the only valid key is search.enabled'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const enabled = search.enabled ?? true;
|
|
196
|
+
if (typeof enabled !== 'boolean') {
|
|
197
|
+
throw new Error('acms: search.enabled must be true or false');
|
|
198
|
+
}
|
|
199
|
+
return { enabled };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const semverLt = (a, b) => {
|
|
203
|
+
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
204
|
+
for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Dev-only update nudge: one log line when a newer core is on npm, checked at
|
|
209
|
+
// most once per 24h (stamp file in Astro's cache dir). Fire-and-forget — it
|
|
210
|
+
// never delays startup and swallows every failure. ACMS_NO_UPDATE_CHECK=1
|
|
211
|
+
// disables it entirely.
|
|
212
|
+
async function devUpdateNudge(cacheDir, logger) {
|
|
213
|
+
const stampFile = join(cacheDir, 'acms-update-check.json');
|
|
214
|
+
try {
|
|
215
|
+
const stamp = JSON.parse(readFileSync(stampFile, 'utf8'));
|
|
216
|
+
if (Date.now() - stamp.at < 86_400_000) return;
|
|
217
|
+
} catch {}
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetch('https://registry.npmjs.org/@acms-subir%2Fcore/latest', {
|
|
220
|
+
signal: AbortSignal.timeout(3000),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) return;
|
|
223
|
+
const latest = (await res.json()).version ?? '';
|
|
224
|
+
mkdirSync(dirname(stampFile), { recursive: true });
|
|
225
|
+
writeFileSync(stampFile, JSON.stringify({ at: Date.now(), latest }));
|
|
226
|
+
if (latest && semverLt(CORE_VERSION, latest)) {
|
|
227
|
+
logger.info(`v${latest} is available (you're on ${CORE_VERSION}) — npx acms update`);
|
|
228
|
+
}
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
183
232
|
const CONFIG_ID = 'virtual:acms-config';
|
|
184
233
|
const THEMES_ID = 'virtual:acms-user-themes';
|
|
185
234
|
|
|
@@ -231,6 +280,10 @@ function acmsVitePlugin(userConfig) {
|
|
|
231
280
|
* Home page content per language is authored in /admin as a normal page
|
|
232
281
|
* marked "home" with that language selected. Omit for monolingual sites —
|
|
233
282
|
* nothing changes.
|
|
283
|
+
* @param {{enabled?: boolean}} [options.search]
|
|
284
|
+
* Public site search: the /search page, the /api/search JSON endpoint, and
|
|
285
|
+
* the header search modal. On by default — set `enabled: false` to remove
|
|
286
|
+
* all three.
|
|
234
287
|
* @returns {import('astro').AstroIntegration}
|
|
235
288
|
*/
|
|
236
289
|
export default function acms(options = {}) {
|
|
@@ -240,12 +293,14 @@ export default function acms(options = {}) {
|
|
|
240
293
|
[content.tags.base, 'content.tags.base'],
|
|
241
294
|
]);
|
|
242
295
|
const i18n = resolveI18n(options.i18n, contentBases);
|
|
296
|
+
const search = resolveSearch(options.search);
|
|
243
297
|
const userConfig = {
|
|
244
298
|
coreVersion: CORE_VERSION,
|
|
245
299
|
brand: options.brand ?? {},
|
|
246
300
|
seed: options.seed ?? {},
|
|
247
301
|
content,
|
|
248
302
|
i18n,
|
|
303
|
+
search,
|
|
249
304
|
};
|
|
250
305
|
const routes = [...ROUTES];
|
|
251
306
|
for (const c of Object.values(content.collections)) {
|
|
@@ -257,13 +312,35 @@ export default function acms(options = {}) {
|
|
|
257
312
|
for (const l of i18n.locales) {
|
|
258
313
|
if (l !== i18n.defaultLocale) routes.push([`/${l}`, 'index.astro']);
|
|
259
314
|
}
|
|
315
|
+
if (search.enabled) {
|
|
316
|
+
routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts']);
|
|
317
|
+
// a locale-prefixed search page per non-default locale, mirroring the
|
|
318
|
+
// per-locale home routes; search.astro derives its locale from the path
|
|
319
|
+
for (const l of i18n.locales) {
|
|
320
|
+
if (l !== i18n.defaultLocale) routes.push([`/${l}/search`, 'search.astro']);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
let cacheDir = '';
|
|
260
324
|
return {
|
|
261
325
|
name: 'acms',
|
|
262
326
|
hooks: {
|
|
263
|
-
'astro:config:setup': ({ command, injectRoute, addMiddleware, updateConfig }) => {
|
|
327
|
+
'astro:config:setup': ({ config, command, injectRoute, addMiddleware, updateConfig }) => {
|
|
328
|
+
if (config.cacheDir) cacheDir = fileURLToPath(config.cacheDir);
|
|
264
329
|
updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
|
|
265
330
|
|
|
331
|
+
// "Project-local routes win" is enforced here: Astro registers
|
|
332
|
+
// injected and file-based routes at equal priority (and the injected
|
|
333
|
+
// one is served on a collision), so a route is only injected when the
|
|
334
|
+
// consumer has no file at the same pattern in src/pages.
|
|
335
|
+
const pagesDir = fileURLToPath(new URL('./pages/', config.srcDir));
|
|
336
|
+
const overridden = (pattern) => {
|
|
337
|
+
const rel = pattern === '/' ? 'index' : pattern.slice(1);
|
|
338
|
+
return ['.astro', '.ts', '/index.astro', '/index.ts'].some((suffix) =>
|
|
339
|
+
existsSync(`${pagesDir}${rel}${suffix}`)
|
|
340
|
+
);
|
|
341
|
+
};
|
|
266
342
|
for (const [pattern, file] of routes) {
|
|
343
|
+
if (overridden(pattern)) continue;
|
|
267
344
|
injectRoute({ pattern, entrypoint: entry(`./src/pages/${file}`) });
|
|
268
345
|
}
|
|
269
346
|
// Auth gate, trailing-slash canonicalization, redirect fallback,
|
|
@@ -281,6 +358,11 @@ export default function acms(options = {}) {
|
|
|
281
358
|
});
|
|
282
359
|
}
|
|
283
360
|
},
|
|
361
|
+
// fires only for `astro dev` — builds and previews never phone anywhere
|
|
362
|
+
'astro:server:start': ({ logger }) => {
|
|
363
|
+
if (!cacheDir || process.env.ACMS_NO_UPDATE_CHECK) return;
|
|
364
|
+
void devUpdateNudge(cacheDir, logger);
|
|
365
|
+
},
|
|
284
366
|
},
|
|
285
367
|
};
|
|
286
368
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Widget-based CMS for Astro on Cloudflare Workers
|
|
7
|
+
"description": "Widget-based CMS for Astro on Cloudflare Workers — admin UI, content storage, themes, and APIs shipped as one integration.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"homepage": "https://acms.subir.dev",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://
|
|
13
|
+
"url": "git+https://onedev.subir.dev/acms-subir.git",
|
|
14
14
|
"directory": "packages/core"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"files": [
|
|
41
41
|
"integration.mjs",
|
|
42
42
|
"cli.mjs",
|
|
43
|
-
"src"
|
|
43
|
+
"src",
|
|
44
|
+
"CHANGELOG.md"
|
|
44
45
|
],
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"peerDependencies": {
|
|
65
66
|
"@astrojs/cloudflare": "^12.6.13",
|
|
66
67
|
"@astrojs/node": "^9.2.0",
|
|
67
|
-
"@astrojs/react": "^
|
|
68
|
+
"@astrojs/react": "^4.4.2",
|
|
68
69
|
"astro": "^5.7.0",
|
|
69
70
|
"react": "^19.0.0",
|
|
70
71
|
"react-dom": "^19.0.0"
|