@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 +12 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/package.json +1 -1
- package/src/actions/index.ts +2 -0
- package/src/components/DesignManager.tsx +34 -3
- package/src/components/import/ImportExport.tsx +5 -5
- package/src/components/import/runner.ts +20 -3
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Site.astro +5 -4
- package/src/lib/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +2 -1
- package/src/lib/import/bundle.ts +3 -1
- package/src/lib/import/export.ts +1 -1
- package/src/lib/mobile/schemas.ts +1 -1
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +5 -4
- package/src/lib/repo/types.ts +1 -1
- package/src/lib/schema.ts +1 -0
- package/src/lib/site-chrome.ts +1 -0
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.');
|
package/docs/theme-authoring.md
CHANGED
|
@@ -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}
|
|
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
package/src/actions/index.ts
CHANGED
|
@@ -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 &&
|
|
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">
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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>
|
package/src/layouts/Site.astro
CHANGED
|
@@ -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.
|
|
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="/"
|
|
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
|
}
|
package/src/lib/designDiff.ts
CHANGED
|
@@ -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 })),
|
package/src/lib/import/bundle.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/lib/import/export.ts
CHANGED
|
@@ -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
|
});
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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],
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|
package/src/lib/site-chrome.ts
CHANGED
|
@@ -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[];
|