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 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 = `Create a BearMinion site: an Astro website you edit by clicking on it.
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, a workshop for the theme's components
955
- --pm <manager> Package manager to install with: ${PACKAGE_MANAGERS.join(", ")} (default: the one you ran)
956
- --no-install Do not install dependencies
957
- --no-git Do not create a git repository
958
- -y, --yes Ask nothing; use the defaults for anything not given
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(`Expected one directory, got ${positionals.length}: ${positionals.join(" ")}`);
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(`Unknown package manager "${pm}". Use one of: ${PACKAGE_MANAGERS.join(", ")}.`);
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("BearMinion");
1272
+ intro("🐻 Oh, great. Another website. Let's make this quick.");
1273
1273
  const directory = options.directory ?? (interactive ? await ask(text({
1274
- message: "Where should the site go?",
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(`${directory} is not empty (${preview(blocking)}). Choose a new or empty folder.`);
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 is the site called?",
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}" is not a web address. Use one such as https://example.com.`);
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 is its web address?",
1302
- placeholder: "https://example.com (Enter to skip, set it later under Site → URL)",
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 : "Enter a full address such as https://example.com, or leave it empty."
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("Copying the template");
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(`Created ${siteName} in ${path.relative(process.cwd(), targetDir) || "."}${options.storybook ? ", with Storybook" : ""}`);
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(`Installing dependencies with ${pm}`);
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("Installed dependencies");
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(`Run ${pm} install in the folder to try again.`);
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("Inside an existing git repository, so no new one was created.");
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(`Could not create a git repository: ${git.reason ?? "git is not available"}`);
1338
- else if (!git.committed) log.warn("Created a git repository, but the first commit failed. Set your name and email with git config, then commit.");
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 and press Ctrl+E (Cmd+E on a Mac) to edit.`, "Next steps");
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.2.0",
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://github.com/langehelp-software/bearminion#readme",
7
+ "homepage": "https://bearminion.com",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/langehelp-software/bearminion.git",
@@ -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://github.com/langehelp-software/bearminion/blob/main/docs/dev-api.md), and every sidebar tab is described in [the editor docs](https://github.com/langehelp-software/bearminion/blob/main/docs/editor.md).
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
@@ -19,7 +19,7 @@
19
19
  "@fontsource/jetbrains-mono": "^5.3.0",
20
20
  "@tailwindcss/vite": "^4.1.12",
21
21
  "astro": "^7.3.8",
22
- "bearminion": "^1.2.0",
22
+ "bearminion": "^1.3.0",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "sharp": "^0.35.5",
25
25
  "tailwind-merge": "^3.7.0",
@@ -68,9 +68,9 @@ const wordmark = splitMarkedText(site.name, site.nameMarks)
68
68
  </span>
69
69
  )}
70
70
  </div>
71
- <div class="flex flex-wrap justify-end gap-x-8 gap-y-10 @min-[40rem]:ml-auto">
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 w-40 min-w-32 flex-col gap-3.5">
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="flex flex-wrap justify-end gap-x-8 gap-y-8 @min-[40rem]:ml-auto">
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-32 flex-col gap-3.5">
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>
@@ -29,6 +29,8 @@ const player = {
29
29
  image: props.image,
30
30
  label: props.label,
31
31
  imageField: field('image.alt'),
32
+ // The first two sections are usually on screen at load, where a lazy cover would hold back the largest paint.
33
+ eager: index <= 1,
32
34
  }
33
35
  ---
34
36
 
@@ -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 && <img src={cover} alt="" class="absolute inset-0 size-full object-cover" loading="lazy" decoding="async" />}
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
- <script type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, '\\u003c')} />
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