@acms-subir/core 0.3.1 → 0.3.2

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 2c349c0: Fix blank admin editors in `astro dev` on sites that install core from npm. Browser-side dependencies (TipTap, prismjs, mammoth and others) are now pre-bundled by Vite, so islands no longer fail with "doesn't provide an export named …".
8
+
9
+ Also fixed:
10
+
11
+ - A first request storm after an upgrade could run a new migration twice and fail with `UNIQUE constraint failed: migrations.version`.
12
+ - File and bookmark cards reloaded as plain links when a post was reopened in the editor.
13
+ - YouTube embeds now keep their start time (`?t=` / `start=`) and accept `youtube.com/live/…` links.
14
+ - `@tiptap/core` is now a declared dependency instead of relying on hoisting.
15
+
3
16
  ## 0.3.1
4
17
 
5
18
  ### Patch Changes
package/integration.mjs CHANGED
@@ -88,6 +88,19 @@ const ROUTES = [
88
88
  ['/api/restore', 'api/restore.ts'],
89
89
  ];
90
90
 
91
+ // Packages imported by browser-side code (admin islands and the libs they
92
+ // pull in). Keep in sync when a component gains a new dependency; the
93
+ // integration test checks every entry is a declared dependency.
94
+ export const CLIENT_DEPS = [
95
+ 'react', 'react-dom', 'react-dom/client',
96
+ '@tiptap/core', '@tiptap/react', '@tiptap/react/menus', '@tiptap/pm/state', '@tiptap/pm/view', '@tiptap/pm/model',
97
+ '@tiptap/starter-kit', '@tiptap/extensions', '@tiptap/extension-table', '@tiptap/extension-highlight',
98
+ '@tiptap/extension-code-block-lowlight', '@tiptap/suggestion',
99
+ '@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities',
100
+ 'prismjs', 'simple-icons', 'lowlight', 'hast-util-to-html', 'marked', 'turndown', 'zod',
101
+ 'mammoth/mammoth.browser.js',
102
+ ];
103
+
91
104
  // First segments of the fixed routes above — a configured base landing on one
92
105
  // of these would shadow a real page, so it's a build-time error.
