@acms-subir/core 0.0.2 → 0.2.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 (47) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/cli.mjs +128 -16
  3. package/integration.mjs +96 -3
  4. package/package.json +14 -12
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +122 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/plaintext.ts +22 -0
  23. package/src/lib/repo/migrations.ts +52 -0
  24. package/src/lib/repo/sql.ts +289 -30
  25. package/src/lib/repo/types.ts +40 -4
  26. package/src/lib/schema.ts +53 -0
  27. package/src/lib/search-config.ts +10 -0
  28. package/src/lib/search-i18n.ts +45 -0
  29. package/src/pages/[...slug].astro +3 -1
  30. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  31. package/src/pages/admin/c/[collection]/index.astro +1 -0
  32. package/src/pages/admin/design.astro +6 -1
  33. package/src/pages/admin/index.astro +27 -10
  34. package/src/pages/admin/pages/[id].astro +10 -2
  35. package/src/pages/admin/pages/index.astro +1 -0
  36. package/src/pages/admin/settings.astro +148 -0
  37. package/src/pages/api/search.ts +44 -0
  38. package/src/pages/blog/[slug].astro +4 -1
  39. package/src/pages/index.astro +4 -1
  40. package/src/pages/links.astro +10 -4
  41. package/src/pages/preview/[type]/[id].astro +9 -4
  42. package/src/pages/search.astro +83 -18
  43. package/src/styles/admin.css +4 -0
  44. package/src/styles/cards.css +23 -0
  45. package/src/styles/editor.css +53 -1
  46. package/src/styles/site.css +88 -0
  47. package/src/lib/repo/search.ts +0 -14
