@acms-subir/core 0.3.2 → 0.3.3-next.20260929113209
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/integration.mjs +5 -0
- package/package.json +1 -1
- package/src/components/import/ImportExport.tsx +519 -0
- package/src/components/import/runner.ts +284 -0
- package/src/lib/import/apply.ts +430 -0
- package/src/lib/import/bundle.ts +167 -0
- package/src/lib/import/export.ts +54 -0
- package/src/lib/import/ghost.ts +247 -0
- package/src/lib/import/lexical.ts +290 -0
- package/src/lib/import/migration.ts +27 -0
- package/src/lib/import/zipread.ts +72 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +116 -2
- package/src/lib/repo/types.ts +17 -0
- package/src/pages/admin/import.astro +16 -0
- package/src/pages/admin/settings.astro +11 -0
- package/src/pages/api/export.ts +41 -0
- package/src/pages/api/import/asset.ts +32 -0
- package/src/pages/api/import/index.ts +47 -0
- package/src/pages/api/import/media.ts +31 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { slugify } from '../schema';
|
|
2
|
+
import {
|
|
3
|
+
BUNDLE_FORMAT, BUNDLE_VERSION, assetPlaceholder,
|
|
4
|
+
type ImportAsset, type ImportAuthor, type ImportBundle, type ImportIdentity, type ImportItem, type ImportNav, type ImportTag, type ImportWarning,
|
|
5
|
+
} from './bundle';
|
|
6
|
+
import { lexicalToHtml, type LexicalContext } from './lexical';
|
|
7
|
+
|
|
8
|
+
// Ghost JSON export (Settings → Labs → Export) → Import Bundle.
|
|
9
|
+
//
|
|
10
|
+
// The export is a database dump: db[0].data holds ~20 tables, including
|
|
11
|
+
// Ghost's private keys and session/member secrets in `settings`. Only an
|
|
12
|
+
// allow-list of settings keys is read, and the raw file never leaves the
|
|
13
|
+
// browser — just the bundle built here is sent to the server.
|
|
14
|
+
//
|
|
15
|
+
// Images and files are NOT in the export; content references them as
|
|
16
|
+
// `__GHOST_URL__/content/(images|files|media)/…`. Each becomes an asset whose
|
|
17
|
+
// url is that content-relative path, fetched later from the old site or read
|
|
18
|
+
// from Ghost's content folder ZIP.
|
|
19
|
+
|
|
20
|
+
type Row = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
export function isGhostExport(json: unknown): boolean {
|
|
23
|
+
const db = (json as { db?: unknown })?.db;
|
|
24
|
+
const first = Array.isArray(db) ? (db[0] as Row | undefined) : undefined;
|
|
25
|
+
const meta = first?.meta as Row | undefined;
|
|
26
|
+
const data = first?.data as Row | undefined;
|
|
27
|
+
return typeof meta?.version === 'string' && Array.isArray(data?.posts);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GhostOptions {
|
|
31
|
+
// the old site's public origin, used to resolve absolute links back to
|
|
32
|
+
// relative ones and to build asset download URLs
|
|
33
|
+
siteUrl?: string;
|
|
34
|
+
// markdown card renderer (the app's `marked` setup)
|
|
35
|
+
markdown: (md: string) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
39
|
+
const time = (v: unknown): number | null => {
|
|
40
|
+
const t = typeof v === 'string' || typeof v === 'number' ? new Date(v).getTime() : NaN;
|
|
41
|
+
return Number.isFinite(t) ? t : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Ghost serves resized copies under content/images/size/w600/ (and
|
|
45
|
+
// format/webp/); the original is the same path without those segments.
|
|
46
|
+
export function ghostContentPath(url: string, siteUrl = ''): string | null {
|
|
47
|
+
let path: string | null = null;
|
|
48
|
+
if (url.startsWith('__GHOST_URL__/')) path = url.slice('__GHOST_URL__/'.length);
|
|
49
|
+
else if (siteUrl && url.startsWith(siteUrl.replace(/\/+$/, '') + '/')) path = url.slice(siteUrl.replace(/\/+$/, '').length + 1);
|
|
50
|
+
if (path === null || !/^content\/(images|files|media)\//.test(path)) return null;
|
|
51
|
+
path = path.split(/[?#]/)[0].replace(/^content\/images\/size\/[^/]+\/(format\/[^/]+\/)?/, 'content/images/');
|
|
52
|
+
try { path = decodeURI(path); } catch { /* keep as-is */ }
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ghostToBundle(json: unknown, options: GhostOptions): ImportBundle {
|
|
57
|
+
if (!isGhostExport(json)) throw new Error('This is not a Ghost export (expected db[0].data.posts).');
|
|
58
|
+
const first = (json as { db: Row[] }).db[0];
|
|
59
|
+
const meta = first.meta as Row;
|
|
60
|
+
const data = first.data as Record<string, Row[] | undefined>;
|
|
61
|
+
const table = (name: string) => (Array.isArray(data[name]) ? data[name]! : []);
|
|
62
|
+
const siteUrl = str(options.siteUrl).replace(/\/+$/, '');
|
|
63
|
+
|
|
64
|
+
const warnings: ImportWarning[] = [];
|
|
65
|
+
const assets = new Map<string, ImportAsset>();
|
|
66
|
+
function asset(url: string, kind: ImportAsset['kind'], m: { filename?: string; alt?: string } = {}): string {
|
|
67
|
+
const path = ghostContentPath(url, siteUrl);
|
|
68
|
+
if (!path) return url.startsWith('__GHOST_URL__') ? url.slice('__GHOST_URL__'.length) || '/' : url;
|
|
69
|
+
let a = assets.get(path);
|
|
70
|
+
if (!a) {
|
|
71
|
+
// the folder decides the kind: images/ get resized, files/ are
|
|
72
|
+
// documents, media/ is video/audio (not re-hosted yet)
|
|
73
|
+
const folder = path.split('/')[1];
|
|
74
|
+
kind = folder === 'images' ? 'image' : folder === 'media' ? 'media' : 'file';
|
|
75
|
+
a = { ref: `a${assets.size + 1}`, url: path, kind, filename: m.filename || path.split('/').pop() || '', alt: m.alt ?? '' };
|
|
76
|
+
assets.set(path, a);
|
|
77
|
+
} else if (!a.alt && m.alt) a.alt = m.alt;
|
|
78
|
+
return assetPlaceholder(a.ref);
|
|
79
|
+
}
|
|
80
|
+
function link(url: string): string {
|
|
81
|
+
if (!url) return '';
|
|
82
|
+
if (ghostContentPath(url, siteUrl)) return asset(url, 'file');
|
|
83
|
+
if (url.startsWith('__GHOST_URL__')) return url.slice('__GHOST_URL__'.length) || '/';
|
|
84
|
+
if (siteUrl && (url === siteUrl || url.startsWith(siteUrl + '/'))) return url.slice(siteUrl.length) || '/';
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
const ghostUrlRe = new RegExp(`(?:__GHOST_URL__${siteUrl ? `|${siteUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` : ''})(/[^\\s"'<>)&]*)?`, 'g');
|
|
88
|
+
let rawWarn: ((code: string, message: string) => void) | null = null;
|
|
89
|
+
function rawHtml(html: string): string {
|
|
90
|
+
return html.replace(ghostUrlRe, (m, _path, offset: number) => {
|
|
91
|
+
// a URL passed inside another URL (…/viewer?url=https://old/file.pdf)
|
|
92
|
+
// must stay absolute; a /media path would mean nothing to that service
|
|
93
|
+
if (html[offset - 1] === '=' && /^https?:/.test(m)) {
|
|
94
|
+
rawWarn?.('nested-url', `An embed loads ${m} through another service, so it still points at the old site. Re-link it after the import.`);
|
|
95
|
+
return m;
|
|
96
|
+
}
|
|
97
|
+
return ghostContentPath(m, siteUrl) ? asset(m, 'file') : link(m);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- settings (allow-list) ----
|
|
102
|
+
const settings = new Map(table('settings').map((s) => [str(s.key), s.value]));
|
|
103
|
+
const setting = (key: string) => str(settings.get(key)).trim();
|
|
104
|
+
const identity: ImportIdentity = {};
|
|
105
|
+
if (setting('title')) identity.siteTitle = setting('title');
|
|
106
|
+
if (setting('description')) identity.tagline = setting('description');
|
|
107
|
+
if (setting('logo')) identity.siteLogo = asset(setting('logo'), 'image');
|
|
108
|
+
if (setting('icon')) identity.siteFavicon = asset(setting('icon'), 'image');
|
|
109
|
+
if (setting('cover_image')) identity.siteCover = asset(setting('cover_image'), 'image');
|
|
110
|
+
if (setting('timezone')) identity.timezone = setting('timezone');
|
|
111
|
+
if (setting('locale')) identity.locale = setting('locale');
|
|
112
|
+
if (setting('codeinjection_head')) identity.headCode = setting('codeinjection_head');
|
|
113
|
+
|
|
114
|
+
const nav: ImportNav[] = [];
|
|
115
|
+
for (const [key, area] of [['navigation', 'header'], ['secondary_navigation', 'footer']] as const) {
|
|
116
|
+
let items: unknown;
|
|
117
|
+
try { items = JSON.parse(setting(key) || '[]'); } catch { items = []; }
|
|
118
|
+
for (const it of Array.isArray(items) ? (items as Row[]) : []) {
|
|
119
|
+
const label = str(it.label).trim();
|
|
120
|
+
const url = link(str(it.url).trim());
|
|
121
|
+
// #/portal/… links open Ghost's members portal, which doesn't exist here
|
|
122
|
+
if (!label || !url || url.includes('#/portal')) continue;
|
|
123
|
+
nav.push({ label, url, area });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const facebook = setting('facebook'), twitter = setting('twitter');
|
|
127
|
+
if (facebook && facebook !== 'null') nav.push({ label: 'Facebook', url: `https://www.facebook.com/${facebook.replace(/^@/, '')}`, area: 'social' });
|
|
128
|
+
if (twitter && twitter !== 'null') nav.push({ label: 'X', url: `https://x.com/${twitter.replace(/^@/, '')}`, area: 'social' });
|
|
129
|
+
|
|
130
|
+
// ---- authors ----
|
|
131
|
+
const authors: ImportAuthor[] = table('users').map((u) => ({
|
|
132
|
+
sourceId: str(u.id),
|
|
133
|
+
name: str(u.name).trim() || str(u.slug) || 'Author',
|
|
134
|
+
slug: str(u.slug),
|
|
135
|
+
email: str(u.email).trim().toLowerCase(),
|
|
136
|
+
bio: str(u.bio),
|
|
137
|
+
avatar: str(u.profile_image) ? asset(str(u.profile_image), 'image') : '',
|
|
138
|
+
inactive: ['locked', 'inactive'].includes(str(u.status)),
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
// ---- tags ----
|
|
142
|
+
const tagById = new Map<string, ImportTag>();
|
|
143
|
+
for (const t of table('tags')) {
|
|
144
|
+
const name = str(t.name).trim();
|
|
145
|
+
if (!name) continue;
|
|
146
|
+
tagById.set(str(t.id), { name, slug: str(t.slug) || slugify(name), description: str(t.description) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- joins, in Ghost's sort order ----
|
|
150
|
+
const joined = (tableName: string, key: string) => {
|
|
151
|
+
const map = new Map<string, string[]>();
|
|
152
|
+
const rows = [...table(tableName)].sort((a, b) => Number(a.sort_order ?? 0) - Number(b.sort_order ?? 0));
|
|
153
|
+
for (const r of rows) {
|
|
154
|
+
const list = map.get(str(r.post_id)) ?? [];
|
|
155
|
+
list.push(str(r[key]));
|
|
156
|
+
map.set(str(r.post_id), list);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
};
|
|
160
|
+
const postTags = joined('posts_tags', 'tag_id');
|
|
161
|
+
const postAuthors = joined('posts_authors', 'author_id');
|
|
162
|
+
const postMeta = new Map(table('posts_meta').map((m) => [str(m.post_id), m]));
|
|
163
|
+
|
|
164
|
+
// ---- posts & pages ----
|
|
165
|
+
const items: ImportItem[] = [];
|
|
166
|
+
for (const p of table('posts')) {
|
|
167
|
+
const id = str(p.id);
|
|
168
|
+
const title = str(p.title) || 'Untitled';
|
|
169
|
+
const warn = (code: string, message: string) => warnings.push({ item: id, code, message: `${title}: ${message}` });
|
|
170
|
+
const ctx: LexicalContext = { asset, link, rawHtml, markdown: options.markdown, warn };
|
|
171
|
+
rawWarn = warn;
|
|
172
|
+
|
|
173
|
+
let html = '';
|
|
174
|
+
if (str(p.lexical)) {
|
|
175
|
+
try { html = lexicalToHtml(JSON.parse(str(p.lexical)), ctx); }
|
|
176
|
+
catch { warn('unreadable-body', 'The Lexical body could not be read, so Ghost’s rendered HTML was used instead.'); html = rawHtml(str(p.html)); }
|
|
177
|
+
} else if (str(p.html)) {
|
|
178
|
+
// pre-Lexical (mobiledoc) posts: Ghost's rendered HTML still loads in
|
|
179
|
+
// the editor, but its kg-card markup won't become acms cards
|
|
180
|
+
warn('legacy-body', 'Written in Ghost’s older editor. Text and images import; check any cards.');
|
|
181
|
+
html = rawHtml(str(p.html));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let status = str(p.status);
|
|
185
|
+
if (status === 'sent') {
|
|
186
|
+
warn('email-only', 'This was an email-only newsletter, so it imports as a draft.');
|
|
187
|
+
status = 'draft';
|
|
188
|
+
}
|
|
189
|
+
if (!['draft', 'published', 'scheduled'].includes(status)) status = 'draft';
|
|
190
|
+
if (str(p.visibility) && str(p.visibility) !== 'public') {
|
|
191
|
+
warn('visibility', `Was visible to “${str(p.visibility)}” members only; acms has no memberships, so it will be public once published.`);
|
|
192
|
+
}
|
|
193
|
+
if (str(p.codeinjection_head) || str(p.codeinjection_foot)) {
|
|
194
|
+
warn('post-code', 'Has per-post code injection, which acms doesn’t support. Move it into the post as an HTML card if it’s still needed.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const m = postMeta.get(id) ?? {};
|
|
198
|
+
const created = time(p.created_at) ?? Date.now();
|
|
199
|
+
const published = time(p.published_at);
|
|
200
|
+
const slug = str(p.slug) || slugify(title);
|
|
201
|
+
items.push({
|
|
202
|
+
sourceId: id,
|
|
203
|
+
type: str(p.type) === 'page' ? 'page' : 'post',
|
|
204
|
+
collection: 'posts',
|
|
205
|
+
slug,
|
|
206
|
+
title,
|
|
207
|
+
status: status as ImportItem['status'],
|
|
208
|
+
html,
|
|
209
|
+
excerpt: str(p.custom_excerpt),
|
|
210
|
+
cover: str(p.feature_image) ? asset(str(p.feature_image), 'image', { alt: str(m.feature_image_alt) }) : '',
|
|
211
|
+
coverAlt: str(m.feature_image_alt),
|
|
212
|
+
seoTitle: str(m.meta_title),
|
|
213
|
+
seoDesc: str(m.meta_description),
|
|
214
|
+
featured: Boolean(p.featured),
|
|
215
|
+
template: '',
|
|
216
|
+
locale: '',
|
|
217
|
+
tags: (postTags.get(id) ?? []).map((t) => tagById.get(t)?.name).filter((n): n is string => Boolean(n)),
|
|
218
|
+
authors: postAuthors.get(id) ?? [],
|
|
219
|
+
publishedAt: status === 'draft' ? null : published,
|
|
220
|
+
scheduledAt: status === 'scheduled' ? published : null,
|
|
221
|
+
createdAt: created,
|
|
222
|
+
updatedAt: time(p.updated_at) ?? created,
|
|
223
|
+
legacyPath: `/${slug}/`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
format: BUNDLE_FORMAT,
|
|
229
|
+
version: BUNDLE_VERSION,
|
|
230
|
+
source: {
|
|
231
|
+
kind: 'ghost',
|
|
232
|
+
version: str(meta.version),
|
|
233
|
+
title: identity.siteTitle ?? '',
|
|
234
|
+
siteUrl,
|
|
235
|
+
exportedAt: time(meta.exported_on) ? new Date(time(meta.exported_on)!).toISOString() : '',
|
|
236
|
+
},
|
|
237
|
+
identity,
|
|
238
|
+
authors,
|
|
239
|
+
tags: [...tagById.values()],
|
|
240
|
+
items,
|
|
241
|
+
assets: [...assets.values()],
|
|
242
|
+
media: [],
|
|
243
|
+
nav,
|
|
244
|
+
redirects: [],
|
|
245
|
+
warnings,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { detectEmbed } from '../embeds';
|
|
2
|
+
import { escapeAttr, escapeHtml } from './bundle';
|
|
3
|
+
|
|
4
|
+
// Ghost's Lexical document (posts.lexical) → editor-compatible HTML. Every
|
|
5
|
+
// card is emitted as the exact markup its acms editor node renders (see
|
|
6
|
+
// components/editor/extensions/*), so the public page renders it with the
|
|
7
|
+
// same styles and the editor's parseHTML rules load it back as that card.
|
|
8
|
+
// Pure string building, no DOM: runs in the browser, in Workers and in tests.
|
|
9
|
+
|
|
10
|
+
export interface LexicalNode {
|
|
11
|
+
type?: string;
|
|
12
|
+
children?: LexicalNode[];
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LexicalContext {
|
|
17
|
+
// a file hosted by the source site → acms-asset placeholder (or the URL
|
|
18
|
+
// itself when it isn't the source's own file)
|
|
19
|
+
asset(url: string, kind: 'image' | 'file' | 'media', meta?: { filename?: string; alt?: string }): string;
|
|
20
|
+
// an ordinary link target (internal source links become relative paths)
|
|
21
|
+
link(url: string): string;
|
|
22
|
+
// raw HTML from an HTML/markdown card, with the source's file URLs swapped
|
|
23
|
+
rawHtml(html: string): string;
|
|
24
|
+
markdown(md: string): string;
|
|
25
|
+
warn(code: string, message: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Lexical text format bitmask
|
|
29
|
+
const BOLD = 1, ITALIC = 2, STRIKE = 4, UNDERLINE = 8, CODE = 16, HIGHLIGHT = 128;
|
|
30
|
+
|
|
31
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
32
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
33
|
+
|
|
34
|
+
// Ghost stores captions and a few card fields as small HTML fragments; acms
|
|
35
|
+
// card captions are plain text.
|
|
36
|
+
export function htmlToText(html: string): string {
|
|
37
|
+
return decodeEntities(html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, ''))
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
function decodeEntities(s: string): string {
|
|
42
|
+
return s
|
|
43
|
+
.replace(/ /g, ' ')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'|'/g, "'")
|
|
48
|
+
.replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))
|
|
49
|
+
.replace(/&/g, '&');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Block content for containers that require it (toggle bodies): wrap bare
|
|
53
|
+
// inline HTML in a paragraph.
|
|
54
|
+
function asBlocks(html: string): string {
|
|
55
|
+
const t = html.trim();
|
|
56
|
+
if (!t) return '<p></p>';
|
|
57
|
+
return /^<(p|h[1-6]|ul|ol|blockquote|pre|figure|hr|table|div|aside|details)\b/i.test(t) ? t : `<p>${t}</p>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function lexicalToHtml(doc: unknown, ctx: LexicalContext): string {
|
|
61
|
+
const root = (doc && typeof doc === 'object' && 'root' in doc ? (doc as { root: LexicalNode }).root : doc) as LexicalNode | null;
|
|
62
|
+
if (!root || !Array.isArray(root.children)) return '';
|
|
63
|
+
return blocks(root.children, ctx);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function blocks(nodes: LexicalNode[], ctx: LexicalContext): string {
|
|
67
|
+
return nodes.map((n) => block(n, ctx)).join('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inline(nodes: LexicalNode[] | undefined, ctx: LexicalContext): string {
|
|
71
|
+
let out = '';
|
|
72
|
+
for (const n of nodes ?? []) {
|
|
73
|
+
switch (n.type) {
|
|
74
|
+
case 'text':
|
|
75
|
+
case 'extended-text': {
|
|
76
|
+
let t = escapeHtml(str(n.text));
|
|
77
|
+
const f = num(n.format);
|
|
78
|
+
if (f & CODE) t = `<code>${t}</code>`;
|
|
79
|
+
if (f & HIGHLIGHT) t = `<mark>${t}</mark>`;
|
|
80
|
+
if (f & STRIKE) t = `<s>${t}</s>`;
|
|
81
|
+
if (f & UNDERLINE) t = `<u>${t}</u>`;
|
|
82
|
+
if (f & ITALIC) t = `<em>${t}</em>`;
|
|
83
|
+
if (f & BOLD) t = `<strong>${t}</strong>`;
|
|
84
|
+
out += t;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'linebreak':
|
|
88
|
+
out += '<br>';
|
|
89
|
+
break;
|
|
90
|
+
case 'tab':
|
|
91
|
+
out += ' ';
|
|
92
|
+
break;
|
|
93
|
+
case 'link':
|
|
94
|
+
case 'autolink': {
|
|
95
|
+
const href = ctx.link(str(n.url));
|
|
96
|
+
out += href ? `<a href="${escapeAttr(href)}">${inline(n.children, ctx)}</a>` : inline(n.children, ctx);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
// unknown inline wrappers (hashtags, mentions…) keep their text
|
|
101
|
+
out += n.children ? inline(n.children, ctx) : escapeHtml(str(n.text));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function list(n: LexicalNode, ctx: LexicalContext): string {
|
|
108
|
+
const ordered = n.listType === 'number' || n.tag === 'ol';
|
|
109
|
+
// Lexical nests a sub-list as its own listitem holding only the list; in
|
|
110
|
+
// HTML (and the editor) it belongs inside the previous item.
|
|
111
|
+
const items: string[] = [];
|
|
112
|
+
for (const li of n.children ?? []) {
|
|
113
|
+
const kids = li.children ?? [];
|
|
114
|
+
const nested = kids.filter((k) => k.type === 'list');
|
|
115
|
+
const body = kids.filter((k) => k.type !== 'list');
|
|
116
|
+
const sub = nested.map((k) => list(k, ctx)).join('');
|
|
117
|
+
if (!body.length && sub && items.length) {
|
|
118
|
+
items[items.length - 1] = items[items.length - 1].replace(/<\/li>$/, `${sub}</li>`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
items.push(`<li><p>${inline(body, ctx)}</p>${sub}</li>`);
|
|
122
|
+
}
|
|
123
|
+
if (!items.length) return '';
|
|
124
|
+
const start = ordered && num(n.start) > 1 ? ` start="${num(n.start)}"` : '';
|
|
125
|
+
return ordered ? `<ol${start}>${items.join('')}</ol>` : `<ul>${items.join('')}</ul>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function figureCaption(caption: unknown): string {
|
|
129
|
+
const t = htmlToText(str(caption));
|
|
130
|
+
return t ? `<figcaption>${escapeHtml(t)}</figcaption>` : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function imageTag(src: string, alt: string, width: number, height: number): string {
|
|
134
|
+
const dims = width && height ? ` width="${width}" height="${height}"` : '';
|
|
135
|
+
return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" loading="lazy"${dims}>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function embedCard(url: string, caption: unknown): string | null {
|
|
139
|
+
const info = detectEmbed(url);
|
|
140
|
+
if (!info || info.provider === 'iframe') return null;
|
|
141
|
+
const frame = `<div class="pc-embed-frame" style="aspect-ratio:16/9"><iframe src="${escapeAttr(info.embedSrc)}" loading="lazy" allowfullscreen="" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe></div>`;
|
|
142
|
+
return `<figure class="pc-embed" data-provider="${info.provider}" data-url="${escapeAttr(url)}" data-height="400">${frame}${figureCaption(caption)}</figure>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function htmlCard(html: string): string {
|
|
146
|
+
return html.trim() ? `<div class="pc-html">${html}</div>` : '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A table pasted into Ghost's HTML card becomes a real table: the editor's
|
|
150
|
+
// table extension parses plain <table> markup, so it stays editable.
|
|
151
|
+
const TABLE_ONLY = /^\s*<table\b[^>]*>[\s\S]*<\/table>\s*$/i;
|
|
152
|
+
function plainTable(html: string): string | null {
|
|
153
|
+
if (!TABLE_ONLY.test(html) || /<table\b[\s\S]*<table\b/i.test(html)) return null;
|
|
154
|
+
// styled tables keep their look as an HTML card
|
|
155
|
+
if (/<(script|style|iframe|form|input|img)\b|\s(on\w+|style|class)=/i.test(html)) return null;
|
|
156
|
+
return html.trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function block(n: LexicalNode, ctx: LexicalContext): string {
|
|
160
|
+
switch (n.type) {
|
|
161
|
+
case 'paragraph':
|
|
162
|
+
return `<p>${inline(n.children, ctx)}</p>`;
|
|
163
|
+
case 'heading':
|
|
164
|
+
case 'extended-heading': {
|
|
165
|
+
const body = inline(n.children, ctx);
|
|
166
|
+
if (!body.trim()) return '';
|
|
167
|
+
// the editor's deepest heading is h3
|
|
168
|
+
const level = Math.min(3, Math.max(1, Number(str(n.tag).slice(1)) || 2));
|
|
169
|
+
return `<h${level}>${body}</h${level}>`;
|
|
170
|
+
}
|
|
171
|
+
case 'quote':
|
|
172
|
+
case 'extended-quote':
|
|
173
|
+
// Ghost's "aside" is its second quote style (the large pull quote)
|
|
174
|
+
case 'aside':
|
|
175
|
+
return `<blockquote><p>${inline(n.children, ctx)}</p></blockquote>`;
|
|
176
|
+
case 'list':
|
|
177
|
+
return list(n, ctx);
|
|
178
|
+
case 'horizontalrule':
|
|
179
|
+
return '<hr>';
|
|
180
|
+
case 'codeblock': {
|
|
181
|
+
const lang = str(n.language).replace(/[^a-z0-9+#-]/gi, '');
|
|
182
|
+
const code = `<pre><code${lang ? ` class="language-${lang}"` : ''}>${escapeHtml(str(n.code))}</code></pre>`;
|
|
183
|
+
return code;
|
|
184
|
+
}
|
|
185
|
+
case 'image': {
|
|
186
|
+
const src = str(n.src);
|
|
187
|
+
if (!src) return '';
|
|
188
|
+
const alt = str(n.alt);
|
|
189
|
+
const url = ctx.asset(src, 'image', { alt });
|
|
190
|
+
const width = n.cardWidth === 'wide' ? ' data-width="wide"' : n.cardWidth === 'full' ? ' data-width="full"' : '';
|
|
191
|
+
return `<figure class="pc-image"${width}>${imageTag(url, alt, num(n.width), num(n.height))}${figureCaption(n.caption)}</figure>`;
|
|
192
|
+
}
|
|
193
|
+
case 'gallery': {
|
|
194
|
+
const images = (Array.isArray(n.images) ? n.images : []) as LexicalNode[];
|
|
195
|
+
const tags = images
|
|
196
|
+
.filter((img) => str(img.src))
|
|
197
|
+
.map((img) => `<img src="${escapeAttr(ctx.asset(str(img.src), 'image', { alt: str(img.alt) }))}" alt="${escapeAttr(str(img.alt))}" loading="lazy">`);
|
|
198
|
+
if (!tags.length) return '';
|
|
199
|
+
return `<figure class="pc-gallery"><div class="pc-gallery-grid" data-count="${tags.length}">${tags.join('')}</div>${figureCaption(n.caption)}</figure>`;
|
|
200
|
+
}
|
|
201
|
+
case 'embed': {
|
|
202
|
+
const url = str(n.url);
|
|
203
|
+
const card = url ? embedCard(url, n.caption) : null;
|
|
204
|
+
if (card) return card;
|
|
205
|
+
// non-video embeds (tweets, Spotify…) keep Ghost's oEmbed markup
|
|
206
|
+
const html = str(n.html);
|
|
207
|
+
if (html) return htmlCard(ctx.rawHtml(html));
|
|
208
|
+
return url ? `<p><a href="${escapeAttr(url)}">${escapeHtml(url)}</a></p>` : '';
|
|
209
|
+
}
|
|
210
|
+
case 'bookmark': {
|
|
211
|
+
const url = str(n.url);
|
|
212
|
+
if (!url) return '';
|
|
213
|
+
const meta = (n.metadata ?? {}) as Record<string, unknown>;
|
|
214
|
+
const title = str(meta.title) || url;
|
|
215
|
+
let host = '';
|
|
216
|
+
try { host = new URL(url).hostname; } catch { /* keep empty */ }
|
|
217
|
+
const favicon = str(meta.icon) ? `<img class="pc-bookmark-favicon" src="${escapeAttr(str(meta.icon))}" alt="" loading="lazy">` : '';
|
|
218
|
+
const desc = str(meta.description) ? `<div class="pc-bookmark-desc">${escapeHtml(str(meta.description))}</div>` : '';
|
|
219
|
+
const thumb = str(meta.thumbnail) ? `<div class="pc-bookmark-thumb"><img src="${escapeAttr(str(meta.thumbnail))}" alt="" loading="lazy"></div>` : '';
|
|
220
|
+
return `<a class="pc-bookmark" href="${escapeAttr(url)}" target="_blank" rel="noopener"><div class="pc-bookmark-content"><div class="pc-bookmark-title">${escapeHtml(title)}</div>${desc}<div class="pc-bookmark-meta">${favicon}<span class="pc-bookmark-site">${escapeHtml(str(meta.publisher) || host)}</span></div></div>${thumb}</a>`;
|
|
221
|
+
}
|
|
222
|
+
case 'callout': {
|
|
223
|
+
const emoji = str(n.calloutEmoji) || '💡';
|
|
224
|
+
const body = ctx.rawHtml(str(n.calloutText)).replace(/<\/?p\b[^>]*>/gi, '');
|
|
225
|
+
return `<aside class="pc-callout" data-emoji="${escapeAttr(emoji)}"><span class="pc-callout-emoji">${escapeHtml(emoji)}</span><div class="pc-callout-body">${body}</div></aside>`;
|
|
226
|
+
}
|
|
227
|
+
case 'toggle': {
|
|
228
|
+
const summary = htmlToText(str(n.heading));
|
|
229
|
+
return `<details class="pc-toggle"><summary>${escapeHtml(summary)}</summary><div class="pc-toggle-body">${asBlocks(ctx.rawHtml(str(n.content)))}</div></details>`;
|
|
230
|
+
}
|
|
231
|
+
case 'button': {
|
|
232
|
+
const align = n.alignment === 'left' ? 'left' : 'center';
|
|
233
|
+
const href = ctx.link(str(n.buttonUrl));
|
|
234
|
+
return `<div class="pc-buttoncard" data-align="${align}" style="text-align:${align}"><a class="pc-btncard" href="${escapeAttr(href)}">${escapeHtml(str(n.buttonText) || 'Button')}</a></div>`;
|
|
235
|
+
}
|
|
236
|
+
case 'file': {
|
|
237
|
+
const src = str(n.src);
|
|
238
|
+
if (!src) return '';
|
|
239
|
+
const filename = str(n.fileName) || str(n.fileTitle) || 'Download';
|
|
240
|
+
const href = ctx.asset(src, 'file', { filename });
|
|
241
|
+
const size = num(n.fileSize);
|
|
242
|
+
return `<a class="pc-filecard" href="${escapeAttr(href)}" download="${escapeAttr(filename)}" data-size="${size}"><span class="pc-file-icon" aria-hidden="true">↓</span><span class="pc-file-text"><span class="pc-file-name">${escapeHtml(filename)}</span><span class="pc-file-meta">${formatBytes(size)}</span></span></a>`;
|
|
243
|
+
}
|
|
244
|
+
case 'html': {
|
|
245
|
+
const html = ctx.rawHtml(str(n.html));
|
|
246
|
+
return plainTable(html) ?? htmlCard(html);
|
|
247
|
+
}
|
|
248
|
+
case 'markdown':
|
|
249
|
+
return htmlCard(ctx.rawHtml(ctx.markdown(str(n.markdown))));
|
|
250
|
+
case 'video':
|
|
251
|
+
case 'audio': {
|
|
252
|
+
// acms has no video/audio uploads yet: keep a player pointing at the
|
|
253
|
+
// original file so nothing disappears, and flag it for replacement
|
|
254
|
+
const src = str(n.src);
|
|
255
|
+
if (!src) return '';
|
|
256
|
+
const url = ctx.asset(src, 'media', { filename: str(n.fileName) });
|
|
257
|
+
const name = str(n.fileName) || str(n.title) || n.type;
|
|
258
|
+
ctx.warn(`${n.type}-card`, `“${name}” is a ${n.type} upload, which acms can't host yet. It still plays from the old site; replace it with an embed (for example YouTube) before that site goes away.`);
|
|
259
|
+
const tag = n.type === 'video'
|
|
260
|
+
? `<video controls preload="metadata" src="${escapeAttr(url)}" style="width:100%;height:auto"></video>`
|
|
261
|
+
: `<audio controls preload="metadata" src="${escapeAttr(url)}" style="width:100%"></audio>`;
|
|
262
|
+
return htmlCard(tag);
|
|
263
|
+
}
|
|
264
|
+
case 'header': {
|
|
265
|
+
const title = htmlToText(str(n.header) || str(n.heading));
|
|
266
|
+
const sub = htmlToText(str(n.subheader) || str(n.subheading));
|
|
267
|
+
return (title ? `<h2>${escapeHtml(title)}</h2>` : '') + (sub ? `<p>${escapeHtml(sub)}</p>` : '');
|
|
268
|
+
}
|
|
269
|
+
// members/newsletter-only cards have no public equivalent
|
|
270
|
+
case 'paywall':
|
|
271
|
+
case 'signup':
|
|
272
|
+
case 'email':
|
|
273
|
+
case 'email-cta':
|
|
274
|
+
case 'call-to-action':
|
|
275
|
+
case 'product':
|
|
276
|
+
ctx.warn('skipped-card', `A Ghost “${n.type}” card was left out: it only works with Ghost memberships or newsletters.`);
|
|
277
|
+
return '';
|
|
278
|
+
default:
|
|
279
|
+
if (Array.isArray(n.children)) return `<p>${inline(n.children, ctx)}</p>`;
|
|
280
|
+
ctx.warn('unknown-card', `An unsupported Ghost “${String(n.type)}” card was left out.`);
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function formatBytes(n: number): string {
|
|
286
|
+
if (!n) return '';
|
|
287
|
+
if (n < 1024) return `${n} B`;
|
|
288
|
+
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
289
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
290
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Import runs: one row per import, plus a record of every entity each run
|
|
2
|
+
// created or updated (source id ↔ acms id). Records make re-running an
|
|
3
|
+
// import idempotent (the same Ghost post maps back to the same acms post)
|
|
4
|
+
// and let a run be undone. `stamp` is the entity's updated_at right after
|
|
5
|
+
// the import, so undo can leave alone anything edited since.
|
|
6
|
+
export const IMPORT_MIGRATION = { version: 35, statements: [
|
|
7
|
+
`CREATE TABLE IF NOT EXISTS import_runs (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
source TEXT NOT NULL,
|
|
10
|
+
label TEXT NOT NULL DEFAULT '',
|
|
11
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
12
|
+
report TEXT NOT NULL DEFAULT '{}',
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
updated_at INTEGER NOT NULL
|
|
15
|
+
)`,
|
|
16
|
+
`CREATE TABLE IF NOT EXISTS import_records (
|
|
17
|
+
run_id TEXT NOT NULL,
|
|
18
|
+
kind TEXT NOT NULL,
|
|
19
|
+
source_id TEXT NOT NULL,
|
|
20
|
+
acms_id TEXT NOT NULL,
|
|
21
|
+
action TEXT NOT NULL,
|
|
22
|
+
stamp INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
created_at INTEGER NOT NULL,
|
|
24
|
+
PRIMARY KEY (run_id, kind, source_id)
|
|
25
|
+
)`,
|
|
26
|
+
`CREATE INDEX IF NOT EXISTS import_records_source ON import_records (kind, source_id)`,
|
|
27
|
+
] };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { crc32 } from '../backup/zip';
|
|
2
|
+
|
|
3
|
+
// Lazy ZIP reader for import sources: acms exports (stored) and archives made
|
|
4
|
+
// by Finder, Windows or `zip` (deflated). Only the central directory is read
|
|
5
|
+
// up front; entries inflate on demand through the platform's
|
|
6
|
+
// DecompressionStream('deflate-raw'), available in browsers, Workers and
|
|
7
|
+
// Node, so a large Ghost content folder never inflates all at once.
|
|
8
|
+
|
|
9
|
+
export interface ZipIndex {
|
|
10
|
+
names: string[];
|
|
11
|
+
has(name: string): boolean;
|
|
12
|
+
read(name: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openZip(buf: ArrayBuffer): ZipIndex {
|
|
16
|
+
const bytes = new Uint8Array(buf);
|
|
17
|
+
const view = new DataView(buf);
|
|
18
|
+
let eocd = -1;
|
|
19
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65536); i--) {
|
|
20
|
+
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
|
21
|
+
}
|
|
22
|
+
if (eocd < 0) throw new Error('Not a ZIP file.');
|
|
23
|
+
const count = view.getUint16(eocd + 10, true);
|
|
24
|
+
const dirSize = view.getUint32(eocd + 12, true);
|
|
25
|
+
let off = view.getUint32(eocd + 16, true);
|
|
26
|
+
if (count === 0xffff || off === 0xffffffff) throw new Error('ZIP64 archives are not supported. Split the folder into smaller ZIPs.');
|
|
27
|
+
if (off + dirSize > eocd) throw new Error('Corrupt ZIP directory.');
|
|
28
|
+
|
|
29
|
+
const entries = new Map<string, { method: number; crc: number; size: number; compSize: number; header: number }>();
|
|
30
|
+
for (let n = 0; n < count; n++) {
|
|
31
|
+
if (off + 46 > eocd || view.getUint32(off, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
|
|
32
|
+
const flags = view.getUint16(off + 8, true);
|
|
33
|
+
const method = view.getUint16(off + 10, true);
|
|
34
|
+
const nameLen = view.getUint16(off + 28, true);
|
|
35
|
+
const extraLen = view.getUint16(off + 30, true);
|
|
36
|
+
const commentLen = view.getUint16(off + 32, true);
|
|
37
|
+
const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
|
|
38
|
+
if (!(flags & 1) && !name.endsWith('/') && !name.split('/').includes('..') && !name.startsWith('/')) {
|
|
39
|
+
entries.set(name, {
|
|
40
|
+
method,
|
|
41
|
+
crc: view.getUint32(off + 16, true),
|
|
42
|
+
compSize: view.getUint32(off + 20, true),
|
|
43
|
+
size: view.getUint32(off + 24, true),
|
|
44
|
+
header: view.getUint32(off + 42, true),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
off += 46 + nameLen + extraLen + commentLen;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
names: [...entries.keys()],
|
|
52
|
+
has: (name) => entries.has(name),
|
|
53
|
+
async read(name) {
|
|
54
|
+
const e = entries.get(name);
|
|
55
|
+
if (!e) throw new Error(`${name} is not in the ZIP.`);
|
|
56
|
+
if (view.getUint32(e.header, true) !== 0x04034b50) throw new Error('Corrupt ZIP entry.');
|
|
57
|
+
const start = e.header + 30 + view.getUint16(e.header + 26, true) + view.getUint16(e.header + 28, true);
|
|
58
|
+
const raw = bytes.subarray(start, start + e.compSize);
|
|
59
|
+
let data: Uint8Array<ArrayBuffer>;
|
|
60
|
+
if (e.method === 0) data = raw;
|
|
61
|
+
else if (e.method === 8) data = await inflate(raw);
|
|
62
|
+
else throw new Error(`${name} uses an unsupported compression method.`);
|
|
63
|
+
if (data.length !== e.size || crc32(data) !== e.crc) throw new Error(`${name} is damaged (checksum mismatch).`);
|
|
64
|
+
return data;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function inflate(raw: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
|
|
70
|
+
const stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
71
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
72
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LIVE_MIGRATION } from '../live/migration';
|
|
2
|
+
import { IMPORT_MIGRATION } from '../import/migration';
|
|
2
3
|
import { EMAIL_MIGRATION } from '../email/migration';
|
|
3
4
|
import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
4
5
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
@@ -499,6 +500,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
499
500
|
] },
|
|
500
501
|
WEBHOOK_MIGRATION,
|
|
501
502
|
LIVE_MIGRATION,
|
|
503
|
+
IMPORT_MIGRATION,
|
|
502
504
|
];
|
|
503
505
|
|
|
504
506
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|