93
106
  const RESERVED_BASES = new Set([
@@ -515,6 +528,25 @@ export default function acms(options = {}) {
515
528
  }
516
529
  updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
517
530
 
531
+ // Installed from npm, core's admin islands are source files inside
532
+ // node_modules, so Vite's dev scanner never crawls them: their
533
+ // dependencies are found only at runtime and CommonJS ones
534
+ // (use-sync-external-store via @tiptap/react, prismjs, mammoth) are
535
+ // served raw, which breaks hydration ("doesn't provide an export
536
+ // named …"), and late discoveries force a mid-load reload. Pre-bundle
537
+ // the whole client graph up front. The `core > dep` form resolves
538
+ // each package from core's own location, so it also works when the
539
+ // package manager doesn't hoist them. Build output is unaffected.
540
+ if (command === 'dev') {
541
+ updateConfig({
542
+ vite: {
543
+ optimizeDeps: {
544
+ include: CLIENT_DEPS.map((dep) => `@acms-subir/core > ${dep}`),
545
+ },
546
+ },
547
+ });
548
+ }
549
+
518
550
  // "Project-local routes win" is enforced here: Astro registers
519
551
  // injected and file-based routes at equal priority (and the injected
520
552
  // one is served on a collision), so a route is only injected when the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -59,6 +59,7 @@
59
59
  "@dnd-kit/sortable": "^10.0.0",
60
60
  "@dnd-kit/utilities": "^3.2.2",
61
61
  "@libsql/client": "^0.15.0",
62
+ "@tiptap/core": "^3.30.1",
62
63
  "@tiptap/extension-code-block-lowlight": "^3.30.1",
63
64
  "@tiptap/extension-highlight": "3.30.1",
64
65
  "@tiptap/extension-table": "^3.30.1",
@@ -3,6 +3,9 @@ import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
4
  import { useState } from 'react';
5
5
  import { cleanEventName } from '../../../lib/analytics';
6
+ import { detectEmbed } from '../../../lib/embeds';
7
+
8
+ export { detectEmbed, type EmbedInfo } from '../../../lib/embeds';
6
9
 
7
10
  // Card nodes shared by the post editor. renderHTML emits the public markup
8
11
  // (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
@@ -129,44 +132,6 @@ function ImageCardView({ node, updateAttributes, deleteNode, selected }: NodeVie
129
132
 
130
133
  // ---- embed ----------------------------------------------------------------
131
134
 
132
- export interface EmbedInfo {
133
- provider: string;
134
- embedSrc: string;
135
- aspect: boolean; // true → 16:9, false → fixed height
136
- }
137
-
138
- export function detectEmbed(raw: string): EmbedInfo | null {
139
- let url: URL;
140
- try {
141
- url = new URL(raw.trim());
142
- } catch {
143
- return null;
144
- }
145
- const host = url.hostname.replace(/^www\./, '');
146
- if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
147
- const id =
148
- host === 'youtu.be'
149
- ? url.pathname.slice(1).split('/')[0]
150
- : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')
151
- ? url.pathname.split('/')[2]
152
- : url.searchParams.get('v');
153
- if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}`, aspect: true };
154
- }
155
- if (host === 'vimeo.com' || host === 'player.vimeo.com') {
156
- const id = url.pathname.split('/').filter(Boolean).pop();
157
- if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
158
- }
159
- if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
160
- return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
161
- }
162
- if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
163
- return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
164
- }
165
- if (url.protocol === 'https:') {
166
- return { provider: 'iframe', embedSrc: raw, aspect: false };
167
- }
168
- return null;
169
- }
170
135
 
171
136
  export const EmbedCard = Node.create({
172
137
  name: 'embedCard',
@@ -40,6 +40,8 @@ export const FileCard = Node.create({
40
40
  return [
41
41
  {
42
42
  tag: 'a.pc-filecard',
43
+ // outrank the Link mark's a[href] rule, or the card reloads as a plain link
44
+ priority: 60,
43
45
  getAttrs: (el) => {
44
46
  const e = el as HTMLElement;
45
47
  return {
@@ -288,6 +290,8 @@ export const BookmarkCard = Node.create({
288
290
  return [
289
291
  {
290
292
  tag: 'a.pc-bookmark',
293
+ // outrank the Link mark's a[href] rule, or the card reloads as a plain link
294
+ priority: 60,
291
295
  getAttrs: (el) => {
292
296
  const e = el as HTMLElement;
293
297
  return {
@@ -0,0 +1,50 @@
1
+ // Embeddable-URL detection shared by the editor's embed card (paste →
2
+ // card) and the importers (a Ghost/WordPress embed → embedCard markup).
3
+ export interface EmbedInfo {
4
+ provider: string;
5
+ embedSrc: string;
6
+ aspect: boolean; // true → 16:9, false → fixed height
7
+ }
8
+
9
+ export function detectEmbed(raw: string): EmbedInfo | null {
10
+ let url: URL;
11
+ try {
12
+ url = new URL(raw.trim());
13
+ } catch {
14
+ return null;
15
+ }
16
+ const host = url.hostname.replace(/^www\./, '');
17
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
18
+ const id =
19
+ host === 'youtu.be'
20
+ ? url.pathname.slice(1).split('/')[0]
21
+ : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/') || url.pathname.startsWith('/live/')
22
+ ? url.pathname.split('/')[2]
23
+ : url.searchParams.get('v');
24
+ // keep a start offset (?start=90, ?t=1h2m3s) — sermon and talk links
25
+ // usually point into the middle of a stream
26
+ const start = youtubeStart(url.searchParams.get('start') ?? url.searchParams.get('t'));
27
+ if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}${start ? `?start=${start}` : ''}`, aspect: true };
28
+ }
29
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
30
+ const id = url.pathname.split('/').filter(Boolean).pop();
31
+ if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
32
+ }
33
+ if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
34
+ return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
35
+ }
36
+ if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
37
+ return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
38
+ }
39
+ if (url.protocol === 'https:') {
40
+ return { provider: 'iframe', embedSrc: raw, aspect: false };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function youtubeStart(raw: string | null): number {
46
+ if (!raw) return 0;
47
+ if (/^\d+s?$/.test(raw)) return parseInt(raw, 10);
48
+ const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
49
+ return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0;
50
+ }
@@ -1,23 +1,27 @@
1
1
  import type { ContentRepository } from './types';
2
2
 
3
- let repo: ContentRepository | null = null;
3
+ // The promise (not the instance) is cached: concurrent first requests would
4
+ // otherwise each create a repository and race to run pending migrations.
5
+ let repo: Promise<ContentRepository> | null = null;
4
6
 
5
7
  // Adapter priority: Cloudflare D1 (when the Worker binding exists —
6
8
  // production on Workers and local wrangler/astro dev via platformProxy)
7
9
  // → libSQL file (plain Node self-hosting).
8
10
  // Pass Astro.locals (or the endpoint's `locals`) so the D1 binding is
9
11
  // reachable; the chosen adapter is cached for the isolate's lifetime.
10
- export async function getRepo(locals?: unknown): Promise<ContentRepository> {
11
- if (repo) return repo;
12
+ export function getRepo(locals?: unknown): Promise<ContentRepository> {
13
+ repo ??= create(locals).catch((e) => { repo = null; throw e; });
14
+ return repo;
15
+ }
16
+
17
+ async function create(locals?: unknown): Promise<ContentRepository> {
12
18
  const d1 = (locals as { runtime?: { env?: { DB?: unknown } } } | undefined)?.runtime?.env?.DB;
13
19
  if (d1) {
14
20
  const { D1Repository } = await import('./d1');
15
- repo = new D1Repository(d1 as import('./d1').D1Database);
16
- } else {
17
- const { SqliteRepository } = await import('./sqlite');
18
- repo = new SqliteRepository();
21
+ return new D1Repository(d1 as import('./d1').D1Database);
19
22
  }
20
- return repo;
23
+ const { SqliteRepository } = await import('./sqlite');
24
+ return new SqliteRepository();
21
25
  }
22
26
 
23
27
  export type { ContentRepository };