create-bearminion 1.2.0 → 1.3.0
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 +18 -0
- package/README.md +3 -1
- package/dist/cli.mjs +27 -27
- package/package.json +2 -2
- package/template/README.md +13 -2
- package/template/package.json +1 -1
- package/template/src/components/organisms/Footer.astro +4 -4
- package/template/src/components/sections/media/Media.astro +2 -0
- package/template/src/components/sections/media/MediaPlayer.astro +18 -3
- package/template/src/components/seo/Seo.astro +6 -1
- package/template/src/layouts/Base.astro +5 -0
- package/template/src/lib/analytics.ts +33 -0
- package/template/src/lib/image-url.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# create-bearminion
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4de8434: Custom JSON-LD and analytics snippets. A page's `seo.jsonLd` holds parsed JSON-LD blocks that replace the generated `WebPage` markup, and `site.analytics` (`head`, `bodyEnd`) holds raw snippets the template emits on the live site only: not in `astro dev`, and not in Vercel or Netlify preview deployments. Nothing needs configuring; `BEARMINION_ANALYTICS=on|off` overrides the choice for hosts that cannot be told apart. The Pages tab shows an SEO card (search result preview and badges) with an Edit SEO button that opens a dialog for every SEO field, including JSON-LD with validation and script-tag unwrapping. The Site tab has an Analytics & scripts group. `parseJsonLdInput`, `formatJsonLd`, `jsonLdWarnings`, `jsonLdSchema` and `siteAnalyticsSchema` are exported. Themes need to render the two new fields (see the theme docs).
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 826b0ac: `create-bearminion` speaks in the BearMinion voice: the prompts, progress lines, errors, next steps and `--help` text. The messages say the same things, with fewer words and a grumpier bear.
|
|
12
|
+
- 2032a4a: Template: footer columns stay left-aligned on small screens instead of piling up on the right. They stack in one column on phones, two columns on larger phones, three columns on tablets, and sit beside the brand block from wide layouts up.
|
|
13
|
+
|
|
14
|
+
## 1.2.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 755bad0: The Media section's video cover is a responsive image (a WebP `srcset` from 480 to 1920 pixels wide), so a phone no longer downloads the full-size poster, and it loads at once instead of lazily when the section is one of the first two on the page, where it is often the largest thing on screen.
|
|
19
|
+
- 0a2301e: The package pages link to the new website and documentation: the npm homepage is https://bearminion.com, and the READMEs (including the template's) point to https://docs.bearminion.com instead of Markdown files on GitHub.
|
|
20
|
+
|
|
3
21
|
## 1.2.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -58,8 +58,10 @@ The [default BearMinion template](https://github.com/langehelp-software/bearmini
|
|
|
58
58
|
|
|
59
59
|
## Learn more
|
|
60
60
|
|
|
61
|
+
- [bearminion.com](https://bearminion.com), with a demo of the editor
|
|
62
|
+
- [Getting started](https://docs.bearminion.com/getting-started/): create, edit and put your site online
|
|
63
|
+
- [Documentation](https://docs.bearminion.com)
|
|
61
64
|
- [BearMinion on GitHub](https://github.com/langehelp-software/bearminion)
|
|
62
|
-
- [Documentation](https://github.com/langehelp-software/bearminion/blob/main/docs/README.md)
|
|
63
65
|
|
|
64
66
|
## License
|
|
65
67
|
|
package/dist/cli.mjs
CHANGED
|
@@ -943,7 +943,7 @@ function runCommand(pm, script) {
|
|
|
943
943
|
//#endregion
|
|
944
944
|
//#region src/args.ts
|
|
945
945
|
var UsageError = class extends Error {};
|
|
946
|
-
const HELP = `
|
|
946
|
+
const HELP = `Scaffold a BearMinion site: Astro, plain JSON content, an editor you click on. No database, no drama.
|
|
947
947
|
|
|
948
948
|
Usage
|
|
949
949
|
npm create bearminion@latest [directory] -- [options]
|
|
@@ -951,11 +951,11 @@ Usage
|
|
|
951
951
|
Options
|
|
952
952
|
--name <name> Site name (default: from the directory)
|
|
953
953
|
--url <address> Public address, such as https://example.com (you can set it later in the editor)
|
|
954
|
-
--storybook Include Storybook
|
|
955
|
-
--pm <manager> Package manager
|
|
956
|
-
--no-install
|
|
957
|
-
--no-git
|
|
958
|
-
-y, --yes Ask nothing
|
|
954
|
+
--storybook Include Storybook (off by default; it is a lot of extra packages)
|
|
955
|
+
--pm <manager> Package manager: ${PACKAGE_MANAGERS.join(", ")} (default: the one you ran)
|
|
956
|
+
--no-install Skip installing dependencies
|
|
957
|
+
--no-git Skip git init
|
|
958
|
+
-y, --yes Ask nothing, use defaults for whatever is missing
|
|
959
959
|
-h, --help Show this help
|
|
960
960
|
-v, --version Show the version
|
|
961
961
|
`;
|
|
@@ -968,9 +968,9 @@ function parseCliArgs(argv) {
|
|
|
968
968
|
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
969
969
|
}
|
|
970
970
|
const { values, positionals } = parsed;
|
|
971
|
-
if (positionals.length > 1) throw new UsageError(`
|
|
971
|
+
if (positionals.length > 1) throw new UsageError(`One directory, please. Got ${positionals.length}: ${positionals.join(" ")}`);
|
|
972
972
|
const pm = values.pm;
|
|
973
|
-
if (pm !== void 0 && !isPackageManager(pm)) throw new UsageError(`
|
|
973
|
+
if (pm !== void 0 && !isPackageManager(pm)) throw new UsageError(`"${pm}" isn't a package manager I know. Use one of: ${PACKAGE_MANAGERS.join(", ")}.`);
|
|
974
974
|
return {
|
|
975
975
|
...positionals[0] !== void 0 ? { directory: positionals[0] } : {},
|
|
976
976
|
...values.name !== void 0 ? { name: values.name } : {},
|
|
@@ -1269,9 +1269,9 @@ async function main() {
|
|
|
1269
1269
|
return 0;
|
|
1270
1270
|
}
|
|
1271
1271
|
const interactive = !options.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1272
|
-
intro("
|
|
1272
|
+
intro("🐻 Oh, great. Another website. Let's make this quick.");
|
|
1273
1273
|
const directory = options.directory ?? (interactive ? await ask(text({
|
|
1274
|
-
message: "
|
|
1274
|
+
message: "Which folder? A new or empty one.",
|
|
1275
1275
|
placeholder: `./${DEFAULT_DIRECTORY}`,
|
|
1276
1276
|
defaultValue: DEFAULT_DIRECTORY
|
|
1277
1277
|
})) : DEFAULT_DIRECTORY);
|
|
@@ -1279,11 +1279,11 @@ async function main() {
|
|
|
1279
1279
|
const targetDir = path.resolve(directory);
|
|
1280
1280
|
const blocking = await blockingEntries(targetDir);
|
|
1281
1281
|
if (blocking.length > 0) {
|
|
1282
|
-
cancel(
|
|
1282
|
+
cancel(`Grrr. ${directory} isn't empty (${preview(blocking)}). Pick another folder, or clean up your mess first.`);
|
|
1283
1283
|
return 1;
|
|
1284
1284
|
}
|
|
1285
1285
|
const siteName = options.name ?? (interactive ? await ask(text({
|
|
1286
|
-
message: "What
|
|
1286
|
+
message: "What are we calling this masterpiece?",
|
|
1287
1287
|
placeholder: toSiteName(directory),
|
|
1288
1288
|
defaultValue: toSiteName(directory)
|
|
1289
1289
|
})) : toSiteName(directory));
|
|
@@ -1292,23 +1292,23 @@ async function main() {
|
|
|
1292
1292
|
if (options.url !== void 0) {
|
|
1293
1293
|
const normalized = normalizeUrl(options.url);
|
|
1294
1294
|
if (!normalized) {
|
|
1295
|
-
cancel(`"${options.url}"
|
|
1295
|
+
cancel(`"${options.url}" isn't a web address. Try something like https://example.com.`);
|
|
1296
1296
|
return 1;
|
|
1297
1297
|
}
|
|
1298
1298
|
url = normalized;
|
|
1299
1299
|
} else if (interactive) {
|
|
1300
1300
|
const answer = await ask(text({
|
|
1301
|
-
message: "What
|
|
1302
|
-
placeholder: "https://example.com (Enter to skip
|
|
1301
|
+
message: "What's its web address?",
|
|
1302
|
+
placeholder: "https://example.com (Enter to skip. Set it later under Site → URL)",
|
|
1303
1303
|
defaultValue: "",
|
|
1304
|
-
validate: (value) => !value || normalizeUrl(value) ? void 0 : "
|
|
1304
|
+
validate: (value) => !value || normalizeUrl(value) ? void 0 : "That's not a web address. Try https://example.com, or leave it empty."
|
|
1305
1305
|
}));
|
|
1306
1306
|
if (answer === void 0) return cancelled();
|
|
1307
1307
|
url = answer ? normalizeUrl(answer) ?? void 0 : void 0;
|
|
1308
1308
|
}
|
|
1309
1309
|
const pm = options.packageManager ?? detectPackageManager(process.env.npm_config_user_agent);
|
|
1310
1310
|
const spin = spinner();
|
|
1311
|
-
spin.start("
|
|
1311
|
+
spin.start("Unpacking the template");
|
|
1312
1312
|
await scaffold({
|
|
1313
1313
|
templateDir: path.join(packageRoot, "template"),
|
|
1314
1314
|
targetDir,
|
|
@@ -1317,25 +1317,25 @@ async function main() {
|
|
|
1317
1317
|
...url ? { url } : {},
|
|
1318
1318
|
storybook: options.storybook
|
|
1319
1319
|
});
|
|
1320
|
-
spin.stop(`
|
|
1320
|
+
spin.stop(`Done. ${siteName} lives in ${path.relative(process.cwd(), targetDir) || "."}${options.storybook ? ", Storybook included" : ""}`);
|
|
1321
1321
|
let installed = false;
|
|
1322
1322
|
if (options.install) {
|
|
1323
|
-
spin.start(`
|
|
1323
|
+
spin.start(`Fetching the bare minimum with ${pm}`);
|
|
1324
1324
|
const result = await install(pm, targetDir);
|
|
1325
1325
|
installed = result.ok;
|
|
1326
|
-
if (result.ok) spin.stop("
|
|
1326
|
+
if (result.ok) spin.stop("Dependencies installed. Could have been worse.");
|
|
1327
1327
|
else {
|
|
1328
1328
|
spin.error(`${pm} install failed`);
|
|
1329
1329
|
log.message(result.output);
|
|
1330
|
-
log.warn(`
|
|
1330
|
+
log.warn(`Sort out whatever that is, then run ${pm} install in the folder.`);
|
|
1331
1331
|
}
|
|
1332
1332
|
}
|
|
1333
1333
|
if (options.git) {
|
|
1334
|
-
if (insideGitRepository(path.dirname(targetDir))) log.info("
|
|
1334
|
+
if (insideGitRepository(path.dirname(targetDir))) log.info("Already inside a git repository, so I didn't start another one.");
|
|
1335
1335
|
else {
|
|
1336
1336
|
const git = initGit(targetDir);
|
|
1337
|
-
if (!git.initialized) log.warn(`
|
|
1338
|
-
else if (!git.committed) log.warn("
|
|
1337
|
+
if (!git.initialized) log.warn(`Couldn't create a git repository: ${git.reason ?? "git is not available"}`);
|
|
1338
|
+
else if (!git.committed) log.warn("Repository created, first commit failed. Set your name and email with git config, then commit. Not my department.");
|
|
1339
1339
|
}
|
|
1340
1340
|
}
|
|
1341
1341
|
const steps = [
|
|
@@ -1343,8 +1343,8 @@ async function main() {
|
|
|
1343
1343
|
...installed ? [] : [`${pm} install`],
|
|
1344
1344
|
runCommand(pm, "dev")
|
|
1345
1345
|
];
|
|
1346
|
-
note(`${steps.join("\n")}\n\nOpen the address it prints
|
|
1347
|
-
outro(url ? `Ready to publish at ${url}.` : "Set your web address under Site → URL before you publish.");
|
|
1346
|
+
note(`${steps.join("\n")}\n\nOpen the address it prints, press Ctrl+E (Cmd+E on a Mac) and click on things.`, "Next steps");
|
|
1347
|
+
outro(url ? `Ready to publish at ${url}. I'm going back to sleep.` : "Set your web address under Site → URL before you publish. I'll be asleep.");
|
|
1348
1348
|
return 0;
|
|
1349
1349
|
}
|
|
1350
1350
|
/** Awaits a prompt; `undefined` when the person pressed Ctrl+C or Esc. */
|
|
@@ -1353,7 +1353,7 @@ async function ask(prompt) {
|
|
|
1353
1353
|
return typeof value === "string" ? value : void 0;
|
|
1354
1354
|
}
|
|
1355
1355
|
function cancelled() {
|
|
1356
|
-
cancel("Nothing was created.");
|
|
1356
|
+
cancel("Nothing was created. Back to sleep.");
|
|
1357
1357
|
return 1;
|
|
1358
1358
|
}
|
|
1359
1359
|
function preview(entries) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-bearminion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Create a BearMinion site: an Astro website you edit by clicking on it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jakub Lange",
|
|
7
|
-
"homepage": "https://
|
|
7
|
+
"homepage": "https://bearminion.com",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/langehelp-software/bearminion.git",
|
package/template/README.md
CHANGED
|
@@ -184,12 +184,23 @@ The site's own images (`logo.png`, `favicon.png`, `og.png`, `hero.png`) live in
|
|
|
184
184
|
|
|
185
185
|
## SEO and sitemap
|
|
186
186
|
|
|
187
|
-
`Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image.
|
|
187
|
+
`Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image. A page that sets `seo.jsonLd` gets those blocks instead of the generated WebPage block, one script tag per block. `site.analytics.head` and `site.analytics.bodyEnd` are emitted by `Base.astro` in production builds only.
|
|
188
188
|
|
|
189
189
|
`astro.config.ts` sets `site` from `site.json` and adds `@astrojs/sitemap`. The filter keeps content pages whose `seo.noindex` is false. `content/pages/preview.json` is the sample noindex page: it is built, it emits `noindex`, and it is absent from the sitemap. `src/pages/robots.txt.ts` points crawlers at `sitemap-index.xml`.
|
|
190
190
|
|
|
191
191
|
The style guide at `/style-guide` renders every typography size and button variant. `/variants` renders every section, header, and footer variant. They live in `src/dev/` and only `npm run dev` serves them (the `devPages` integration in `astro.config.ts`), so they never reach the built site. Delete that integration and the folder if you do not want them.
|
|
192
192
|
|
|
193
|
+
## Analytics
|
|
194
|
+
|
|
195
|
+
`site.analytics.head` and `site.analytics.bodyEnd` are raw HTML snippets (the editor's Site → Analytics & scripts). `Base.astro` writes `head` at the end of `<head>` and `bodyEnd` before `</body>`, and only when `analyticsEnabled()` in `src/lib/analytics.ts` says the build is the live site's:
|
|
196
|
+
|
|
197
|
+
1. `BEARMINION_ANALYTICS=on` or `off` decides, when set. `1`, `true` and `yes` also mean on; `0`, `false` and `no` mean off.
|
|
198
|
+
2. Otherwise a production build is required, so `npm run dev` sends no visits.
|
|
199
|
+
3. Vercel (`VERCEL_ENV`) and Netlify (`NETLIFY`, `CONTEXT`) say which deployment is building, so only their production deployments include the snippets.
|
|
200
|
+
4. On any other host every production build includes them. Cloudflare Pages and similar hosts do not say which build is a preview. Set `BEARMINION_ANALYTICS=off` in the host's preview environment (Cloudflare lets you set variables per environment), or `on` to test a snippet locally with `npm run dev`.
|
|
201
|
+
|
|
202
|
+
People who only use the editor never set a variable: steps 2 and 3 cover the usual setup. The snippets are not sanitised, so give write access to `content/site.json` only to people you would trust with the site's code.
|
|
203
|
+
|
|
193
204
|
## Dev editor
|
|
194
205
|
|
|
195
206
|
`astro.config.ts` registers `bearminion()`. It is active only while `pnpm dev` is running. The production build does not add editor routes, scripts, or the overlay.
|
|
@@ -200,7 +211,7 @@ A variant change, a new, duplicated, moved, or deleted section, and a sidebar va
|
|
|
200
211
|
|
|
201
212
|
Header links nest up to three levels (`MAX_NAV_DEPTH`). Desktop navigation uses hover and focus flyouts. The mobile menu uses an accordion. The link picker lists pages, `#anchor` on the current page, `/page#anchor` on other pages, and a custom URL.
|
|
202
213
|
|
|
203
|
-
The API is `/__bearminion/api` on localhost. Saving `content/*.json` asks the editor to reload. If the draft is still dirty, the editor asks to save, discard, or cancel first. Unsaved drafts are kept in `localStorage` until save or discard. An upload does not reload by itself, so the draft can preview the new file before save. Request and response shapes, with curl examples, are in the [Dev API docs](https://
|
|
214
|
+
The API is `/__bearminion/api` on localhost. Saving `content/*.json` asks the editor to reload. If the draft is still dirty, the editor asks to save, discard, or cancel first. Unsaved drafts are kept in `localStorage` until save or discard. An upload does not reload by itself, so the draft can preview the new file before save. Request and response shapes, with curl examples, are in the [Dev API docs](https://docs.bearminion.com/dev-api/), and every sidebar tab is described in [the editor docs](https://docs.bearminion.com/editor/).
|
|
204
215
|
|
|
205
216
|
```bash
|
|
206
217
|
pnpm dev
|
package/template/package.json
CHANGED
|
@@ -68,9 +68,9 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
|
|
|
68
68
|
</span>
|
|
69
69
|
)}
|
|
70
70
|
</div>
|
|
71
|
-
<div class="
|
|
71
|
+
<div class="grid w-full grid-cols-1 gap-x-8 gap-y-10 @min-[26rem]:grid-cols-2 @min-[40rem]:grid-cols-3 @min-[56rem]:ml-auto @min-[56rem]:flex @min-[56rem]:w-auto @min-[56rem]:flex-wrap @min-[56rem]:justify-end">
|
|
72
72
|
{columns.map((column, columnIndex) => (
|
|
73
|
-
<div class="flex
|
|
73
|
+
<div class="flex min-w-0 flex-col gap-3.5 @min-[56rem]:w-40 @min-[56rem]:min-w-32">
|
|
74
74
|
<p class="font-mono text-[0.6875rem] tracking-[0.08em] text-muted uppercase" data-field={`footer.columns.${columnIndex}.title`}>
|
|
75
75
|
{column.title}
|
|
76
76
|
</p>
|
|
@@ -99,9 +99,9 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
|
|
|
99
99
|
</Typography>
|
|
100
100
|
)}
|
|
101
101
|
</div>
|
|
102
|
-
<div class="
|
|
102
|
+
<div class="grid w-full grid-cols-1 gap-x-8 gap-y-8 @min-[26rem]:grid-cols-2 @min-[40rem]:grid-cols-3 @min-[56rem]:ml-auto @min-[56rem]:flex @min-[56rem]:w-auto @min-[56rem]:flex-wrap @min-[56rem]:justify-end">
|
|
103
103
|
{columns.map((column, columnIndex) => (
|
|
104
|
-
<div class="flex min-w-
|
|
104
|
+
<div class="flex min-w-0 flex-col gap-3.5 @min-[56rem]:min-w-32">
|
|
105
105
|
<p class="font-mono text-[0.6875rem] tracking-[0.08em] text-muted uppercase" data-field={`footer.columns.${columnIndex}.title`}>
|
|
106
106
|
{column.title}
|
|
107
107
|
</p>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { Image } from 'bearminion'
|
|
3
|
-
import { imageUrl } from '../../../lib/image-url'
|
|
3
|
+
import { imageSrcset, imageUrl } from '../../../lib/image-url'
|
|
4
4
|
import { cn } from '../../../lib/cn'
|
|
5
5
|
import MediaFrame from '../../molecules/MediaFrame.astro'
|
|
6
6
|
import type { MediaProps } from './definition'
|
|
@@ -15,10 +15,12 @@ interface Props {
|
|
|
15
15
|
label?: string
|
|
16
16
|
/** `data-field` path for the image, so a click on the frame opens the image picker. */
|
|
17
17
|
imageField: string
|
|
18
|
+
/** Near the top of the page: the cover loads at once instead of lazily, since it is likely the largest thing on screen. */
|
|
19
|
+
eager?: boolean
|
|
18
20
|
class?: string
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const { kind, aspect, video, poster, image, label, imageField, class: className } = Astro.props
|
|
23
|
+
const { kind, aspect, video, poster, image, label, imageField, eager = false, class: className } = Astro.props
|
|
22
24
|
const ASPECT = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '21:9': 'aspect-[21/9]', '1:1': 'aspect-square' } as const
|
|
23
25
|
const frame = cn(
|
|
24
26
|
'relative w-full min-h-48 overflow-hidden rounded-[clamp(1rem,2cqi,1.5rem)] bg-surface shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]',
|
|
@@ -29,6 +31,9 @@ const source = kind === 'video' && video ? parseMediaSource(video) : null
|
|
|
29
31
|
const hasImage = kind === 'image' && Boolean(image?.src) && image?.src !== 'placeholder' && !image?.hidden
|
|
30
32
|
const empty = kind === 'video' ? !source : !hasImage
|
|
31
33
|
const cover = source ? ((await imageUrl(poster)) ?? (source.kind === 'youtube' ? source.thumbnail : undefined)) : undefined
|
|
34
|
+
const coverSrcset = source ? await imageSrcset(poster) : undefined
|
|
35
|
+
// The frame is at most 68rem wide and the full width of a phone.
|
|
36
|
+
const coverSizes = '(min-width: 72rem) 68rem, 100vw'
|
|
32
37
|
const name = label || 'Video'
|
|
33
38
|
---
|
|
34
39
|
|
|
@@ -68,7 +73,17 @@ const name = label || 'Video'
|
|
|
68
73
|
data-label={name}
|
|
69
74
|
data-poster={source.kind === 'file' ? cover : undefined}
|
|
70
75
|
>
|
|
71
|
-
{cover &&
|
|
76
|
+
{cover && (
|
|
77
|
+
<img
|
|
78
|
+
src={cover}
|
|
79
|
+
srcset={coverSrcset}
|
|
80
|
+
sizes={coverSrcset ? coverSizes : undefined}
|
|
81
|
+
alt=""
|
|
82
|
+
class="absolute inset-0 size-full object-cover"
|
|
83
|
+
loading={eager ? 'eager' : 'lazy'}
|
|
84
|
+
decoding="async"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
72
87
|
<button
|
|
73
88
|
type="button"
|
|
74
89
|
class="group absolute inset-0 grid cursor-pointer place-items-center bg-[oklch(0.15_0.01_255/0.12)] transition-colors hover:bg-[oklch(0.15_0.01_255/0.22)] focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-accent"
|
|
@@ -60,4 +60,9 @@ const jsonLd = {
|
|
|
60
60
|
<meta name="twitter:title" content={title} />
|
|
61
61
|
<meta name="twitter:description" content={description} />
|
|
62
62
|
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
|
63
|
-
|
|
63
|
+
{/* Custom JSON-LD replaces the generated WebPage block. `<` is escaped so a value cannot close the script tag. */}
|
|
64
|
+
{
|
|
65
|
+
(page.seo.jsonLd ?? [jsonLd]).map((block) => (
|
|
66
|
+
<script type="application/ld+json" set:html={JSON.stringify(block).replace(/</g, '\\u003c')} />
|
|
67
|
+
))
|
|
68
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { Site } from '../components/organisms/site-definition'
|
|
|
4
4
|
import Footer from '../components/organisms/Footer.astro'
|
|
5
5
|
import Header from '../components/organisms/Header.astro'
|
|
6
6
|
import Seo from '../components/seo/Seo.astro'
|
|
7
|
+
import { analyticsEnabled } from '../lib/analytics'
|
|
7
8
|
import { resolveLocalAsset } from '../lib/assets'
|
|
8
9
|
import '../styles/global.css'
|
|
9
10
|
|
|
@@ -18,6 +19,8 @@ const ICON_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg'
|
|
|
18
19
|
const iconType = icon ? ICON_TYPES[icon.format] : undefined
|
|
19
20
|
const paletteCss = themeCss(site.theme.colors)
|
|
20
21
|
const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
22
|
+
// Snippets ship in the live site's build only: not in `astro dev`, not in host previews. See lib/analytics.ts.
|
|
23
|
+
const analytics = analyticsEnabled(process.env, import.meta.env.PROD) ? site.analytics : undefined
|
|
21
24
|
---
|
|
22
25
|
|
|
23
26
|
<!doctype html>
|
|
@@ -27,6 +30,7 @@ const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
|
27
30
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
31
|
<Seo site={site} page={page} pathname={Astro.url.pathname} />
|
|
29
32
|
<meta name="theme-color" content={themeColor} />
|
|
33
|
+
{analytics?.head && <Fragment set:html={analytics.head} />}
|
|
30
34
|
{/* Always present so the editor can update the palette live. */}
|
|
31
35
|
<style id="bm-theme" is:inline set:html={paletteCss}></style>
|
|
32
36
|
{icon && <link rel="icon" type={iconType} href={icon.src} />}
|
|
@@ -43,6 +47,7 @@ const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
|
43
47
|
<slot />
|
|
44
48
|
</main>
|
|
45
49
|
<Footer site={site} />
|
|
50
|
+
{analytics?.bodyEnd && <Fragment set:html={analytics.bodyEnd} />}
|
|
46
51
|
<script>
|
|
47
52
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
48
53
|
const observer =
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Build-time environment, as `process.env` gives it. */
|
|
2
|
+
export type Env = Record<string, string | undefined>
|
|
3
|
+
|
|
4
|
+
const ON = new Set(['on', '1', 'true', 'yes'])
|
|
5
|
+
const OFF = new Set(['off', '0', 'false', 'no'])
|
|
6
|
+
|
|
7
|
+
function readSwitch(value: string | undefined): boolean | undefined {
|
|
8
|
+
const word = value?.trim().toLowerCase()
|
|
9
|
+
if (word === undefined) return undefined
|
|
10
|
+
if (ON.has(word)) return true
|
|
11
|
+
if (OFF.has(word)) return false
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether this build puts `site.analytics` into its pages (`Base.astro` calls it with `process.env` and
|
|
17
|
+
* `import.meta.env.PROD`). Nobody has to set anything:
|
|
18
|
+
*
|
|
19
|
+
* 1. `BEARMINION_ANALYTICS=on|off` decides, when someone sets it (for hosts that cannot be told apart, or a
|
|
20
|
+
* local test).
|
|
21
|
+
* 2. Otherwise only a production build counts, so `astro dev` sends no visits.
|
|
22
|
+
* 3. Vercel (`VERCEL_ENV`) and Netlify (`NETLIFY`, `CONTEXT`) say which deployment this is, so their preview
|
|
23
|
+
* deployments send none either.
|
|
24
|
+
* 4. Any other host: every production build counts.
|
|
25
|
+
*/
|
|
26
|
+
export function analyticsEnabled(env: Env, production: boolean): boolean {
|
|
27
|
+
const override = readSwitch(env.BEARMINION_ANALYTICS)
|
|
28
|
+
if (override !== undefined) return override
|
|
29
|
+
if (!production) return false
|
|
30
|
+
if (env.VERCEL_ENV) return env.VERCEL_ENV === 'production'
|
|
31
|
+
if (env.NETLIFY === 'true') return env.CONTEXT === 'production'
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
@@ -7,6 +7,23 @@ import { isRemote, resolveLocalAsset } from './assets'
|
|
|
7
7
|
* optimized to WebP; a file uploaded in this dev session is served by the editor's dev route; a remote URL is used as
|
|
8
8
|
* it is. A missing file in `src/assets` fails the build, like a missing `<Image>`.
|
|
9
9
|
*/
|
|
10
|
+
/** Widths for a responsive `srcset`; none wider than the file itself. */
|
|
11
|
+
const SRCSET_WIDTHS = [480, 768, 1080, 1440, 1920]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An image as a WebP `srcset`, for a plain `<img>` that is not an Astro `<Image>`, such as a video's cover. Undefined
|
|
15
|
+
* when the image is not a file in `src/assets` (a remote URL or a dev upload): then `imageUrl` alone is used.
|
|
16
|
+
*/
|
|
17
|
+
export async function imageSrcset(image: Image | undefined): Promise<string | undefined> {
|
|
18
|
+
if (!image || image.hidden || !image.src || image.src === 'placeholder' || isRemote(image.src)) return undefined
|
|
19
|
+
const asset = resolveLocalAsset(image.src.replace(/^\//, ''))
|
|
20
|
+
if (!asset) return undefined
|
|
21
|
+
const widths = SRCSET_WIDTHS.filter((width) => width < asset.width)
|
|
22
|
+
widths.push(Math.min(asset.width, SRCSET_WIDTHS.at(-1) ?? asset.width))
|
|
23
|
+
const sources = await Promise.all(widths.map(async (width) => `${(await getImage({ src: asset, width, format: 'webp' })).src} ${width}w`))
|
|
24
|
+
return sources.join(', ')
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export async function imageUrl(image: Image | undefined, maxWidth = 1920): Promise<string | undefined> {
|
|
11
28
|
if (!image || image.hidden || !image.src || image.src === 'placeholder') return undefined
|
|
12
29
|
if (isRemote(image.src)) return image.src
|