package/CHANGELOG.md ADDED
@@ -0,0 +1,75 @@
1
+ # @acms-subir/core
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c568c1d: Manual publish: nothing goes live until you press Update
8
+
9
+ - Posts & pages (incl. the homepage): editing a live (published or scheduled)
10
+ document no longer pushes autosaves to the public site. Edits stage in a
11
+ private pending draft (new `draft` column, migration v22); the editor shows
12
+ "Published · edited — Saved, not live yet", the publish popover gains
13
+ **Update — push changes live** and **Discard pending changes**, and admin
14
+ lists mark pending documents with an `edited` pill. `/preview/…` (and share
15
+ links) render the pending version — exactly what Update will publish.
16
+ Deferred side-effects: the slug-rename 301 and the search index only update
17
+ at Update time, so staged edits can never leak through search or move a
18
+ live URL early. Unpublishing folds pending edits into the document; the
19
+ scheduled-publish sweep publishes the live copy and keeps pending edits
20
+ pending. Drafts survive backup/restore.
21
+ - Design screen: theme, token, logo, favicon and share-image changes stage in
22
+ `settings.draftDesign` (migration v23). The preview iframe renders the
23
+ staged design via the admin-only `?designPreview=1` flag while visitors
24
+ keep the live one; **Publish design** applies it, **Discard** reverts.
25
+ - Navigation & Links managers: the auto-save debounce is gone. Edits stay
26
+ local with an "Unsaved changes — not live yet" bar; **Save changes**
27
+ commits everything at once (add/edit/delete/reorder + links profile),
28
+ **Discard** restores the saved state, and leaving the page with unsaved
29
+ edits warns first.
30
+
31
+ ## 0.1.0
32
+
33
+ ### Minor Changes
34
+
35
+ - 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.
36
+ - 769f9de: Code-first content config: `acms({ content })` renames the post collection per project
37
+
38
+ - `content.posts.{singular,plural,base}` changes every user-visible label
39
+ (site nav, admin sidebar, headings, buttons, feeds) and moves the public
40
+ routes off `/blog` — validated at build time, defaults unchanged.
41
+ - Tag archives moved to a standalone `/tag/[slug]` (configurable via
42
+ `content.tags.base`); `<postsBase>/tag/*` always 301s to the new location.
43
+ - When `base` is renamed, `/blog/*` (plus any `content.posts.redirectFrom`
44
+ entries) 301s to the new base — only on requests that would otherwise 404,
45
+ so rules never shadow real content.
46
+ - Internal identifiers (DB rows, backup format, `/admin/posts`) are never
47
+ renamed, so existing backups and sessions survive.
48
+ - Scaffolder: new "What do you call your posts?" prompt (`--content
49
+ articles|releases|notes`) writes the block into `astro.config.mjs`.
50
+
51
+ - 769f9de: Multiple content collections: `acms({ content: { collections } })`
52
+
53
+ - Each key in the `collections` map gets its own public routes at `base`,
54
+ admin section at `/admin/c/<key>`, sidebar entry, dashboard stat, and
55
+ feed/sitemap entries. `posts` is the built-in collection; `content.posts`
56
+ stays as shorthand for `collections.posts`. Labels default from the key.
57
+ - Migration v19 rebuilds the posts table: new `collection` column (existing
58
+ rows land in `posts`) and slug uniqueness becomes per-collection, so the
59
+ same slug can exist in different collections. Pre-v19 backups restore
60
+ cleanly.
61
+ - One shared tag registry; tag archives group entries by collection when
62
+ more than one is configured. `/rss.xml` stays the everything-feed with a
63
+ collection `<category>` per item.
64
+ - The editor gains a collection switcher (multi-collection sites only);
65
+ moving a published entry between collections leaves a 301. Post-list cards
66
+ can target a collection.
67
+ - `/admin/posts` and `/admin/posts/[id]` 308 to their `/admin/c/posts`
68
+ equivalents. Removing a collection key hides its routes but never deletes
69
+ rows — the dashboard shows a restore notice.
70
+
71
+ - 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`.
72
+
73
+ ### Patch Changes
74
+
75
+ - 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,51 +1,152 @@
1
1
  #!/usr/bin/env node
2
2
  // `acms` — tiny maintenance CLI installed with @acms-subir/core.
3
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 update --next follow the canary channel (`next` dist-tag); sticky once installed
6
+ // acms update --latest leave the canary channel and go back to stable
4
7
  // acms doctor sanity-check the project setup
5
8
  // acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
6
9
  // acms add docker retrofit self-hosting (Dockerfile, compose.yml)
7
10
  import { spawnSync } from 'node:child_process';
8
11
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
9
12
  import { createRequire } from 'node:module';
10
- import { join } from 'node:path';
13
+ import { dirname, join } from 'node:path';
11
14
 
12
15
  const PKG = '@acms-subir/core';
13
16
  const cmd = process.argv[2];
14
17
 
15
18
  const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
16
- const installedVersion = () => {
19
+ const installedPkgJson = () => {
17
20
  try {
18
- const require = createRequire(join(process.cwd(), 'package.json'));
19
- return JSON.parse(readFileSync(require.resolve(`${PKG}/package.json`), 'utf8')).version;
21
+ return createRequire(join(process.cwd(), 'package.json')).resolve(`${PKG}/package.json`);
20
22
  } catch { return null; }
21
23
  };
22
- const latestVersion = async () => {
23
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`);
24
+ const installedVersion = () => {
25
+ const p = installedPkgJson();
26
+ try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
27
+ };
28
+ const latestVersion = async (tag = 'latest') => {
29
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/${tag}`, {
30
+ signal: AbortSignal.timeout(10_000),
31
+ });
32
+ if (res.status === 404 && tag !== 'latest') {
33
+ throw Object.assign(new Error(`the '${tag}' channel has no releases yet`), { noTag: true });
34
+ }
24
35
  if (!res.ok) throw new Error(`registry responded ${res.status}`);
25
36
  return (await res.json()).version;
26
37
  };
38
+ const splitPre = (v) => {
39
+ const i = v.indexOf('-');
40
+ return i === -1 ? [v, ''] : [v.slice(0, i), v.slice(i + 1)];
41
+ };
42
+ const isCanary = (v) => splitPre(v)[1] !== '';
27
43
  const semverLt = (a, b) => {
28
- const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
44
+ const [aBase, aPre] = splitPre(a), [bBase, bPre] = splitPre(b);
45
+ const pa = aBase.split('.').map(Number), pb = bBase.split('.').map(Number);
29
46
  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; }
47
+ // equal bases: a release outranks any of its prereleases; two prereleases
48
+ // compare identifier by identifier, numerically where both sides are numbers
49
+ if (!aPre) return false;
50
+ if (!bPre) return true;
51
+ const as = aPre.split('.'), bs = bPre.split('.');
52
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
53
+ const x = as[i], y = bs[i];
54
+ if (x === undefined) return true;
55
+ if (y === undefined) return false;
56
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
57
+ if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) < Number(y); }
58
+ else if (nx !== ny) return nx;
59
+ else if (x !== y) return x < y;
60
+ }
30
61
  return false;
31
62
  };
32
63
 
64
+ // Release notes come from the CHANGELOG that changesets writes into the
65
+ // package itself, read after the install from the freshly unpacked copy. No
66
+ // git-host API is involved on purpose: the repository lives on a private
67
+ // OneDev instance, so anything API-based would need a token on every site
68
+ // that runs this command.
69
+ const changelogFor = (pkgJsonPath) => (pkgJsonPath ? read(join(dirname(pkgJsonPath), 'CHANGELOG.md')) : '');
70
+ // Entries newer than `current` up to `latest`, oldest first. Changesets writes
71
+ // one "## X.Y.Z" heading per release.
72
+ const releaseNotes = (md, current, latest) => {
73
+ // split on every "## " heading, not just the version ones, so a hand-written
74
+ // "## Unreleased" section cannot get swallowed into the entry above it
75
+ const heads = [...md.matchAll(/^## +(.*)$/gm)];
76
+ return heads
77
+ .map((h, i) => ({
78
+ version: h[1].trim().match(/^\d+\.\d+\.\d+$/)?.[0],
79
+ body: md.slice(h.index + h[0].length, heads[i + 1]?.index ?? md.length).trim(),
80
+ }))
81
+ .filter((r) => r.version && semverLt(current, r.version) && !semverLt(latest, r.version))
82
+ .sort((a, b) => (semverLt(a.version, b.version) ? -1 : 1));
83
+ };
84
+ const deployTargets = () => ({
85
+ cloudflare: ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'].some((f) => existsSync(f)),
86
+ docker: ['compose.yml', 'compose.yaml', 'docker-compose.yml', 'Dockerfile'].some((f) => existsSync(f)),
87
+ });
88
+
33
89
  if (cmd === 'update') {
90
+ const checkOnly = process.argv.includes('--check');
34
91
  const current = installedVersion();
35
92
  if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
93
+ // Channel: --next opts into canary prereleases and is sticky — once a canary
94
+ // is installed, a plain `acms update` keeps following the next tag until
95
+ // --latest switches back to stable.
96
+ const backToStable = process.argv.includes('--latest') && isCanary(current);
97
+ const channel = process.argv.includes('--latest') ? 'latest'
98
+ : process.argv.includes('--next') || isCanary(current) ? 'next'
99
+ : 'latest';
36
100
  let latest;
37
- try { latest = await latestVersion(); } catch (e) { console.error(`✗ Could not reach the npm registry (${e.message}).`); process.exit(1); }
38
- if (!semverLt(current, latest)) {
39
- console.log(`✓ Already up to date (${current}).`);
101
+ try { latest = await latestVersion(channel); } catch (e) {
102
+ console.error(e.noTag ? `✗ ${e.message[0].toUpperCase()}${e.message.slice(1)}.` : `✗ Could not reach the npm registry (${e.message}).`);
103
+ process.exit(1);
104
+ }
105
+ // Leaving the canary channel is usually a downgrade (0.1.1-next.x → 0.1.0),
106
+ // so "am I behind" is the wrong question there — switch unconditionally.
107
+ if (!backToStable && !semverLt(current, latest)) {
108
+ console.log(`✓ Already up to date (${current}${channel === 'next' ? ', canary channel' : ''}).`);
40
109
  process.exit(0);
41
110
  }
42
- console.log(`Updating ${PKG} ${current} → ${latest}…`);
43
- const res = spawnSync('npm', ['install', `${PKG}@latest`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
111
+ if (checkOnly) {
112
+ console.log(`Update available: ${current} → ${latest}. Run: npx acms update${channel === 'next' ? ' --next' : backToStable ? ' --latest' : ''}`);
113
+ process.exit(1);
114
+ }
115
+ console.log(backToStable
116
+ ? `Returning to the stable channel: ${PKG} ${current} → ${latest}…`
117
+ : `Updating ${PKG} ${current} → ${latest}${channel === 'next' ? ' (canary)' : ''}…`);
118
+ const res = spawnSync('npm', ['install', `${PKG}@${channel}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
44
119
  if (res.status !== 0) process.exit(res.status ?? 1);
45
- console.log(`
46
- ✓ Updated to ${latest}. What's new: https://github.com/zurielbm/acms-subir/releases
47
- Deploy when ready: npm run deploy
48
- (the database migrates itself on the first request — no manual steps)`);
120
+ console.log(`\n✓ Updated to ${latest}.`);
121
+ const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
122
+ if (notes.length) {
123
+ console.log(`\nWhat's new:`);
124
+ for (const r of notes) {
125
+ console.log(`\n ${PKG} ${r.version}`);
126
+ if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
127
+ }
128
+ } else if (channel === 'next') {
129
+ console.log(`\nWhat's new: canary build — notes arrive with the next stable release.`);
130
+ } else {
131
+ console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
132
+ }
133
+ if (channel === 'next') {
134
+ console.log(`\nYou're on the canary channel; \`npx acms update --latest\` returns to stable.`);
135
+ }
136
+ const t = deployTargets();
137
+ const steps = [
138
+ ...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
139
+ ...(t.docker ? [' git push, then on your server: docker compose up -d --build'] : []),
140
+ ];
141
+ console.log([
142
+ '',
143
+ 'Deploy when ready:',
144
+ ...(steps.length ? steps : [' rebuild and redeploy your host']),
145
+ ' (the database migrates itself on the first request — no manual steps)',
146
+ ].join('\n'));
147
+ // the registry fetch leaves a keep-alive socket in undici's pool, which can
148
+ // hold the event loop open for seconds after there is nothing left to do
149
+ process.exit(0);
49
150
  } else if (cmd === 'add') {
50
151
  // Retrofit a deployment target onto an existing project. Both targets share
51
152
  // one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
@@ -212,6 +313,14 @@ data
212
313
  .dev.vars
213
314
  .wrangler
214
315
  .git
316
+ `);
317
+ writeNew('.env.example', `# Copy to .env and fill in real values — compose.yml reads .env automatically.
318
+ # .env itself is gitignored; this file is the committed record of what to set.
319
+ ADMIN_PASSWORD=change-me
320
+ # Random secret used to sign the session cookie (openssl rand -hex 32)
321
+ SESSION_SECRET=
322
+ # SQLite file used by the Node target (a volume mounts /app/data in Docker)
323
+ SQLITE_URL=file:./data/acms.db
215
324
  `);
216
325
  setScript('build:node', 'DEPLOY_TARGET=node astro build');
217
326
  writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
@@ -251,6 +360,9 @@ data
251
360
 
252
361
  Usage:
253
362
  acms update Update the CMS core to the latest release
363
+ acms update --check Report whether an update exists (exit 1 if behind)
364
+ acms update --next Follow the canary channel (prereleases); later updates stay on it
365
+ acms update --latest Leave the canary channel and return to stable releases
254
366
  acms doctor Check this project's setup
255
367
  acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
256
368
  acms add docker Add Docker self-hosting to this project`);
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,
@@ -280,6 +357,22 @@ export default function acms(options = {}) {
280
357
  vite: { resolve: { alias: { 'react-dom/server': 'react-dom/server.edge' } } },
281
358
  });
