@glossarist/concept-browser 0.7.106 → 0.7.108
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/README.md +146 -1
- package/cli/index.mjs +133 -36
- package/cli/index.ts +137 -37
- package/package.json +1 -1
- package/src/__tests__/cli/favicon-config.test.ts +61 -0
- package/src/__tests__/utils/favicon-base.test.ts +89 -0
- package/src/__tests__/utils/favicon-links.test.ts +124 -0
- package/src/__tests__/utils/font-stack.test.ts +105 -0
- package/src/components/ConceptDetail.vue +1 -1
- package/src/config/types.ts +22 -0
- package/src/config/use-site-config.ts +22 -6
- package/src/layouts/Default.astro +46 -35
- package/src/styles/tailwind.css +5 -0
- package/src/utils/favicon-base.ts +45 -0
- package/src/utils/favicon-links.ts +79 -0
- package/src/utils/font-stack.ts +77 -0
- package/src/views/DatasetView.vue +1 -1
- package/src/views/GroupView.vue +1 -1
- package/src/views/HomeView.vue +1 -1
- package/tailwind.config.js +3 -1
package/README.md
CHANGED
|
@@ -109,7 +109,152 @@ branding:
|
|
|
109
109
|
|
|
110
110
|
If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
### Fonts
|
|
113
|
+
|
|
114
|
+
Brand typography is fully slot-based and category-agnostic. There are **four slots** — `title`, `heading`, `body`, `mono` — and each slot accepts **any category** (`serif`, `sans-serif`, `monospace`). Nothing dictates that "headings must be serif" or "body must be sans-serif". Pick whatever combination matches your brand.
|
|
115
|
+
|
|
116
|
+
#### Slots
|
|
117
|
+
|
|
118
|
+
| Slot | Applies to | Default family | Default category |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| `title` | The single most-prominent text on each page (home hero, concept name on detail page, dataset/group title) | DM Serif Display | serif |
|
|
121
|
+
| `heading` | h2–h6 section headings | DM Serif Display | serif |
|
|
122
|
+
| `body` | Paragraph text, lists, table cells | DM Sans | sans-serif |
|
|
123
|
+
| `mono` | Code blocks, inline code, kbd | JetBrains Mono | monospace |
|
|
124
|
+
|
|
125
|
+
The defaults preserve the Glossarist visual identity — override any of them to match your brand.
|
|
126
|
+
|
|
127
|
+
#### Per-slot category override
|
|
128
|
+
|
|
129
|
+
Set `category` on any slot to control the fallback chain (the browser uses it when the primary family fails to load or is still loading):
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
branding:
|
|
133
|
+
fonts:
|
|
134
|
+
title:
|
|
135
|
+
family: Inter
|
|
136
|
+
source: google
|
|
137
|
+
category: sans-serif # sans-serif title (was serif by default)
|
|
138
|
+
weights: [400, 600, 700]
|
|
139
|
+
heading:
|
|
140
|
+
family: Inter
|
|
141
|
+
source: google
|
|
142
|
+
category: sans-serif
|
|
143
|
+
weights: [600, 700]
|
|
144
|
+
body:
|
|
145
|
+
family: Merriweather
|
|
146
|
+
source: google
|
|
147
|
+
category: serif # serif body (was sans-serif by default)
|
|
148
|
+
weights: [400, 700]
|
|
149
|
+
mono:
|
|
150
|
+
family: Fira Code
|
|
151
|
+
source: google
|
|
152
|
+
category: monospace
|
|
153
|
+
weights: [400, 500]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
This produces:
|
|
157
|
+
|
|
158
|
+
| Slot | CSS variable | Stack |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| title | `--font-title` | `'Inter', system-ui, sans-serif` |
|
|
161
|
+
| heading | `--font-heading` (and `--font-header` for backward compat) | `'Inter', system-ui, sans-serif` |
|
|
162
|
+
| body | `--font-body` | `'Merriweather', Georgia, serif` |
|
|
163
|
+
| mono | `--font-mono` | `'Fira Code', ui-monospace, "JetBrains Mono", Menlo, Monaco, monospace` |
|
|
164
|
+
|
|
165
|
+
#### Source options
|
|
166
|
+
|
|
167
|
+
| `source` | Behavior |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `google` | Build emits a Google Fonts CSS `@import` for the declared `family` + `weights`. |
|
|
170
|
+
| `url` | Build emits a `@font-face` block loading from `url`. |
|
|
171
|
+
| `local` | Consumer ships the font files in `public/`; no build-time fetch. |
|
|
172
|
+
|
|
173
|
+
#### Backward compatibility
|
|
174
|
+
|
|
175
|
+
The legacy `branding.fonts.header` slot still works — it's a deprecated alias for `branding.fonts.heading`. Existing configs don't break. The CSS variable `--font-header` is still emitted (as an alias for `--font-heading`) so existing stylesheets that reference it continue to work.
|
|
176
|
+
|
|
177
|
+
#### Mixed-category example: all sans-serif
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
branding:
|
|
181
|
+
fonts:
|
|
182
|
+
title: { family: Inter, source: google, category: sans-serif }
|
|
183
|
+
heading: { family: Inter, source: google, category: sans-serif }
|
|
184
|
+
body: { family: Inter, source: google, category: sans-serif }
|
|
185
|
+
mono: { family: JetBrains Mono, source: google, category: monospace }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### Mixed-category example: traditional serif
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
branding:
|
|
192
|
+
fonts:
|
|
193
|
+
title: { family: Playfair Display, source: google, category: serif }
|
|
194
|
+
heading: { family: Lora, source: google, category: serif }
|
|
195
|
+
body: { family: Source Sans Pro, source: google, category: sans-serif }
|
|
196
|
+
mono: { family: Source Code Pro, source: google, category: monospace }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Favicons
|
|
200
|
+
|
|
201
|
+
`branding.favicon` accepts two shapes — a legacy **string** form and an **object** form that lets consumers provide their own canonical favicon set without writing a post-build script.
|
|
202
|
+
|
|
203
|
+
#### String form (legacy, still supported)
|
|
204
|
+
|
|
205
|
+
Path to a single source SVG/PNG. The CLI generates the full variant set (favicon.ico, apple-touch-icon-*.png, etc.) from it using the `favicons` package:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
branding:
|
|
209
|
+
favicon: assets/my-brand.svg
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### Object form (recommended for branded deployments)
|
|
213
|
+
|
|
214
|
+
The object form lets you provide your own canonical favicon set (typically RealFaviconGenerator output) and have the CLI install it without a post-build script.
|
|
215
|
+
|
|
216
|
+
A canonical favicon set is **multiple files** — typically `favicon.svg`, `favicon.ico`, `favicon-96x96.png`, `apple-touch-icon.png`, `web-app-manifest-192x192.png`, `web-app-manifest-512x512.png`, and `site.webmanifest`. Put all of them in a directory (e.g. `assets/favicons/`) and reference it via `source_dir`:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
my-deployment/
|
|
220
|
+
├── site-config.yml
|
|
221
|
+
└── assets/
|
|
222
|
+
└── favicons/ ← branding.favicon.source_dir points here
|
|
223
|
+
├── favicon.svg
|
|
224
|
+
├── favicon.ico
|
|
225
|
+
├── favicon-96x96.png
|
|
226
|
+
├── apple-touch-icon.png
|
|
227
|
+
├── web-app-manifest-192x192.png
|
|
228
|
+
├── web-app-manifest-512x512.png
|
|
229
|
+
└── site.webmanifest ← optional; CLI regenerates with BASE_PATH
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Then in `site-config.yml`:
|
|
233
|
+
|
|
234
|
+
```yaml
|
|
235
|
+
branding:
|
|
236
|
+
favicon:
|
|
237
|
+
base_path: / # URL prefix (BASE_PATH-aware; default '/')
|
|
238
|
+
skip_default_links: true # suppress the default <link> block
|
|
239
|
+
source_dir: assets/favicons # canonical files (all of them, in one directory)
|
|
240
|
+
links_html: |- # raw HTML emitted verbatim in <head>
|
|
241
|
+
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
|
242
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
243
|
+
<link rel="shortcut icon" href="/favicon.ico" />
|
|
244
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
245
|
+
<link rel="manifest" href="/site.webmanifest" />
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| Field | Type | Effect |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `source_dir` | string | Path (relative to cwd) to a directory containing **all** canonical favicon files. The CLI copies every file in this directory into `public/`, overriding any defaults. Also removes the default-generated cruft (`apple-touch-icon-57x57.png`, `favicon-16x16.png`, etc.) so it doesn't linger. |
|
|
251
|
+
| `links_html` | string | Raw HTML (one or more `<link>` tags) emitted verbatim in `<head>`. Use this to declare which of the source_dir files the browser should load, with what `sizes`/`type`. |
|
|
252
|
+
| `skip_default_links` | boolean | When true, the CLI does NOT call the `favicons` package and does NOT emit the default `<link>` tags. Pair with `links_html` and `source_dir` for fully custom branding. |
|
|
253
|
+
| `base_path` | string | URL prefix prepended to every emitted link. Useful for BASE_PATH-scoped deployments (e.g. `/vocab/`). |
|
|
254
|
+
|
|
255
|
+
The object form exists so consumers with a RealFaviconGenerator favicon set (or any other canonical brand favicon bundle) can install it without a post-build script. Previously this required workarounds like `glossarist/cie-eilv/scripts/install-favicons.mjs` (149 lines) and `glossarist/iala-vocab/scripts/install-favicons.mjs` (175 lines) — both are now unnecessary.
|
|
256
|
+
|
|
257
|
+
If neither form is set, the Glossarist default favicon is used.
|
|
113
258
|
|
|
114
259
|
### About pages
|
|
115
260
|
|
package/cli/index.mjs
CHANGED
|
@@ -88,53 +88,150 @@ Environment:
|
|
|
88
88
|
fs.mkdirSync(publicDir, { recursive: true });
|
|
89
89
|
|
|
90
90
|
const branding = config?.branding || {};
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
|
|
92
|
+
// Favicon resolution (issue #173).
|
|
93
|
+
//
|
|
94
|
+
// `branding.favicon` accepts two shapes:
|
|
95
|
+
//
|
|
96
|
+
// 1. String (legacy) — path to a single source SVG/PNG. The `favicons`
|
|
97
|
+
// package generates the full variant set from it.
|
|
98
|
+
//
|
|
99
|
+
// 2. Object — `{ source_dir?, links_html?, skip_default_links?, base_path? }`.
|
|
100
|
+
// - `links_html`: raw HTML (one or more <link> tags) emitted verbatim.
|
|
101
|
+
// Skips the `favicons` package entirely; consumer is responsible
|
|
102
|
+
// for providing the actual icon files in public/.
|
|
103
|
+
// - `source_dir`: directory of canonical favicon files copied as-is
|
|
104
|
+
// into public/ (overriding any defaults). Useful when the consumer
|
|
105
|
+
// already has a RealFaviconGenerator output set.
|
|
106
|
+
// - `skip_default_links`: don't emit the default <link> block at all.
|
|
107
|
+
// - `base_path`: URL prefix for the emitted links (BASE_PATH-aware).
|
|
108
|
+
const faviconCfg = branding.favicon;
|
|
109
|
+
const faviconCfgIsObject =
|
|
110
|
+
faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
|
|
95
111
|
|
|
96
112
|
let faviconHtml = '';
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
theme_color: branding.primaryColor || '#2563eb',
|
|
105
|
-
icons: {
|
|
106
|
-
android: false,
|
|
107
|
-
appleIcon: true,
|
|
108
|
-
appleStartup: false,
|
|
109
|
-
favicons: true,
|
|
110
|
-
windows: false,
|
|
111
|
-
yandex: false,
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
for (const img of response.images) {
|
|
116
|
-
fs.writeFileSync(resolve(publicDir, img.name), img.contents);
|
|
117
|
-
}
|
|
118
|
-
for (const file of response.files) {
|
|
119
|
-
fs.writeFileSync(resolve(publicDir, file.name), file.contents);
|
|
113
|
+
let skipDefaultGeneration = false;
|
|
114
|
+
|
|
115
|
+
if (faviconCfgIsObject) {
|
|
116
|
+
// Object form — honor each declared field.
|
|
117
|
+
if (faviconCfg.links_html) {
|
|
118
|
+
faviconHtml = String(faviconCfg.links_html);
|
|
119
|
+
skipDefaultGeneration = true;
|
|
120
120
|
}
|
|
121
|
+
if (faviconCfg.source_dir) {
|
|
122
|
+
const srcDir = resolve(process.cwd(), faviconCfg.source_dir);
|
|
123
|
+
if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
|
|
124
|
+
console.log(`\n=== FAVICONS (copy from ${faviconCfg.source_dir}) ===\n`);
|
|
125
|
+
// When the consumer provides a canonical favicon set, also delete
|
|
126
|
+
// the default-generated cruft (apple-touch-icon-*.png, etc.) so
|
|
127
|
+
// it doesn't linger in public/ and pollute the deployment.
|
|
128
|
+
// Mirrors glossarist/iala-vocab/scripts/install-favicons.mjs.
|
|
129
|
+
const cruftFiles = [
|
|
130
|
+
'apple-touch-icon-57x57.png',
|
|
131
|
+
'apple-touch-icon-60x60.png',
|
|
132
|
+
'apple-touch-icon-72x72.png',
|
|
133
|
+
'apple-touch-icon-76x76.png',
|
|
134
|
+
'apple-touch-icon-114x114.png',
|
|
135
|
+
'apple-touch-icon-120x120.png',
|
|
136
|
+
'apple-touch-icon-144x144.png',
|
|
137
|
+
'apple-touch-icon-152x152.png',
|
|
138
|
+
'apple-touch-icon-167x167.png',
|
|
139
|
+
'apple-touch-icon-180x180.png',
|
|
140
|
+
'apple-touch-icon-1024x1024.png',
|
|
141
|
+
'apple-touch-icon-precomposed.png',
|
|
142
|
+
'favicon-16x16.png',
|
|
143
|
+
'favicon-32x32.png',
|
|
144
|
+
'favicon-48x48.png',
|
|
145
|
+
'browserconfig.xml',
|
|
146
|
+
];
|
|
147
|
+
let removed = 0;
|
|
148
|
+
for (const cruft of cruftFiles) {
|
|
149
|
+
const cruftPath = resolve(publicDir, cruft);
|
|
150
|
+
if (fs.existsSync(cruftPath)) {
|
|
151
|
+
fs.unlinkSync(cruftPath);
|
|
152
|
+
removed++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (removed > 0) console.log(` Removed ${removed} default cruft file(s)`);
|
|
121
156
|
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
let copied = 0;
|
|
158
|
+
for (const entry of fs.readdirSync(srcDir)) {
|
|
159
|
+
const fromPath = resolve(srcDir, entry);
|
|
160
|
+
if (fs.statSync(fromPath).isFile()) {
|
|
161
|
+
fs.copyFileSync(fromPath, resolve(publicDir, entry));
|
|
162
|
+
copied++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
console.log(` Copied ${copied} canonical favicon file(s) from ${faviconCfg.source_dir}`);
|
|
166
|
+
} else {
|
|
167
|
+
console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
|
|
168
|
+
}
|
|
124
169
|
}
|
|
170
|
+
if (faviconCfg.skip_default_links) {
|
|
171
|
+
skipDefaultGeneration = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!skipDefaultGeneration) {
|
|
176
|
+
// Legacy / default path — derive a single source file and let the
|
|
177
|
+
// `favicons` package generate the full variant set.
|
|
178
|
+
const faviconSrc =
|
|
179
|
+
(typeof faviconCfg === 'string' && faviconCfg && resolve(process.cwd(), faviconCfg)) ||
|
|
180
|
+
(branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
|
|
181
|
+
resolve(pkgRoot, 'public', 'favicon.svg');
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
183
|
+
if (fs.existsSync(faviconSrc)) {
|
|
184
|
+
console.log(`\n=== FAVICONS ===\n`);
|
|
185
|
+
const favicons = (await import('favicons')).default;
|
|
186
|
+
const source = fs.readFileSync(faviconSrc);
|
|
187
|
+
const response = await favicons(source, {
|
|
188
|
+
appName: config?.title || 'Glossarist',
|
|
189
|
+
background: branding.primaryColor || '#2563eb',
|
|
190
|
+
theme_color: branding.primaryColor || '#2563eb',
|
|
191
|
+
icons: {
|
|
192
|
+
android: false,
|
|
193
|
+
appleIcon: true,
|
|
194
|
+
appleStartup: false,
|
|
195
|
+
favicons: true,
|
|
196
|
+
windows: false,
|
|
197
|
+
yandex: false,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for (const img of response.images) {
|
|
202
|
+
fs.writeFileSync(resolve(publicDir, img.name), img.contents);
|
|
203
|
+
}
|
|
204
|
+
for (const file of response.files) {
|
|
205
|
+
fs.writeFileSync(resolve(publicDir, file.name), file.contents);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (faviconSrc.endsWith('.svg')) {
|
|
209
|
+
fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Only use the generated HTML if the consumer didn't supply their own.
|
|
213
|
+
if (!faviconHtml) {
|
|
214
|
+
faviconHtml = response.html.join('\n ');
|
|
215
|
+
if (faviconSrc.endsWith('.svg')) {
|
|
216
|
+
faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
console.log(` Generated ${response.images.length} favicon files`);
|
|
129
220
|
}
|
|
130
|
-
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
131
|
-
console.log(` Generated ${response.images.length} favicon files`);
|
|
132
221
|
}
|
|
133
222
|
|
|
134
223
|
if (faviconHtml) {
|
|
135
|
-
|
|
224
|
+
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
225
|
+
// Apply base_path prefix (issue #173 + BASE_PATH-aware deployments).
|
|
226
|
+
const basePath =
|
|
227
|
+
(faviconCfgIsObject && faviconCfg.base_path) ||
|
|
228
|
+
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
229
|
+
'';
|
|
136
230
|
if (basePath) {
|
|
137
|
-
faviconHtml = faviconHtml.replace(
|
|
231
|
+
faviconHtml = faviconHtml.replace(
|
|
232
|
+
/(href|content)="\/([^"]+)"/g,
|
|
233
|
+
`$1="${basePath}/$2`,
|
|
234
|
+
);
|
|
138
235
|
}
|
|
139
236
|
process.env.FAVICON_HTML = faviconHtml;
|
|
140
237
|
}
|
package/cli/index.ts
CHANGED
|
@@ -66,8 +66,26 @@ interface BrandingFonts {
|
|
|
66
66
|
weights?: number[];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
interface FaviconConfig {
|
|
70
|
+
/** Path prefix for all favicon URLs. Defaults to '/', set to a sub-path
|
|
71
|
+
* when deploying under BASE_PATH != '/'. */
|
|
72
|
+
base_path?: string;
|
|
73
|
+
/** Skip emitting the default <link rel="icon"> and <link rel="apple-touch-icon">
|
|
74
|
+
* tags. Use when the consumer provides their own RFG-compatible markup via
|
|
75
|
+
* `links_html`. Default: false (emit default tags). */
|
|
76
|
+
skip_default_links?: boolean;
|
|
77
|
+
/** Raw HTML string (one or more <link> tags) to emit in <head> instead of
|
|
78
|
+
* the default favicon block. RealFaviconGenerator output goes here. */
|
|
79
|
+
links_html?: string;
|
|
80
|
+
/** Consumer-side directory (relative to cwd) holding canonical favicon files.
|
|
81
|
+
* The CLI copies these into public/, overriding defaults. */
|
|
82
|
+
source_dir?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
interface BrandingConfig {
|
|
70
|
-
|
|
86
|
+
/** Either a string (legacy: path to a source SVG/PNG) or an object form
|
|
87
|
+
* with `source_dir`, `links_html`, `skip_default_links`, `base_path`. */
|
|
88
|
+
favicon?: string | FaviconConfig;
|
|
71
89
|
logo?: { localPath?: string };
|
|
72
90
|
primaryColor?: string;
|
|
73
91
|
fonts?: { header?: BrandingFonts; body?: BrandingFonts };
|
|
@@ -99,53 +117,135 @@ async function runBuildPipeline(config: SiteConfig | null): Promise<void> {
|
|
|
99
117
|
fs.mkdirSync(publicDir, { recursive: true });
|
|
100
118
|
|
|
101
119
|
const branding: BrandingConfig = config?.branding ?? {};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
|
|
121
|
+
// Favicon resolution (issue #173, #177).
|
|
122
|
+
// `branding.favicon` accepts two shapes:
|
|
123
|
+
// 1. String (legacy) — path to a single source SVG/PNG.
|
|
124
|
+
// 2. Object — `{ source_dir?, links_html?, skip_default_links?, base_path? }`.
|
|
125
|
+
const faviconCfg: string | FaviconConfig | undefined = branding.favicon;
|
|
126
|
+
const faviconCfgIsObject: boolean =
|
|
127
|
+
faviconCfg !== null && typeof faviconCfg === 'object' && !Array.isArray(faviconCfg);
|
|
128
|
+
const faviconCfgObject: FaviconConfig | undefined =
|
|
129
|
+
faviconCfgIsObject ? (faviconCfg as FaviconConfig) : undefined;
|
|
106
130
|
|
|
107
131
|
let faviconHtml = '';
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
theme_color: branding.primaryColor || '#2563eb',
|
|
116
|
-
icons: {
|
|
117
|
-
android: false,
|
|
118
|
-
appleIcon: true,
|
|
119
|
-
appleStartup: false,
|
|
120
|
-
favicons: true,
|
|
121
|
-
windows: false,
|
|
122
|
-
yandex: false,
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
for (const img of response.images) {
|
|
127
|
-
fs.writeFileSync(resolve(publicDir, img.name), img.contents);
|
|
132
|
+
let skipDefaultGeneration = false;
|
|
133
|
+
|
|
134
|
+
if (faviconCfgObject) {
|
|
135
|
+
const cfg: FaviconConfig = faviconCfgObject;
|
|
136
|
+
if (cfg.links_html) {
|
|
137
|
+
faviconHtml = String(cfg.links_html);
|
|
138
|
+
skipDefaultGeneration = true;
|
|
128
139
|
}
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
if (cfg.source_dir) {
|
|
141
|
+
const srcDir: string = resolve(process.cwd(), cfg.source_dir);
|
|
142
|
+
if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
|
|
143
|
+
console.log(`\n=== FAVICONS (copy from ${cfg.source_dir}) ===\n`);
|
|
144
|
+
// When the consumer provides a canonical favicon set, also delete
|
|
145
|
+
// the default-generated cruft so it doesn't linger in public/.
|
|
146
|
+
const cruftFiles: readonly string[] = [
|
|
147
|
+
'apple-touch-icon-57x57.png',
|
|
148
|
+
'apple-touch-icon-60x60.png',
|
|
149
|
+
'apple-touch-icon-72x72.png',
|
|
150
|
+
'apple-touch-icon-76x76.png',
|
|
151
|
+
'apple-touch-icon-114x114.png',
|
|
152
|
+
'apple-touch-icon-120x120.png',
|
|
153
|
+
'apple-touch-icon-144x144.png',
|
|
154
|
+
'apple-touch-icon-152x152.png',
|
|
155
|
+
'apple-touch-icon-167x167.png',
|
|
156
|
+
'apple-touch-icon-180x180.png',
|
|
157
|
+
'apple-touch-icon-1024x1024.png',
|
|
158
|
+
'apple-touch-icon-precomposed.png',
|
|
159
|
+
'favicon-16x16.png',
|
|
160
|
+
'favicon-32x32.png',
|
|
161
|
+
'favicon-48x48.png',
|
|
162
|
+
'browserconfig.xml',
|
|
163
|
+
];
|
|
164
|
+
let removed = 0;
|
|
165
|
+
for (const cruft of cruftFiles) {
|
|
166
|
+
const cruftPath: string = resolve(publicDir, cruft);
|
|
167
|
+
if (fs.existsSync(cruftPath)) {
|
|
168
|
+
fs.unlinkSync(cruftPath);
|
|
169
|
+
removed++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (removed > 0) console.log(` Removed ${removed} default cruft file(s)`);
|
|
173
|
+
|
|
174
|
+
let copied = 0;
|
|
175
|
+
for (const entry of fs.readdirSync(srcDir)) {
|
|
176
|
+
const fromPath: string = resolve(srcDir, entry);
|
|
177
|
+
if (fs.statSync(fromPath).isFile()) {
|
|
178
|
+
fs.copyFileSync(fromPath, resolve(publicDir, entry));
|
|
179
|
+
copied++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
console.log(` Copied ${copied} canonical favicon file(s) from ${cfg.source_dir}`);
|
|
183
|
+
} else {
|
|
184
|
+
console.warn(` Warning: branding.favicon.source_dir not found: ${srcDir}`);
|
|
185
|
+
}
|
|
131
186
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
|
|
187
|
+
if (cfg.skip_default_links) {
|
|
188
|
+
skipDefaultGeneration = true;
|
|
135
189
|
}
|
|
190
|
+
}
|
|
136
191
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
if (!skipDefaultGeneration) {
|
|
193
|
+
const faviconSrc: string =
|
|
194
|
+
(typeof faviconCfg === 'string' && faviconCfg && resolve(process.cwd(), faviconCfg)) ||
|
|
195
|
+
(branding.logo?.localPath && resolve(process.cwd(), branding.logo.localPath)) ||
|
|
196
|
+
resolve(pkgRoot, 'public', 'favicon.svg');
|
|
197
|
+
|
|
198
|
+
if (fs.existsSync(faviconSrc)) {
|
|
199
|
+
console.log(`\n=== FAVICONS ===\n`);
|
|
200
|
+
const favicons = (await import('favicons')).default;
|
|
201
|
+
const source: Buffer = fs.readFileSync(faviconSrc);
|
|
202
|
+
const response: FaviconResponse = await favicons(source, {
|
|
203
|
+
appName: config?.title || 'Glossarist',
|
|
204
|
+
background: branding.primaryColor || '#2563eb',
|
|
205
|
+
theme_color: branding.primaryColor || '#2563eb',
|
|
206
|
+
icons: {
|
|
207
|
+
android: false,
|
|
208
|
+
appleIcon: true,
|
|
209
|
+
appleStartup: false,
|
|
210
|
+
favicons: true,
|
|
211
|
+
windows: false,
|
|
212
|
+
yandex: false,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
for (const img of response.images) {
|
|
217
|
+
fs.writeFileSync(resolve(publicDir, img.name), img.contents);
|
|
218
|
+
}
|
|
219
|
+
for (const file of response.files) {
|
|
220
|
+
fs.writeFileSync(resolve(publicDir, file.name), file.contents);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (faviconSrc.endsWith('.svg')) {
|
|
224
|
+
fs.copyFileSync(faviconSrc, resolve(publicDir, 'favicon.svg'));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Only use the generated HTML if the consumer didn't supply their own.
|
|
228
|
+
if (!faviconHtml) {
|
|
229
|
+
faviconHtml = response.html.join('\n ');
|
|
230
|
+
if (faviconSrc.endsWith('.svg')) {
|
|
231
|
+
faviconHtml += '\n <link rel="icon" type="image/svg+xml" href="/favicon.svg">';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
console.log(` Generated ${response.images.length} favicon files`);
|
|
140
235
|
}
|
|
141
|
-
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
142
|
-
console.log(` Generated ${response.images.length} favicon files`);
|
|
143
236
|
}
|
|
144
237
|
|
|
145
238
|
if (faviconHtml) {
|
|
146
|
-
|
|
239
|
+
fs.writeFileSync(resolve(publicDir, 'favicon-links.html'), faviconHtml);
|
|
240
|
+
const basePath: string =
|
|
241
|
+
(faviconCfgObject?.base_path) ||
|
|
242
|
+
process.env.BASE_PATH?.replace(/\/+$/, '') ||
|
|
243
|
+
'';
|
|
147
244
|
if (basePath) {
|
|
148
|
-
faviconHtml = faviconHtml.replace(
|
|
245
|
+
faviconHtml = faviconHtml.replace(
|
|
246
|
+
/(href|content)="\/([^"]+)"/g,
|
|
247
|
+
`$1="${basePath}/$2`,
|
|
248
|
+
);
|
|
149
249
|
}
|
|
150
250
|
process.env.FAVICON_HTML = faviconHtml;
|
|
151
251
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.108",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression test for issue #177 — `branding.favicon` object form crashes
|
|
6
|
+
* the CLI with ERR_INVALID_ARG_TYPE.
|
|
7
|
+
*
|
|
8
|
+
* The 0.7.106 fix for #173 shipped `branding.favicon` as an object
|
|
9
|
+
* (`{ source_dir, links_html, skip_default_links, base_path }`) in the
|
|
10
|
+
* TypeScript types and in `src/layouts/Default.astro`, but `cli/index.mjs`
|
|
11
|
+
* still called `resolve(process.cwd(), branding.favicon)` — which throws
|
|
12
|
+
* `ERR_INVALID_ARG_TYPE` when `branding.favicon` is an object.
|
|
13
|
+
*
|
|
14
|
+
* Importing `cli/index.mjs` directly runs the full build pipeline, which
|
|
15
|
+
* is too heavy for a unit test. Instead, this spec pins the type-narrowing
|
|
16
|
+
* contract that the CLI's favicon resolution MUST honor: a string is a
|
|
17
|
+
* path, an object is a config, and the two must never be conflated. The
|
|
18
|
+
* integration smoke test (scripts/smoke.mjs) covers the end-to-end path.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe('CLI favicon config (issue #177)', () => {
|
|
22
|
+
it('resolve() throws ERR_INVALID_ARG_TYPE when branding.favicon is an object — proves the bug class', async () => {
|
|
23
|
+
// This is the bug: the legacy code did `resolve(cwd, branding.favicon)`
|
|
24
|
+
// unconditionally. When branding.favicon is an object, Node throws.
|
|
25
|
+
const faviconCfg = { skip_default_links: true, links_html: '<link>' };
|
|
26
|
+
expect(() => resolve(process.cwd(), faviconCfg as any)).toThrow(/paths\[1\].*string|ERR_INVALID_ARG_TYPE/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('typeof-string guard prevents the crash', () => {
|
|
30
|
+
const faviconCfg = { skip_default_links: true, links_html: '<link>' };
|
|
31
|
+
const safeResolve = () =>
|
|
32
|
+
typeof faviconCfg === 'string' && faviconCfg
|
|
33
|
+
? resolve(process.cwd(), faviconCfg)
|
|
34
|
+
: null;
|
|
35
|
+
expect(safeResolve).not.toThrow();
|
|
36
|
+
expect(safeResolve()).toBe(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accepts string form (legacy) — single source path', () => {
|
|
40
|
+
const faviconCfg = 'assets/my-brand.svg';
|
|
41
|
+
const resolved =
|
|
42
|
+
typeof faviconCfg === 'string' && faviconCfg
|
|
43
|
+
? resolve(process.cwd(), faviconCfg)
|
|
44
|
+
: null;
|
|
45
|
+
expect(resolved).toBe(resolve(process.cwd(), 'assets/my-brand.svg'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts object form — no path resolution attempted on the object itself', () => {
|
|
49
|
+
const faviconCfg = {
|
|
50
|
+
source_dir: 'assets/favicons',
|
|
51
|
+
links_html: '<link rel="icon" href="/favicon.svg" />',
|
|
52
|
+
skip_default_links: true,
|
|
53
|
+
base_path: '/',
|
|
54
|
+
};
|
|
55
|
+
expect(typeof faviconCfg).toBe('object');
|
|
56
|
+
// The object form must be inspected for fields, not passed to resolve().
|
|
57
|
+
expect((faviconCfg as any).source_dir).toBe('assets/favicons');
|
|
58
|
+
expect((faviconCfg as any).links_html).toContain('<link');
|
|
59
|
+
expect((faviconCfg as any).skip_default_links).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|