@tenphi/starlight 0.10.2 → 0.11.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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { a as resolveNavigationLayout } from "./navigation-CkQXI2Zb.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync } from "node:fs";
5
5
  import { cp, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
6
- import { dirname, extname, join } from "node:path";
6
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { COOKBOOK_COMPONENT_NAMES, assertValidDocs, createDocsGraph } from "@tenphi/docs";
9
9
  import { configure } from "@tenphi/tasty/core";
@@ -11,6 +11,7 @@ import { tastyIntegration } from "@tenphi/tasty/ssr/astro";
11
11
  import { renderMermaidSVG } from "beautiful-mermaid";
12
12
  import { apcaContrast, glaze, okhslToLinearSrgb, relativeLuminanceFromLinearRgb, variantToOkhsl } from "@tenphi/glaze";
13
13
  import "@tenphi/tasty";
14
+ import sharp from "sharp";
14
15
  //#region src/markdown/rehype-mermaid.ts
15
16
  const sourceStyleDirective = /^\s*(?:classDef|style|linkStyle)\s+.*$/gim;
16
17
  const accessibilityDirective = /^\s*acc(?:Title|Descr):\s*.*$/gim;
@@ -144,6 +145,64 @@ function isScrollContainer(node) {
144
145
  return node.type === "element" && node.tagName === "div" && Array.isArray(className) && className.includes(containerClass);
145
146
  }
146
147
  //#endregion
148
+ //#region src/markdown/rehype-page-affordances.ts
149
+ /** Add copy controls to rendered Markdown code blocks. */
150
+ function rehypePageAffordances() {
151
+ return (tree) => addPageAffordances(tree);
152
+ }
153
+ /** Sätteri adapter for Astro's default Markdown processor. */
154
+ const satteriPageAffordances = {
155
+ name: "cookbook:page-affordances",
156
+ element: {
157
+ filter: ["pre"],
158
+ visit(node, context) {
159
+ const replacement = pageAffordance(node);
160
+ if (replacement !== node) context.replaceNode(node, replacement);
161
+ }
162
+ }
163
+ };
164
+ function addPageAffordances(parent) {
165
+ if (!parent.children) return;
166
+ for (const [index, child] of parent.children.entries()) {
167
+ const replacement = pageAffordance(child);
168
+ if (replacement !== child) {
169
+ parent.children[index] = replacement;
170
+ continue;
171
+ }
172
+ addPageAffordances(child);
173
+ }
174
+ }
175
+ function pageAffordance(node) {
176
+ if (isOrdinaryCodeBlock(node)) return codeBlockWithCopy(node);
177
+ return node;
178
+ }
179
+ function isOrdinaryCodeBlock(node) {
180
+ if (node.type !== "element" || node.tagName !== "pre") return false;
181
+ return (node.properties?.dataLanguage ?? node.properties?.["data-language"]) !== "mermaid" && node.children?.[0]?.tagName === "code";
182
+ }
183
+ function codeBlockWithCopy(pre) {
184
+ return element("cookbook-code-block", {
185
+ className: ["td-code-block"],
186
+ dataTastyAnatomy: "MarkdownCodeBlock"
187
+ }, [pre, element("button", {
188
+ type: "button",
189
+ dataCopyCode: "",
190
+ ariaLabel: "Copy code",
191
+ title: "Copy code"
192
+ }, [element("span", {
193
+ dataCopyIcon: "",
194
+ ariaHidden: "true"
195
+ }, [])])]);
196
+ }
197
+ function element(tagName, properties, children) {
198
+ return {
199
+ type: "element",
200
+ tagName,
201
+ properties,
202
+ children
203
+ };
204
+ }
205
+ //#endregion
147
206
  //#region src/theme/defaults.ts
148
207
  const DEFAULT_THEME_TOKENS = {
149
208
  $gap: "0.5rem",
@@ -868,6 +927,7 @@ const cookbookStates = {
868
927
  "@mobile": "@media(w < 50rem)",
869
928
  "@desktop": "@media(w >= 50rem)",
870
929
  "@small": "@media(w <= 40rem)",
930
+ "@compact": "@media(w <= 23rem)",
871
931
  "@shell-mobile": "@media(w <= 48rem)",
872
932
  "@shell-desktop": "@media(w > 48rem)",
873
933
  "@narrow-layout": "@media(w < 72rem)",
@@ -875,6 +935,212 @@ const cookbookStates = {
875
935
  "@reduced-motion": "@media(prefers-reduced-motion: reduce)"
876
936
  };
877
937
  //#endregion
938
+ //#region src/site-icons.ts
939
+ const ICON_DIRECTORY = "_cookbook/icons";
940
+ const DEFAULT_ICON_BACKGROUND = "#315efb";
941
+ const SUPPORTED_SOURCE_FORMATS = /* @__PURE__ */ new Set([
942
+ "avif",
943
+ "gif",
944
+ "heif",
945
+ "jpeg",
946
+ "jpg",
947
+ "png",
948
+ "svg",
949
+ "webp"
950
+ ]);
951
+ async function createSiteIcons({ base, root, site = {}, themeColors }) {
952
+ const configured = site.favicon;
953
+ const source = typeof configured === "string" ? configured : configured?.source;
954
+ const sourcePath = source ? resolveSourcePath(source, root) : fileURLToPath(new URL("./icons/favicon.svg", import.meta.url));
955
+ const background = typeof configured === "object" && configured?.background ? configured.background : DEFAULT_ICON_BACKGROUND;
956
+ const input = await readFile(sourcePath).catch((error) => {
957
+ throw new Error(`Unable to read site.favicon source at ${sourcePath}: ${errorMessage$1(error)}`);
958
+ });
959
+ const format = (await sharp(input, { animated: false }).metadata()).format?.toLowerCase();
960
+ if (!format || !SUPPORTED_SOURCE_FORMATS.has(format)) throw new Error("site.favicon must point to an SVG, PNG, JPEG, WebP, AVIF, or GIF image.");
961
+ const definitions = [
962
+ await pngAsset(input, base, "favicon-32x32.png", 32),
963
+ await safePngAsset(input, base, "apple-touch-icon.png", 180, background),
964
+ await pngAsset(input, base, "icon-192x192.png", 192),
965
+ await pngAsset(input, base, "icon-512x512.png", 512),
966
+ await safePngAsset(input, base, "icon-192x192-maskable.png", 192, background),
967
+ await safePngAsset(input, base, "icon-512x512-maskable.png", 512, background)
968
+ ];
969
+ const scalable = format === "svg" ? asset(base, "favicon.svg", input, "image/svg+xml") : void 0;
970
+ const faviconPath = `/${ICON_DIRECTORY}/${scalable ? "favicon.svg" : "favicon-32x32.png"}`;
971
+ const iconEntries = [
972
+ {
973
+ src: pathWithBase(base, `/${ICON_DIRECTORY}/icon-192x192.png`),
974
+ sizes: "192x192",
975
+ type: "image/png",
976
+ purpose: "any"
977
+ },
978
+ {
979
+ src: pathWithBase(base, `/${ICON_DIRECTORY}/icon-512x512.png`),
980
+ sizes: "512x512",
981
+ type: "image/png",
982
+ purpose: "any"
983
+ },
984
+ {
985
+ src: pathWithBase(base, `/${ICON_DIRECTORY}/icon-192x192-maskable.png`),
986
+ sizes: "192x192",
987
+ type: "image/png",
988
+ purpose: "maskable"
989
+ },
990
+ {
991
+ src: pathWithBase(base, `/${ICON_DIRECTORY}/icon-512x512-maskable.png`),
992
+ sizes: "512x512",
993
+ type: "image/png",
994
+ purpose: "maskable"
995
+ },
996
+ ...scalable ? [{
997
+ src: scalable.publicPath,
998
+ sizes: "any",
999
+ type: "image/svg+xml",
1000
+ purpose: "any"
1001
+ }] : []
1002
+ ];
1003
+ const manifest = asset(base, "site.webmanifest", Buffer.from(`${JSON.stringify({
1004
+ name: site.title ?? "Documentation",
1005
+ short_name: site.title ?? "Documentation",
1006
+ ...site.description ? { description: site.description } : {},
1007
+ id: normalizedBase(base),
1008
+ start_url: normalizedBase(base),
1009
+ scope: normalizedBase(base),
1010
+ display: "standalone",
1011
+ background_color: background,
1012
+ theme_color: themeColors.light,
1013
+ icons: iconEntries
1014
+ }, null, 2)}\n`), "application/manifest+json");
1015
+ const favicon32 = definitions[0];
1016
+ const appleTouchIcon = definitions[1];
1017
+ if (!favicon32 || !appleTouchIcon) throw new Error("Cookbook failed to generate the required site icons.");
1018
+ return {
1019
+ assets: [
1020
+ ...definitions,
1021
+ ...scalable ? [scalable] : [],
1022
+ manifest
1023
+ ],
1024
+ faviconPath,
1025
+ head: [
1026
+ {
1027
+ tag: "link",
1028
+ attrs: {
1029
+ rel: "icon",
1030
+ href: favicon32.publicPath,
1031
+ sizes: "32x32",
1032
+ type: favicon32.contentType
1033
+ }
1034
+ },
1035
+ ...scalable ? [{
1036
+ tag: "link",
1037
+ attrs: {
1038
+ rel: "icon",
1039
+ href: scalable.publicPath,
1040
+ sizes: "any",
1041
+ type: scalable.contentType
1042
+ }
1043
+ }] : [],
1044
+ {
1045
+ tag: "link",
1046
+ attrs: {
1047
+ rel: "apple-touch-icon",
1048
+ href: appleTouchIcon.publicPath,
1049
+ sizes: "180x180"
1050
+ }
1051
+ },
1052
+ {
1053
+ tag: "link",
1054
+ attrs: {
1055
+ rel: "manifest",
1056
+ href: manifest.publicPath
1057
+ }
1058
+ },
1059
+ {
1060
+ tag: "meta",
1061
+ attrs: {
1062
+ name: "theme-color",
1063
+ content: themeColors.light,
1064
+ media: "(prefers-color-scheme: light)"
1065
+ }
1066
+ },
1067
+ {
1068
+ tag: "meta",
1069
+ attrs: {
1070
+ name: "theme-color",
1071
+ content: themeColors.dark,
1072
+ media: "(prefers-color-scheme: dark)"
1073
+ }
1074
+ }
1075
+ ],
1076
+ sourcePath
1077
+ };
1078
+ }
1079
+ async function pngAsset(input, base, name, size) {
1080
+ return asset(base, name, await sharp(input, {
1081
+ animated: false,
1082
+ density: 512
1083
+ }).resize(size, size, {
1084
+ fit: "contain",
1085
+ background: {
1086
+ r: 0,
1087
+ g: 0,
1088
+ b: 0,
1089
+ alpha: 0
1090
+ }
1091
+ }).png().toBuffer(), "image/png");
1092
+ }
1093
+ async function safePngAsset(input, base, name, size, background) {
1094
+ const safeSize = Math.round(size * .8);
1095
+ const foreground = await sharp(input, {
1096
+ animated: false,
1097
+ density: 512
1098
+ }).resize(safeSize, safeSize, {
1099
+ fit: "contain",
1100
+ background: {
1101
+ r: 0,
1102
+ g: 0,
1103
+ b: 0,
1104
+ alpha: 0
1105
+ }
1106
+ }).png().toBuffer();
1107
+ return asset(base, name, await sharp({ create: {
1108
+ width: size,
1109
+ height: size,
1110
+ channels: 4,
1111
+ background
1112
+ } }).composite([{
1113
+ input: foreground,
1114
+ gravity: "center"
1115
+ }]).png().toBuffer().catch((error) => {
1116
+ throw new Error(`Unable to use site.favicon.background ${JSON.stringify(background)}: ${errorMessage$1(error)}`);
1117
+ }), "image/png");
1118
+ }
1119
+ function asset(base, name, body, contentType) {
1120
+ const outputPath = `${ICON_DIRECTORY}/${name}`;
1121
+ return {
1122
+ body,
1123
+ contentType,
1124
+ outputPath,
1125
+ publicPath: pathWithBase(base, `/${outputPath}`)
1126
+ };
1127
+ }
1128
+ function resolveSourcePath(source, root) {
1129
+ if (/^[a-z][a-z\d+.-]*:/i.test(source)) throw new Error("site.favicon must reference a local image path.");
1130
+ return isAbsolute(source) ? source : resolve(root, source);
1131
+ }
1132
+ function pathWithBase(base, pathname) {
1133
+ const prefix = normalizedBase(base);
1134
+ return `${prefix === "/" ? "" : prefix.replace(/\/$/, "")}${pathname}`;
1135
+ }
1136
+ function normalizedBase(base) {
1137
+ const value = base.replace(/^\/+|\/+$/g, "");
1138
+ return value ? `/${value}/` : "/";
1139
+ }
1140
+ function errorMessage$1(error) {
1141
+ return error instanceof Error ? error.message : String(error);
1142
+ }
1143
+ //#endregion
878
1144
  //#region src/integration.ts
879
1145
  const packageRequire = createRequire(import.meta.url);
880
1146
  const starlightRoot = dirname(packageRequire.resolve("@astrojs/starlight"));
@@ -892,12 +1158,16 @@ function cookbook(options = {}) {
892
1158
  const headerPath = fileURLToPath(new URL("./overrides/Header.astro", import.meta.url));
893
1159
  const footerPath = fileURLToPath(new URL("./overrides/Footer.astro", import.meta.url));
894
1160
  const emptyFooterPath = fileURLToPath(new URL("./overrides/EmptyFooter.astro", import.meta.url));
1161
+ const sidebarPath = fileURLToPath(new URL("./overrides/Sidebar.astro", import.meta.url));
1162
+ const mobileMenuFooterPath = fileURLToPath(new URL("./overrides/MobileMenuFooter.astro", import.meta.url));
1163
+ const mobileMenuTogglePath = fileURLToPath(new URL("./overrides/MobileMenuToggle.astro", import.meta.url));
895
1164
  const components = resolveComponentOverrides({
896
1165
  Footer: footerPath,
897
1166
  Header: headerPath,
898
- Sidebar: fileURLToPath(new URL("./overrides/Sidebar.astro", import.meta.url)),
899
- MobileMenuFooter: fileURLToPath(new URL("./overrides/MobileMenuFooter.astro", import.meta.url)),
900
- MobileMenuToggle: fileURLToPath(new URL("./overrides/MobileMenuToggle.astro", import.meta.url)),
1167
+ MarkdownContent: fileURLToPath(new URL("./overrides/MarkdownContent.astro", import.meta.url)),
1168
+ Sidebar: sidebarPath,
1169
+ MobileMenuFooter: mobileMenuFooterPath,
1170
+ MobileMenuToggle: mobileMenuTogglePath,
901
1171
  ThemeSelect: fileURLToPath(new URL("./overrides/ThemeSelect.astro", import.meta.url))
902
1172
  }, options.config?.components?.overrides, emptyFooterPath);
903
1173
  const navigation = resolveNavigationLayout(options.config?.navigation);
@@ -910,6 +1180,8 @@ function cookbook(options = {}) {
910
1180
  let graphConfig = options.config;
911
1181
  let graph;
912
1182
  let usingContentCollection = false;
1183
+ let siteIconBase = options.config?.build?.base ?? "/";
1184
+ let siteIcons;
913
1185
  async function loadGraph(refresh = false) {
914
1186
  if (!graph || refresh) {
915
1187
  graph = await createDocsGraph({
@@ -920,6 +1192,18 @@ function cookbook(options = {}) {
920
1192
  }
921
1193
  return graph;
922
1194
  }
1195
+ async function loadSiteIcons() {
1196
+ if (!projectRoot) throw new Error("Cookbook cannot generate site icons without a project root.");
1197
+ return createSiteIcons({
1198
+ base: siteIconBase,
1199
+ root: projectRoot,
1200
+ ...options.config?.site ? { site: options.config.site } : {},
1201
+ themeColors: {
1202
+ light: docsTheme.colors.surface.light ?? "#ffffff",
1203
+ dark: docsTheme.colors.surface.dark ?? "#20232a"
1204
+ }
1205
+ });
1206
+ }
923
1207
  return {
924
1208
  name: "cookbook",
925
1209
  hooks: {
@@ -928,6 +1212,8 @@ function cookbook(options = {}) {
928
1212
  if (context.config.integrations.some((integration) => integration.name === "@astrojs/starlight")) throw new Error("Cookbook already includes Starlight. Remove the direct @astrojs/starlight integration before continuing.");
929
1213
  projectRoot ??= fileURLToPath(context.config.root);
930
1214
  const base = options.config?.build?.base ?? context.config.base;
1215
+ siteIconBase = base;
1216
+ siteIcons = await loadSiteIcons();
931
1217
  graphConfig = {
932
1218
  ...options.config,
933
1219
  build: {
@@ -940,7 +1226,12 @@ function cookbook(options = {}) {
940
1226
  const starlightIntegration = starlight({
941
1227
  title: options.config?.site?.title ?? "Documentation",
942
1228
  expressiveCode: false,
943
- ...options.config?.head ? { head: options.config.head } : {},
1229
+ favicon: siteIcons.faviconPath,
1230
+ head: [...siteIcons.head, ...options.config?.head ?? []],
1231
+ ...options.config?.editLink ? { editLink: options.config.editLink } : {},
1232
+ ...options.config?.lastUpdated !== void 0 ? { lastUpdated: options.config.lastUpdated } : {},
1233
+ ...options.config?.locales ? { locales: options.config.locales } : {},
1234
+ ...options.config?.defaultLocale ? { defaultLocale: options.config.defaultLocale } : {},
944
1235
  ...options.config?.site?.description ? { description: options.config.site.description } : {},
945
1236
  ...options.config?.search?.enabled === false ? { pagefind: false } : {},
946
1237
  ...!usingContentCollection ? { disable404Route: true } : {},
@@ -1072,14 +1363,35 @@ function cookbook(options = {}) {
1072
1363
  "astro:config:done": async (context) => {
1073
1364
  await callInner(inner, "astro:config:done", context);
1074
1365
  },
1075
- "astro:server:setup": async ({ server }) => {
1366
+ "astro:server:setup": async ({ server, logger }) => {
1076
1367
  let assets = docsAssetMap(await loadGraph());
1368
+ if (options.config?.site?.favicon && siteIcons) {
1369
+ server.watcher.add(siteIcons.sourcePath);
1370
+ server.watcher.on("change", async (changedPath) => {
1371
+ if (changedPath !== siteIcons?.sourcePath) return;
1372
+ try {
1373
+ siteIcons = await loadSiteIcons();
1374
+ server.ws.send({ type: "full-reload" });
1375
+ } catch (error) {
1376
+ logger.error(errorMessage(error));
1377
+ }
1378
+ });
1379
+ }
1077
1380
  server.middlewares.use(async (request, response, next) => {
1078
1381
  if (request.method !== "GET" && request.method !== "HEAD") {
1079
1382
  next();
1080
1383
  return;
1081
1384
  }
1082
1385
  const pathname = requestPath(request.url);
1386
+ const siteIcon = siteIcons?.assets.find((asset) => asset.publicPath === pathname);
1387
+ if (siteIcon) {
1388
+ response.statusCode = 200;
1389
+ response.setHeader("Content-Type", siteIcon.contentType);
1390
+ response.setHeader("Content-Length", siteIcon.body.byteLength);
1391
+ response.setHeader("Cache-Control", "no-cache");
1392
+ response.end(request.method === "HEAD" ? void 0 : siteIcon.body);
1393
+ return;
1394
+ }
1083
1395
  if (!pathname.includes("/_tasty-assets/")) {
1084
1396
  next();
1085
1397
  return;
@@ -1111,6 +1423,7 @@ function cookbook(options = {}) {
1111
1423
  });
1112
1424
  },
1113
1425
  "astro:build:start": async (context) => {
1426
+ siteIcons = await loadSiteIcons();
1114
1427
  await loadGraph();
1115
1428
  await callInner(inner, "astro:build:start", context);
1116
1429
  },
@@ -1128,6 +1441,11 @@ function cookbook(options = {}) {
1128
1441
  if (existsSync(pagefindOutput)) {
1129
1442
  for (const name of await readdir(pagefindOutput)) if (extname(name) === ".css") await unlink(join(pagefindOutput, name));
1130
1443
  }
1444
+ for (const asset of siteIcons?.assets ?? []) {
1445
+ const target = join(output, asset.outputPath);
1446
+ await mkdir(dirname(target), { recursive: true });
1447
+ await writeFile(target, asset.body);
1448
+ }
1131
1449
  if (!graph) return;
1132
1450
  for (const asset of graph.assets) {
1133
1451
  if (!asset.sourcePath || !asset.publicPath) continue;
@@ -1145,12 +1463,14 @@ function registerCookbookMarkdownPlugins(processor) {
1145
1463
  const plugins = Array.isArray(options.rehypePlugins) ? options.rehypePlugins : [];
1146
1464
  if (!plugins.includes(rehypeMermaid)) plugins.push(rehypeMermaid);
1147
1465
  if (!plugins.includes(rehypeTableScroll)) plugins.push(rehypeTableScroll);
1466
+ if (!plugins.includes(rehypePageAffordances)) plugins.push(rehypePageAffordances);
1148
1467
  options.rehypePlugins = plugins;
1149
1468
  } else if (processor.name === "satteri") {
1150
1469
  const options = processor.options;
1151
1470
  const plugins = Array.isArray(options.hastPlugins) ? options.hastPlugins : [];
1152
1471
  if (!plugins.includes(satteriMermaid)) plugins.push(satteriMermaid);
1153
1472
  if (!plugins.includes(satteriTableScroll)) plugins.push(satteriTableScroll);
1473
+ if (!plugins.includes(satteriPageAffordances)) plugins.push(satteriPageAffordances);
1154
1474
  options.hastPlugins = plugins;
1155
1475
  }
1156
1476
  }
@@ -1219,6 +1539,9 @@ function assetContentType(pathname) {
1219
1539
  default: return "application/octet-stream";
1220
1540
  }
1221
1541
  }
1542
+ function errorMessage(error) {
1543
+ return error instanceof Error ? error.message : String(error);
1544
+ }
1222
1545
  function configureTastyTheme(theme, resolved) {
1223
1546
  const tokens = tastyTokens(resolved);
1224
1547
  const globalStyles = resolveLegacyAnatomyStyles(theme?.styles);