282
359
  }
360
+ // The Docker runtime image installs only @libsql/client, so the
361
+ // standalone bundle must carry every other dependency. Left to its
362
+ // default, Vite externalizes package imports (react-dom, astro's
363
+ // runtime, zod, marked, …) and the server crashes on its first
364
+ // import in the container. @libsql/client stays external: it is
365
+ // dynamically imported and ships native bindings.
366
+ if (target === 'node' && command === 'build') {
367
+ updateConfig({
368
+ vite: { ssr: { noExternal: true, external: ['@libsql/client'] } },
369
+ });
370
+ }
371
+ },
372
+ // fires only for `astro dev` — builds and previews never phone anywhere
373
+ 'astro:server:start': ({ logger }) => {
374
+ if (!cacheDir || process.env.ACMS_NO_UPDATE_CHECK) return;
375
+ void devUpdateNudge(cacheDir, logger);
283
376
  },
284
377
  },
285
378
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.0.2",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,7 +10,7 @@
10
10
  "homepage": "https://acms.subir.dev",
11
11
  "repository": {
12
12
  "type": "git",
13
- "url": "git+https://github.com/zurielbm/acms-subir.git",
13
+ "url": "git+https://onedev.subir.dev/acms-subir.git",
14
14
  "directory": "packages/core"
15
15
  },
