@acms-subir/core 0.3.4 → 0.3.6

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,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.6
4
+
5
+ ### Patch Changes
6
+
7
+ - Add an optional dark-mode logo in Design, exposed as `site.logoDark` to theme headers and footers and as `logoDark` in `/api/v1/site` and the typed client. Built-in branding and the reusable `SiteLogo` component select the matching image without color filters, falling back to the regular logo. Preserve the setting through design drafts, publishing, history, backups, and portable exports. Migration v42 adds the optional setting with an empty default for existing sites.
8
+
9
+ ## 0.3.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.3.4
4
16
 
5
17
  ### Patch Changes
package/cli/remote.mjs CHANGED
@@ -27,7 +27,7 @@ export async function remoteCommand(group,command,args,flags){
27
27
  if(group==='design'){
28
28
  if(command==='pull'||command==='history'){const state=await api(url,token,'/api/design');if(command==='history')return {versions:state.versions,audit:state.audit};if(flags.output){if(flags['dry-run'])return {dryRun:true,output:resolve(flags.output),revision:state.revision};await atomicJson(resolve(flags.output),{revision:state.revision,patch:state.draft??state.live});return {output:resolve(flags.output),revision:state.revision};}return state;}
29
29
  let body;
30
- if(command==='push'){if(!flags.draft)fail('USAGE','Use design push --draft; publishing is separate.');const input=await inputJson(flags.file);const source=input.patch??input;const unknown=Object.keys(source).filter(k=>!['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems','savedAt','shareEpoch','revision'].includes(k));if(unknown.length)fail('USAGE','Unknown draft fields.',unknown);const patch=Object.fromEntries(Object.entries(source).filter(([k])=>['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems'].includes(k)));body={operation:'stage',revision:Number(flags.revision??input.revision),patch};}
30
+ if(command==='push'){if(!flags.draft)fail('USAGE','Use design push --draft; publishing is separate.');const input=await inputJson(flags.file);const source=input.patch??input;const unknown=Object.keys(source).filter(k=>!['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','navItems','savedAt','shareEpoch','revision'].includes(k));if(unknown.length)fail('USAGE','Unknown draft fields.',unknown);const patch=Object.fromEntries(Object.entries(source).filter(([k])=>['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','navItems'].includes(k)));body={operation:'stage',revision:Number(flags.revision??input.revision),patch};}
31
31
  else if(['publish','discard','share','revoke','rollback'].includes(command))body={operation:command==='rollback'?'restore':command,revision:Number(flags.revision),...(command==='rollback'?{id:args[0]}:{}),...(command==='share'?{days:Number(flags.days??7)}:{})};
32
32
  else fail('USAGE','Unknown design command.');
33
33
  if(!Number.isInteger(body.revision)||body.revision<0)fail('USAGE','Supply the revision from design pull.');
@@ -36,15 +36,18 @@ Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageP
36
36
 
37
37
  Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
38
38
 
39
- `header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
39
+ `header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `logoDark`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
40
+
41
+ Owners upload **Logo (dark mode)** beside Logo in Design → Brand and publish it with the design. `site.logoDark` is an empty string when unset; use the regular logo as the fallback. The built-in header/footer and the `SiteLogo` component below switch without image filters, following the site’s forced scheme, visitor toggle, or OS preference. Custom markup can use `html[data-scheme="dark"]` / `html[data-scheme="light"]`, falling back to `prefers-color-scheme` when the attribute is absent. `/api/v1/site` also returns `logoDark` as an absolute URL (or `""` when unset).
40
42
 
41
43
  ```astro
42
44
  ---
43
45
  import type { ChromeProps } from '@acms-subir/core/theme';
46
+ import SiteLogo from '@acms-subir/core/theme/components/SiteLogo.astro';
44
47
  const { site } = Astro.props as ChromeProps;
45
48
  ---
46
49
  <header class="gh-head">
47
- <a href={site.homeUrl}>{site.logo ? <img src={site.logo} alt={site.title} /> : site.title}</a>
50
+ <a class="brand" href={site.homeUrl}><SiteLogo title={site.title} logo={site.logo} logoDark={site.logoDark} /></a>
48
51
  <nav>{site.links.map((l) => <a href={l.url} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}</nav>
49
52
  {site.languages.map((l) => <a href={l.url} lang={l.code} aria-current={l.current ? 'true' : undefined}>{l.label}</a>)}
50
53
  {site.search && <a href={site.search.url} data-search-open>{site.search.label}</a>}
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.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -394,6 +394,7 @@ export const server = {
394
394
  activeTheme: z.string().optional(),
395
395
  designOverrides: z.record(z.any()).optional(),
396
396
  siteLogo: z.string().optional(),
397
+ siteLogoDark: z.string().optional(),
397
398
  siteFavicon: z.string().optional(),
398
399
  siteCover: z.string().optional(),
399
400
  // IANA zone ('' = UTC) and BCP 47 locale ('' = en-US), same rules as Settings → General
@@ -416,6 +417,7 @@ export const server = {
416
417
  activeTheme: z.string().optional(),
417
418
  designOverrides: z.record(z.any()).optional(),
418
419
  siteLogo: z.string().optional(),
420
+ siteLogoDark: z.string().optional(),
419
421
  siteFavicon: z.string().optional(),
420
422
  siteCover: z.string().optional(),
421
423
  }),
@@ -137,11 +137,13 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
137
137
  (settings.designOverrides as Record<string, unknown>) ?? {}
138
138
  );
139
139
  const [siteLogo, setSiteLogo] = useState(settings.siteLogo);
140
+ const [siteLogoDark, setSiteLogoDark] = useState(settings.siteLogoDark);
140
141
  const [siteFavicon, setSiteFavicon] = useState(settings.siteFavicon);
141
142
  const [siteCover, setSiteCover] = useState(settings.siteCover);
142
143
  const [saveState, setSaveState] = useState<SaveState>('saved');
143
144
  const [saveMessage, setSaveMessage] = useState('');
144
145
  const [logoPickerOpen, setLogoPickerOpen] = useState(false);
146
+ const [logoDarkPickerOpen, setLogoDarkPickerOpen] = useState(false);
145
147
  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
146
148
  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
147
149
  const [copied, setCopied] = useState(false);
@@ -242,8 +244,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
242
244
  return () => ro.disconnect();
243
245
  }, []);
244
246
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
245
- const latest = useRef({ activeTheme, overrides, siteLogo, siteFavicon, siteCover });
246
- latest.current = { activeTheme, overrides, siteLogo, siteFavicon, siteCover };
247
+ const latest = useRef({ activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover });
248
+ latest.current = { activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover };
247
249
 
248
250
  const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
249
251
  const latestChanges = useRef(changes);
@@ -251,11 +253,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
251
253
 
252
254
  // saves stage into settings.draftDesign — the live site is untouched
253
255
  const doSave = async () => {
254
- const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
256
+ const { activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover } = latest.current;
255
257
  const { data, error } = await actions.settings.saveDesignDraft({
256
258
  activeTheme,
257
259
  designOverrides: overrides,
258
260
  siteLogo,
261
+ siteLogoDark,
259
262
  siteFavicon,
260
263
  siteCover,
261
264
  });
@@ -506,6 +509,24 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
506
509
  </div>
507
510
  </div>
508
511
 
512
+ <div className="dm-row">
513
+ <div className="dm-lab">
514
+ <span>Logo (dark mode)</span>
515
+ <small>Optional. Uses the regular logo when empty.</small>
516
+ </div>
517
+ <div className="dm-ctl">
518
+ {siteLogoDark && <img className="dm-logo" src={siteLogoDark} alt="Logo (dark mode)" />}
519
+ <button className="btn sm" type="button" onClick={() => setLogoDarkPickerOpen(true)}>
520
+ {siteLogoDark ? 'Replace…' : 'Upload…'}
521
+ </button>
522
+ {siteLogoDark && (
523
+ <button className="btn sm ghost" type="button" onClick={() => { setSiteLogoDark(''); scheduleSave(); }}>
524
+ Remove
525
+ </button>
526
+ )}
527
+ </div>
528
+ </div>
529
+
509
530
  <div className="dm-row">
510
531
  <div className="dm-lab">
511
532
  <span>Favicon</span>
@@ -1021,6 +1042,16 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
1021
1042
  scheduleSave();
1022
1043
  }}
1023
1044
  />
1045
+ <ImagePicker
1046
+ open={logoDarkPickerOpen}
1047
+ unsplashEnabled={unsplashEnabled}
1048
+ onClose={() => setLogoDarkPickerOpen(false)}
1049
+ onSelect={(img) => {
1050
+ setSiteLogoDark(img.src);
1051
+ setLogoDarkPickerOpen(false);
1052
+ scheduleSave();
1053
+ }}
1054
+ />
1024
1055
  <ImagePicker
1025
1056
  open={faviconPickerOpen}
1026
1057
  unsplashEnabled={unsplashEnabled}
@@ -23,7 +23,7 @@ interface Report {
23
23
  const SOURCE_NAMES: Record<string, string> = { ghost: 'Ghost', acms: 'acms', wordpress: 'WordPress', markdown: 'Markdown' };
24
24
  const DEFAULTS: ImportOptionsState = { conflict: 'skip', identity: true, nav: true, redirects: true, headCode: false, authors: {} };
25
25
  const SETTING_NAMES: Record<string, string> = {
26
- siteTitle: 'title', tagline: 'description', siteLogo: 'logo', siteFavicon: 'icon', siteCover: 'cover image',
26
+ siteTitle: 'title', tagline: 'description', siteLogo: 'logo', siteLogoDark: 'dark-mode logo', siteFavicon: 'icon', siteCover: 'cover image',
27
27
  timezone: 'timezone', locale: 'language', headCode: 'head code',
28
28
  };
29
29
  const settingNames = (keys: string[]) => keys.map((k) => SETTING_NAMES[k] ?? k).join(', ');
@@ -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,
@@ -0,0 +1,23 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ logo: string;
5
+ logoDark?: string;
6
+ }
7
+ const { title, logo, logoDark = '' } = Astro.props;
8
+ ---
9
+
10
+ {logo
11
+ ? <img class:list={['brand-logo', { 'logo-light': !!logoDark }]} src={logo} alt={title} />
12
+ : <span class:list={['brand-name', { 'logo-light': !!logoDark }]}>{title}</span>}
13
+ {logoDark && <img class="brand-logo logo-dark" src={logoDark} alt={title} />}
14
+
15
+ <style>
16
+ .logo-dark { display: none; }
17
+ :global(:root[data-scheme='dark']) .logo-light { display: none; }
18
+ :global(:root[data-scheme='dark']) .logo-dark { display: block; }
19
+ @media (prefers-color-scheme: dark) {
20
+ :global(:root:not([data-scheme='light'])) .logo-light { display: none; }
21
+ :global(:root:not([data-scheme='light'])) .logo-dark { display: block; }
22
+ }
23
+ </style>
@@ -15,6 +15,7 @@ import { getTheme } from '../lib/theme';
15
15
  import { analyticsHead, analyticsBody } from '../lib/analytics';
16
16
  import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
17
17
  import type { SiteChrome } from '../lib/site-chrome';
18
+ import SiteLogo from '../components/site/SiteLogo.astro';
18
19
  import '../styles/site.css';
19
20
  import '../styles/cards.css';
20
21
  import '../styles/live.css';
@@ -144,7 +145,7 @@ const langName = (l: string) => {
144
145
  };
145
146
  const chromeLink = (n: { label: string; url: string }) => ({ label: n.label, url: lnk(n.url), current: isCurrent(lnk(n.url)), external: isExternal(n.url) });
146
147
  const chrome: SiteChrome = {
147
- title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo,
148
+ title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo, logoDark: settings.siteLogoDark,
148
149
  homeUrl: reqLocale ? localeHomeUrl(reqLocale) : '/',
149
150
  links: headerLinks.map(chromeLink),
150
151
  footerLinks: footerNav.map(chromeLink),
@@ -186,7 +187,7 @@ const homeJsonLd =
186
187
  ---
187
188
 
188
189
  <!doctype html>
189
- <html lang={htmlLang}>
190
+ <html lang={htmlLang} data-scheme={tokens.scheme === 'auto' ? undefined : tokens.scheme}>
190
191
  <head>
191
192
  <meta charset="utf-8" />
192
193
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -276,7 +277,7 @@ const homeJsonLd =
276
277
  <header class="site-header" data-style={tokens.header}>
277
278
  <div class="site-header-in">
278
279
  <a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
279
- {settings.siteLogo ? <img class="brand-logo" src={settings.siteLogo} alt={settings.siteTitle} /> : <span class="brand-name">{settings.siteTitle}</span>}
280
+ <SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} />
280
281
  </a>
281
282
  <nav class="site-nav" aria-label="Main">
282
283
  {headerLinks.map((n) => (
@@ -677,7 +678,7 @@ const homeJsonLd =
677
678
  {tokens.footer === 'columns' ? (
678
679
  <div class="footer-cols">
679
680
  <div class="footer-brand">
680
- <a class="brand" href="/">{settings.siteLogo ? <img class="brand-logo" src={settings.siteLogo} alt={settings.siteTitle} /> : <span class="brand-name">{settings.siteTitle}</span>}</a>
681
+ <a class="brand" href="/"><SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} /></a>
681
682
  {settings.tagline && <p class="footer-blurb">{settings.tagline}</p>}
682
683
  {socialNav.length > 0 && (
683
684
  <nav class="social-nav" aria-label="Social">
@@ -15,7 +15,7 @@ import { ThemeTokens } from '../theme-sdk/contract.mjs';
15
15
  export const DESIGN_SCOPES = ['design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write'] as const;
16
16
  export const DesignPatch = z.object({
17
17
  activeTheme: z.string().max(50).optional(), designOverrides: z.record(z.union([z.string().max(2000), z.number().finite()])).optional(),
18
- siteLogo: z.string().max(2000).optional(), siteFavicon: z.string().max(2000).optional(), siteCover: z.string().max(2000).optional(),
18
+ siteLogo: z.string().max(2000).optional(), siteLogoDark: z.string().max(2000).optional(), siteFavicon: z.string().max(2000).optional(), siteCover: z.string().max(2000).optional(),
19
19
  sectionPages: SectionPages.optional(),
20
20
  navItems: z.array(z.object({ id:z.string().max(100),label:z.string().max(200),url:z.string().max(2000),area:z.enum(['header','footer','social']) }).strict()).max(200).optional(),
21
21
  }).strict();
@@ -93,7 +93,7 @@ export function validateDesignValues(patch: z.infer<typeof DesignPatch>) {
93
93
  if(key==='homeMode'&&!['auto','content',''].includes(String(value)))throw new ApiError(400,'Invalid home mode.');
94
94
  if (typeof value === 'string' && /[;{}<>]|url\s*\(|@import/i.test(value)) throw new ApiError(400,'Design overrides accept token values, not CSS or HTML.');
95
95
  }
96
- for (const value of [patch.siteLogo,patch.siteFavicon,patch.siteCover,...(patch.navItems??[]).map(n=>n.url)]) {
96
+ for (const value of [patch.siteLogo,patch.siteLogoDark,patch.siteFavicon,patch.siteCover,...(patch.navItems??[]).map(n=>n.url)]) {
97
97
  if(value && !/^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(value.trim())) throw new ApiError(400,'Use an HTTP(S) or site-relative URL.');
98
98
  }
99
99
  }
@@ -39,7 +39,7 @@ const VALUE_LABELS: Record<string, string> = {
39
39
  content: 'Content page',
40
40
  };
41
41
 
42
- const IMAGE_LABELS: Record<string, string> = { siteLogo: 'Logo', siteFavicon: 'Favicon', siteCover: 'Share image' };
42
+ const IMAGE_LABELS: Record<string, string> = { siteLogo: 'Logo', siteLogoDark: 'Logo (dark mode)', siteFavicon: 'Favicon', siteCover: 'Share image' };
43
43
  const AREA_LABELS: Record<NavItem['area'], string> = { header: 'Header menu', footer: 'Footer menu', social: 'Social links' };
44
44
 
45
45
  const show = (v: unknown, key?: string): string => {
@@ -97,7 +97,7 @@ export function diffDesign(
97
97
  }
98
98
  }
99
99
 
100
- for (const k of ['siteLogo', 'siteFavicon', 'siteCover'] as const) {
100
+ for (const k of ['siteLogo', 'siteLogoDark', 'siteFavicon', 'siteCover'] as const) {
101
101
  if (staged[k] !== undefined && staged[k] !== live[k]) {
102
102
  out.push({ key: k, label: IMAGE_LABELS[k], kind: 'image', from: live[k] || '', to: staged[k] || '' });
103
103
  }
package/src/lib/drafts.ts CHANGED
@@ -79,7 +79,7 @@ export function draftSavedAt(doc: Pick<Post | Page | CalEvent, 'draft'>): number
79
79
  // ordered navigation list (navItems) — menus stage with the design so one
80
80
  // Publish changes the whole look at once — and the share-link epoch.
81
81
  export const DESIGN_DRAFT_KEYS = [
82
- 'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover', 'sectionPages',
82
+ 'activeTheme', 'designOverrides', 'siteLogo', 'siteLogoDark', 'siteFavicon', 'siteCover', 'sectionPages',
83
83
  ] as const;
84
84
 
85
85
  type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
@@ -124,6 +124,7 @@ export function designSnapshot(settings: Settings, navItems: NavItem[]): Record<
124
124
  designOverrides: settings.designOverrides,
125
125
  sectionPages: settings.sectionPages,
126
126
  siteLogo: settings.siteLogo,
127
+ siteLogoDark: settings.siteLogoDark,
127
128
  siteFavicon: settings.siteFavicon,
128
129
  siteCover: settings.siteCover,
129
130
  navItems: navItems.map(({ id, label, url, area }) => ({ id, label, url, area })),
@@ -100,6 +100,7 @@ export const ImportIdentity = z.object({
100
100
  siteTitle: z.string().max(200).optional(),
101
101
  tagline: z.string().max(1000).optional(),
102
102
  siteLogo: z.string().max(2000).optional(),
103
+ siteLogoDark: z.string().max(2000).optional(),
103
104
  siteFavicon: z.string().max(2000).optional(),
104
105
  siteCover: z.string().max(2000).optional(),
105
106
  timezone: z.string().max(100).optional(),
@@ -146,7 +147,8 @@ export const assetPlaceholder = (ref: string) => `${ASSET_SCHEME}${ref}`;
146
147
  // Swap every acms-asset:<ref> for its re-hosted URL. <img> tags built by the
147
148
  // importers carry only src/alt/width/height, so images that gained responsive
148
149
  // variants get srcset/sizes added here. Unresolved refs (a failed download)
149
- // fall back to the original URL, which is reported separately.
150
+ // fall back to an absolute source URL, or an empty string if none is known.
151
+ // Copy failures are reported separately by the runner.
150
152
  export interface ResolvedAsset { url: string; srcset?: string; sizes?: string }
151
153
  const PLACEHOLDER_RE = /acms-asset:([a-z0-9-]{1,40})/g;
152
154
  export function resolveAssets(value: string, resolved: Map<string, ResolvedAsset>, fallback: Map<string, string>): string {
@@ -19,7 +19,7 @@ export async function exportBundle(repo: ContentRepository, now = Date.now()): P
19
19
  version: BUNDLE_VERSION,
20
20
  source: { kind: 'acms', version: '', title: settings.siteTitle, siteUrl: settings.siteUrl, exportedAt: new Date(now).toISOString() },
21
21
  identity: {
22
- siteTitle: settings.siteTitle, tagline: settings.tagline, siteLogo: settings.siteLogo, siteFavicon: settings.siteFavicon,
22
+ siteTitle: settings.siteTitle, tagline: settings.tagline, siteLogo: settings.siteLogo, siteLogoDark: settings.siteLogoDark, siteFavicon: settings.siteFavicon,
23
23
  siteCover: settings.siteCover, timezone: settings.timezone, locale: settings.locale, headCode: settings.headCode,
24
24
  },
25
25
  // only people with bylines travel; staff accounts stay on this site
@@ -6,7 +6,7 @@ const date = str.datetime(), locale = str.nullable();
6
6
  export const Pagination = z.object({ page: z.number().int(), limit: z.number().int(), nextPage: z.number().int().nullable() });
7
7
  const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str });
8
8
  export const Site = z.object({
9
- name: str, description: str, logo: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
9
+ name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
11
  features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool }), apiVersion: z.literal(1),
12
12
  });
@@ -23,7 +23,7 @@ export async function getSite(repo: ContentRepository, url: URL) {
23
23
  ];
24
24
  return {
25
25
  name: settings.siteTitle, description: settings.tagline,
26
- logo: safeUrl(settings.siteLogo, origin), icon: safeUrl(settings.siteFavicon, origin),
26
+ logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
27
27
  menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
28
28
  languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
29
29
  features: { blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled },
@@ -515,6 +515,9 @@ export const MIGRATIONS: Migration[] = [
515
515
  EDITOR_GRACE_MIGRATION,
516
516
  CALENDAR_SETTINGS_MIGRATION,
517
517
  ROOM_HOLD_MIGRATION,
518
+ { version: 42, statements: [
519
+ `ALTER TABLE settings ADD COLUMN site_logo_dark TEXT NOT NULL DEFAULT ''`,
520
+ ] },
518
521
  ];
519
522
 
520
523
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1839,6 +1839,7 @@ export class SqlRepository implements ContentRepository {
1839
1839
  activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
1840
1840
  sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
1841
1841
  siteLogo: r?.site_logo ?? '',
1842
+ siteLogoDark: r?.site_logo_dark ?? '',
1842
1843
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
1843
1844
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
1844
1845
  analytics: JSON.parse(String(r?.analytics ?? '{}')),
@@ -1862,14 +1863,14 @@ export class SqlRepository implements ContentRepository {
1862
1863
  private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1863
1864
  const columns: Record<string, string> = {
1864
1865
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1865
- activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1866
+ activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1866
1867
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1867
1868
  draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
1868
1869
  };
1869
1870
  const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
1870
1871
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1871
1872
  if (!entries.length) return;
1872
- const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
1873
+ const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
1873
1874
  return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1874
1875
  args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
1875
1876
  }
@@ -1919,8 +1920,8 @@ export class SqlRepository implements ContentRepository {
1919
1920
  const statements: SqlStatement[] = [
1920
1921
  { sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
1921
1922
  { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1922
- { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
1923
- args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteFavicon, design.siteCover] },
1923
+ { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_logo_dark=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
1924
+ args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteLogoDark, design.siteFavicon, design.siteCover] },
1924
1925
  ];
1925
1926
  if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
1926
1927
  sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
@@ -292,7 +292,7 @@ export interface ContentRepository {
292
292
  updateSettings(data: Partial<Settings>): Promise<void>;
293
293
  // Design-screen staging: save stages into settings.draftDesign, apply
294
294
  // copies staged → live design fields and clears, discard just clears
295
- saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
295
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
296
296
  navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
297
297
  shareEpoch?: string;
298
298
  }): Promise<void>;
package/src/lib/schema.ts CHANGED
@@ -650,6 +650,7 @@ export const Settings = z.object({
650
650
  sectionPages: SectionPages.default({}),
651
651
  designRevision: z.number().int().nonnegative().default(0),
652
652
  siteLogo: z.string().default(''),
653
+ siteLogoDark: z.string().default(''),
653
654
  // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
654
655
  // tags — empty falls back to the request origin), favicon, and the default
655
656
  // social-share image used when a post has no cover
@@ -19,6 +19,7 @@ export interface SiteChrome {
19
19
  title: string;
20
20
  tagline: string;
21
21
  logo: string; // '' when the site has no logo
22
+ logoDark: string; // '' when unset; themes can fall back to logo
22
23
  homeUrl: string;
23
24
  links: ChromeLink[]; // header navigation
24
25
  footerLinks: ChromeLink[];