@acms-subir/core 0.3.4 → 0.3.5

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,11 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Require an old-site address or content ZIP for Ghost exports with relative asset references. Failed copies retain only absolute source URLs; unavailable sources leave empty references and named failures in the import report instead of broken URLs on the new site. Correct the import warnings to describe these outcomes.
8
+
3
9
  ## 0.3.4
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -98,7 +98,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
98
98
  const key = planKeyFor(options);
99
99
  const stale = !!plan && planKey !== key;
100
100
  const assetFiles = bundle ? bundle.assets.filter((a) => a.kind !== 'media').length : 0;
101
- const needsFiles = isGhost && assetFiles > 0 && !siteUrl.trim() && !contentZip && !source?.zip;
101
+ const needsFiles = isGhost && !!bundle?.assets.some((a) => !/^https?:\/\//i.test(a.url)) && !siteUrl.trim() && !contentZip && !source?.zip;
102
102
  const resumable = source ? readJournal(source.hash) : null;
103
103
  const toImport = plan ? plan.counts.posts.create + plan.counts.posts.update + plan.counts.pages.create + plan.counts.pages.update : 0;
104
104
 
@@ -198,7 +198,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
198
198
  <span className="btn sm">{contentZip ? 'Change ZIP' : 'Add content folder ZIP'}</span>
199
199
  <span className="hint">{contentZip ? `${contentZip.name} · ${plural(contentZip.zip.names.length, 'file')}` : 'Optional. Used first; the address is the fallback.'}</span>
200
200
  </label>
201
- {needsFiles && <p className="imp-mark warn">Without an address or ZIP, images will point at the old site and break when it goes away.</p>}
201
+ {needsFiles && <p className="imp-mark warn">Enter the old site’s address or add its content folder ZIP before importing. Missing files without a source URL are left empty and listed in the report.</p>}
202
202
  </div>
203
203
  )}
204
204
 
@@ -252,7 +252,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
252
252
  <div className="imp-actions">
253
253
  <button type="button" className="btn" disabled={!!busy} onClick={check}>{busy === 'plan' ? 'Checking…' : plan ? 'Check again' : 'Check import'}</button>
254
254
  {plan && (
255
- <button type="button" className="btn primary" disabled={!!busy || stale || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
255
+ <button type="button" className="btn primary" disabled={!!busy || stale || needsFiles || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
256
256
  {busy === 'run' ? 'Importing…' : resumable?.runId ? `Resume import` : `Import ${plural(toImport, 'item')}`}
257
257
  </button>
258
258
  )}
@@ -341,7 +341,7 @@ function Done({ report, reset, openHistory }: { report: Report; reset: () => voi
341
341
  )}
342
342
  {!!report.failedAssets?.length && (
343
343
  <>
344
- <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied and still point at the old site</p>
344
+ <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied. Source URLs are kept when available; otherwise the references are left empty.</p>
345
345
  <ul className="imp-list">{report.failedAssets.map((f, i) => <li key={i}>{f.url}<small>{f.error}</small></li>)}</ul>
346
346
  </>
347
347
  )}
@@ -101,8 +101,14 @@ export function findInZip(zip: ZipIndex, path: string): string | null {
101
101
  }
102
102
 
103
103
  export function sourceAssetUrl(bundle: ImportBundle, asset: ImportAsset): string {
104
- if (/^https?:\/\//.test(asset.url)) return asset.url;
105
- return bundle.source.siteUrl ? `${bundle.source.siteUrl.replace(/\/+$/, '')}/${asset.url}` : `/${asset.url}`;
104
+ // A ZIP path is not a URL on the destination site. Only an absolute
105
+ // source URL is safe to retain when copying fails.
106
+ try {
107
+ const url = /^[a-z][a-z\d+.-]*:/i.test(asset.url)
108
+ ? new URL(asset.url)
109
+ : new URL(asset.url.replace(/^\/+/, ''), `${bundle.source.siteUrl.replace(/\/+$/, '')}/`);
110
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
111
+ } catch { return ''; }
106
112
  }
107
113
 
108
114
  async function fetchAsset(bundle: ImportBundle, asset: ImportAsset, contentZip: ZipIndex | null): Promise<File> {
@@ -170,6 +176,10 @@ export async function runImport(
170
176
  const journal: Journal = readJournal(hash) ?? { runId: '', assets: {}, failed: {}, done: [], authorIds: {} };
171
177
  const save = () => writeJournal(hash, journal);
172
178
 
179
+ if (source.kind === 'ghost' && !files && bundle.assets.some((a) => !journal.assets[a.ref] && !sourceAssetUrl(bundle, a))) {
180
+ throw new Error('This Ghost export references images or files. Enter the old site’s address or add its content folder ZIP before importing.');
181
+ }
182
+
173
183
  // 1. files → media library
174
184
  const assets = bundle.assets.filter((a) => a.kind !== 'media');
175
185
  const total = assets.length + bundle.media.length;
@@ -214,6 +224,13 @@ export async function runImport(
214
224
 
215
225
  const resolved = new Map(Object.entries(journal.assets).map(([ref, r]) => [ref, r]));
216
226
  const fallback = new Map(bundle.assets.map((a) => [a.ref, sourceAssetUrl(bundle, a)]));
227
+ // Video/audio are not copied yet, but missing source URLs still need to
228
+ // be reported instead of quietly turning into destination-relative URLs.
229
+ for (const a of bundle.assets.filter((a) => a.kind === 'media')) {
230
+ if (!resolved.has(a.ref) && !fallback.get(a.ref)) {
231
+ journal.failed[a.ref] = 'Video/audio cannot be copied yet. Enter the old site’s address to keep its source URL.';
232
+ } else delete journal.failed[a.ref];
233
+ }
217
234
  const fix = (v: string) => resolveAssets(v, resolved, fallback);
218
235
 
219
236
  // 2. run + authors + tags; only people with a byline become authors
@@ -261,7 +278,7 @@ export async function runImport(
261
278
  const identity = Object.fromEntries(Object.entries(bundle.identity).map(([k, v]) => [k, typeof v === 'string' ? fix(v) : v]));
262
279
  const failedAssets = Object.entries(journal.failed).map(([ref, error]) => {
263
280
  const a = bundle.assets.find((x) => x.ref === ref);
264
- return { url: a ? sourceAssetUrl(bundle, a) : ref.replace(/^m-/, 'media '), error };
281
+ return { url: a ? sourceAssetUrl(bundle, a) || a.url : ref.replace(/^m-/, 'media '), error };
265
282
  });
266
283
  const { report } = await api<{ report: Record<string, unknown> }>({
267
284
  op: 'finish', runId: journal.runId, options, identity,
@@ -146,7 +146,8 @@ export const assetPlaceholder = (ref: string) => `${ASSET_SCHEME}${ref}`;
146
146
  // Swap every acms-asset:<ref> for its re-hosted URL. <img> tags built by the
147
147
  // importers carry only src/alt/width/height, so images that gained responsive
148
148
  // variants get srcset/sizes added here. Unresolved refs (a failed download)
149
- // fall back to the original URL, which is reported separately.
149
+ // fall back to an absolute source URL, or an empty string if none is known.
150
+ // Copy failures are reported separately by the runner.
150
151
  export interface ResolvedAsset { url: string; srcset?: string; sizes?: string }
151
152
  const PLACEHOLDER_RE = /acms-asset:([a-z0-9-]{1,40})/g;
152
153
  export function resolveAssets(value: string, resolved: Map<string, ResolvedAsset>, fallback: Map<string, string>): string {