16
16
  "keywords": [
@@ -22,7 +22,7 @@
22
22
  "workers"
23
23
  ],
24
24
  "bin": {
25
- "acms": "./cli.mjs"
25
+ "acms": "cli.mjs"
26
26
  },
27
27
  "exports": {
28
28
  ".": "./integration.mjs",
@@ -40,20 +40,22 @@
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",
47
48
  "@dnd-kit/sortable": "^10.0.0",
48
49
  "@dnd-kit/utilities": "^3.2.2",
49
50
  "@libsql/client": "^0.15.0",
50
- "@tiptap/extension-code-block-lowlight": "^3.29.2",
51
- "@tiptap/extension-table": "^3.29.2",
52
- "@tiptap/extensions": "^3.29.2",
53
- "@tiptap/pm": "^3.29.2",
54
- "@tiptap/react": "^3.29.2",
55
- "@tiptap/starter-kit": "^3.29.2",
56
- "@tiptap/suggestion": "^3.29.2",
51
+ "@tiptap/extension-code-block-lowlight": "^3.30.1",
52
+ "@tiptap/extension-highlight": "3.30.1",
53
+ "@tiptap/extension-table": "^3.30.1",
54
+ "@tiptap/extensions": "^3.30.1",
55
+ "@tiptap/pm": "^3.30.1",
56
+ "@tiptap/react": "^3.30.1",
57
+ "@tiptap/starter-kit": "^3.30.1",
58
+ "@tiptap/suggestion": "^3.30.1",
57
59
  "hast-util-to-html": "^9.0.5",
58
60
  "lowlight": "^3.3.0",
59
61
  "marked": "^15.0.0",
@@ -64,7 +66,7 @@
64
66
  "peerDependencies": {
65
67
  "@astrojs/cloudflare": "^12.6.13",
66
68
  "@astrojs/node": "^9.2.0",
67
- "@astrojs/react": "^6.0.2",
69
+ "@astrojs/react": "^4.4.2",
68
70
  "astro": "^5.7.0",
69
71
  "react": "^19.0.0",
70
72
  "react-dom": "^19.0.0"