@thjodann/wk 1.0.3 → 1.0.4

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/core/site.js CHANGED
@@ -187,7 +187,7 @@ function buildSiteFiles(root, options = {}) {
187
187
  })
188
188
  });
189
189
  }
190
- files.push({ fileName: "favicon.svg", content: faviconSvg(model) }, { fileName: "assets/wikiwiki.css", content: siteCss() }, { fileName: "assets/project-theme.css", content: projectThemeCss(model.options.theme) }, { fileName: "assets/search-index.js", content: searchIndexJs(model) }, { fileName: "assets/wikiwiki.js", content: siteJs() }, { fileName: ".nojekyll", content: "" }, { fileName: "site-manifest.json", content: `${JSON.stringify(siteManifest(model), null, 2)}\n` });
190
+ files.push({ fileName: "favicon.svg", content: faviconSvg(model) }, { fileName: "assets/wikiwiki.css", content: siteCss() }, { fileName: "assets/project-theme.css", content: projectThemeCss(model.options.theme, model.assets.fonts) }, { fileName: "assets/search-index.js", content: searchIndexJs(model) }, { fileName: "assets/wikiwiki.js", content: siteJs() }, { fileName: ".nojekyll", content: "" }, { fileName: "site-manifest.json", content: `${JSON.stringify(siteManifest(model), null, 2)}\n` }, ...model.assets.files);
191
191
  return files;
192
192
  }
193
193
  function renderSite(root, options = {}) {
@@ -198,7 +198,12 @@ function renderSite(root, options = {}) {
198
198
  return files.map((file) => {
199
199
  const outputFile = path_1.default.join(outputPath, file.fileName);
200
200
  fs_1.default.mkdirSync(path_1.default.dirname(outputFile), { recursive: true });
201
- fs_1.default.writeFileSync(outputFile, file.content, "utf8");
201
+ if (typeof file.content === "string") {
202
+ fs_1.default.writeFileSync(outputFile, file.content, "utf8");
203
+ }
204
+ else {
205
+ fs_1.default.writeFileSync(outputFile, file.content);
206
+ }
202
207
  return outputFile;
203
208
  });
204
209
  }
@@ -223,12 +228,14 @@ function createSiteModel(root, records, options) {
223
228
  const articleHrefByLookup = articleLookupMap(audienceRecords.article);
224
229
  const counts = Object.fromEntries(schemas_1.recordTypes.map((type) => [type, audienceRecords[type].length]));
225
230
  const repoName = options.theme.project_name?.trim() || path_1.default.basename(root);
231
+ const assets = resolveSiteAssets(root, options.theme);
226
232
  return {
227
233
  root,
228
234
  repoName,
229
235
  siteTitle: `${repoName} Wiki`,
230
236
  siteDescription: options.theme.project_description?.trim() || "A project wiki generated from durable repo knowledge.",
231
237
  projectInitials: initialsForName(repoName),
238
+ assets,
232
239
  records: audienceRecords,
233
240
  flatRecords,
234
241
  sourceRecords,
@@ -263,6 +270,10 @@ function renderLayout(options) {
263
270
  : `${options.title} - ${options.model.siteTitle}`;
264
271
  const defaultColorScheme = defaultThemeColorScheme(options.model.options.theme);
265
272
  const defaultThemeAttribute = defaultColorScheme === "auto" ? "" : ` data-theme="${defaultColorScheme}"`;
273
+ const homeBrandAsset = options.active === "home" ? homeBrandAssetHtml(options.model, options.fileName) : "";
274
+ const homeBrandAssetMarkup = homeBrandAsset ? ` ${homeBrandAsset}\n` : "";
275
+ const homeHasWordmark = options.active === "home" && options.model.assets.wordmark !== undefined;
276
+ const h1Class = homeHasWordmark ? ` class="visually-hidden"` : "";
266
277
  return `${exports.siteGeneratedNotice}
267
278
  <!doctype html>
268
279
  <html lang="en" data-default-theme="${defaultColorScheme}"${defaultThemeAttribute}>
@@ -272,7 +283,7 @@ function renderLayout(options) {
272
283
  <meta name="generator" content="Wikiwiki">
273
284
  <title>${escapeHtml(htmlTitle)}</title>
274
285
  <script>${themeBootScript(defaultColorScheme)}</script>
275
- <link rel="icon" href="${prefix}favicon.svg" type="image/svg+xml">
286
+ ${faviconLink(options.model, options.fileName, prefix)}
276
287
  <link rel="stylesheet" href="${prefix}assets/wikiwiki.css">
277
288
  <link rel="stylesheet" href="${prefix}assets/project-theme.css">
278
289
  <script src="${prefix}assets/search-index.js" defer></script>
@@ -284,7 +295,7 @@ function renderLayout(options) {
284
295
  <aside class="sidebar" aria-label="Wiki navigation">
285
296
  <div class="sidebar-top">
286
297
  <a class="brand" href="${hrefFrom(options.fileName, "index.html")}" aria-label="Wiki home">
287
- <span class="brand-mark">${escapeHtml(options.model.projectInitials)}</span>
298
+ ${brandMarkHtml(options.model, options.fileName)}
288
299
  <span>
289
300
  <strong>${escapeHtml(options.model.repoName)}</strong>
290
301
  <small>Project wiki</small>
@@ -302,8 +313,8 @@ function renderLayout(options) {
302
313
  </div>
303
314
  </aside>
304
315
  <main id="content" class="content">
305
- <header class="page-header">
306
- <h1>${escapeHtml(options.title)}</h1>
316
+ <header class="page-header${homeBrandAsset ? " has-brand-asset" : ""}">
317
+ ${homeBrandAssetMarkup} <h1${h1Class}>${escapeHtml(options.title)}</h1>
307
318
  <p>${escapeHtml(options.description)}</p>
308
319
  </header>
309
320
  ${options.body}
@@ -324,6 +335,41 @@ function themeChoiceButton(mode, label, title, defaultColorScheme) {
324
335
  const pressed = mode === defaultColorScheme ? "true" : "false";
325
336
  return `<button type="button" data-theme-choice="${mode}" aria-pressed="${pressed}" title="${escapeAttribute(title)}">${escapeHtml(label)}</button>`;
326
337
  }
338
+ function faviconLink(model, currentFile, fallbackPrefix) {
339
+ const asset = model.assets.favicon;
340
+ if (!asset) {
341
+ return `<link rel="icon" href="${fallbackPrefix}favicon.svg" type="image/svg+xml">`;
342
+ }
343
+ const type = asset.mimeType ? ` type="${escapeAttribute(asset.mimeType)}"` : "";
344
+ const darkHref = hrefFrom(currentFile, asset.outputFileName);
345
+ const lightHref = model.assets.faviconLight ? hrefFrom(currentFile, model.assets.faviconLight.outputFileName) : darkHref;
346
+ const lightType = model.assets.faviconLight?.mimeType ?? asset.mimeType;
347
+ const faviconData = [
348
+ `data-wikiwiki-favicon`,
349
+ `data-favicon-dark="${darkHref}"`,
350
+ `data-favicon-light="${lightHref}"`,
351
+ ...(asset.mimeType ? [`data-favicon-dark-type="${escapeAttribute(asset.mimeType)}"`] : []),
352
+ ...(lightType ? [`data-favicon-light-type="${escapeAttribute(lightType)}"`] : [])
353
+ ].join(" ");
354
+ return `<link rel="icon" href="${darkHref}"${type} ${faviconData}>`;
355
+ }
356
+ function brandMarkHtml(model, currentFile) {
357
+ const asset = model.assets.sidebarMark;
358
+ if (!asset) {
359
+ return `<span class="brand-mark">${escapeHtml(model.projectInitials)}</span>`;
360
+ }
361
+ return `<span class="brand-mark has-image"><img src="${hrefFrom(currentFile, asset.outputFileName)}" alt="" aria-hidden="true"></span>`;
362
+ }
363
+ function homeBrandAssetHtml(model, currentFile) {
364
+ const asset = model.assets.wordmark ?? model.assets.logo;
365
+ if (!asset) {
366
+ return "";
367
+ }
368
+ const label = asset.kind === "wordmark" ? "wordmark" : "logo";
369
+ return `<div class="home-brand-asset ${asset.kind}">
370
+ <img src="${hrefFrom(currentFile, asset.outputFileName)}" alt="${escapeAttribute(`${model.repoName} ${label}`)}">
371
+ </div>`;
372
+ }
327
373
  function renderNav(model, currentFile, active) {
328
374
  const categoryLinks = model.visibleCategories
329
375
  .map((category) => navLink(currentFile, category.fileName, category.navLabel, active === category.type, model.counts[category.type]))
@@ -822,22 +868,94 @@ function recordCard(record, currentFile, _model) {
822
868
  </article>`;
823
869
  }
824
870
  function formatText(text, context) {
825
- const trimmed = text.trim();
871
+ const trimmed = text.replace(/\r\n/g, "\n").trim();
826
872
  if (!trimmed) {
827
873
  return "<p>Not recorded.</p>";
828
874
  }
829
- const blocks = trimmed.split(/\n{2,}/);
830
- return blocks.map((block) => formatBlock(block, context)).join("\n");
875
+ return formatMarkdownBlocks(trimmed.split("\n"), context);
831
876
  }
832
- function formatBlock(block, context) {
833
- const lines = block.split(/\r?\n/);
834
- if (lines.every((line) => line.trim().startsWith("- "))) {
835
- const items = lines
836
- .map((line) => `<li>${formatInline(line.trim().slice(2), context)}</li>`)
837
- .join("\n");
838
- return `<ul>${items}</ul>`;
877
+ function formatMarkdownBlocks(lines, context) {
878
+ const blocks = [];
879
+ let index = 0;
880
+ while (index < lines.length) {
881
+ const line = lines[index] ?? "";
882
+ const trimmed = line.trim();
883
+ if (!trimmed) {
884
+ index += 1;
885
+ continue;
886
+ }
887
+ const fence = /^```([A-Za-z0-9_-]+)?\s*$/.exec(trimmed);
888
+ if (fence) {
889
+ const codeLines = [];
890
+ index += 1;
891
+ while (index < lines.length && !/^```\s*$/.test((lines[index] ?? "").trim())) {
892
+ codeLines.push(lines[index] ?? "");
893
+ index += 1;
894
+ }
895
+ if (index < lines.length) {
896
+ index += 1;
897
+ }
898
+ const languageClass = fence[1] ? ` class="language-${escapeAttribute(fence[1])}"` : "";
899
+ blocks.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
900
+ continue;
901
+ }
902
+ const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(trimmed);
903
+ if (heading) {
904
+ const level = heading[1].length;
905
+ blocks.push(`<h${level}>${formatInline(heading[2], context)}</h${level}>`);
906
+ index += 1;
907
+ continue;
908
+ }
909
+ if (/^>\s?/.test(trimmed)) {
910
+ const quoteLines = [];
911
+ while (index < lines.length && /^>\s?/.test((lines[index] ?? "").trim())) {
912
+ quoteLines.push((lines[index] ?? "").trim().replace(/^>\s?/, ""));
913
+ index += 1;
914
+ }
915
+ blocks.push(`<blockquote>${formatMarkdownBlocks(quoteLines, context)}</blockquote>`);
916
+ continue;
917
+ }
918
+ if (/^[-*+]\s+/.test(trimmed)) {
919
+ const items = [];
920
+ while (index < lines.length && /^[-*+]\s+/.test((lines[index] ?? "").trim())) {
921
+ items.push(`<li>${formatInline((lines[index] ?? "").trim().replace(/^[-*+]\s+/, ""), context)}</li>`);
922
+ index += 1;
923
+ }
924
+ blocks.push(`<ul>${items.join("\n")}</ul>`);
925
+ continue;
926
+ }
927
+ if (/^\d+[.)]\s+/.test(trimmed)) {
928
+ const items = [];
929
+ while (index < lines.length && /^\d+[.)]\s+/.test((lines[index] ?? "").trim())) {
930
+ items.push(`<li>${formatInline((lines[index] ?? "").trim().replace(/^\d+[.)]\s+/, ""), context)}</li>`);
931
+ index += 1;
932
+ }
933
+ blocks.push(`<ol>${items.join("\n")}</ol>`);
934
+ continue;
935
+ }
936
+ const paragraphLines = [];
937
+ while (index < lines.length) {
938
+ const paragraphLine = lines[index] ?? "";
939
+ const paragraphTrimmed = paragraphLine.trim();
940
+ if (!paragraphTrimmed) {
941
+ break;
942
+ }
943
+ if (paragraphLines.length > 0 && isMarkdownBlockStart(paragraphTrimmed)) {
944
+ break;
945
+ }
946
+ paragraphLines.push(paragraphTrimmed);
947
+ index += 1;
948
+ }
949
+ blocks.push(`<p>${formatInline(paragraphLines.join(" "), context)}</p>`);
839
950
  }
840
- return `<p>${formatInline(lines.join(" "), context)}</p>`;
951
+ return blocks.join("\n");
952
+ }
953
+ function isMarkdownBlockStart(trimmedLine) {
954
+ return (/^```/.test(trimmedLine) ||
955
+ /^(#{1,6})\s+/.test(trimmedLine) ||
956
+ /^>\s?/.test(trimmedLine) ||
957
+ /^[-*+]\s+/.test(trimmedLine) ||
958
+ /^\d+[.)]\s+/.test(trimmedLine));
841
959
  }
842
960
  function formatInline(text, context) {
843
961
  const parts = [];
@@ -855,7 +973,8 @@ function formatInline(text, context) {
855
973
  if (linkMatch) {
856
974
  const label = linkMatch[1];
857
975
  const target = linkMatch[2];
858
- parts.push(`<a href="${escapeAttribute(normalizeContentHref(target, context))}">${escapeHtml(label)}</a>`);
976
+ const href = normalizeContentHref(target, context);
977
+ parts.push(href ? `<a href="${escapeAttribute(href)}">${escapeHtml(label)}</a>` : escapeHtml(label));
859
978
  }
860
979
  }
861
980
  lastIndex = match.index + token.length;
@@ -891,9 +1010,13 @@ function sourceFileLink(file, context) {
891
1010
  }
892
1011
  function normalizeContentHref(target, context) {
893
1012
  const trimmed = target.trim();
894
- if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("#")) {
1013
+ if (trimmed.startsWith("#")) {
895
1014
  return trimmed;
896
1015
  }
1016
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed)?.[1].toLowerCase();
1017
+ if (scheme) {
1018
+ return ["http", "https", "mailto"].includes(scheme) ? trimmed : undefined;
1019
+ }
897
1020
  const recordHrefForTarget = context.hrefById.get(trimmed);
898
1021
  if (recordHrefForTarget) {
899
1022
  return hrefFrom(context.currentFile, recordHrefForTarget);
@@ -978,6 +1101,147 @@ function isDirectoryPath(root, file) {
978
1101
  return false;
979
1102
  }
980
1103
  }
1104
+ function resolveSiteAssets(root, theme) {
1105
+ const usedOutputFileNames = new Set();
1106
+ const files = [];
1107
+ const logo = resolveImageAsset(root, "logo", theme.logo_path, usedOutputFileNames, files);
1108
+ const wordmark = resolveImageAsset(root, "wordmark", theme.wordmark_path, usedOutputFileNames, files);
1109
+ const favicon = resolveImageAsset(root, "favicon", theme.favicon_path, usedOutputFileNames, files);
1110
+ const faviconLight = favicon ? resolveLightFaviconAsset(favicon, usedOutputFileNames, files) : undefined;
1111
+ const fonts = resolveFontAssets(root, theme.fonts ?? [], usedOutputFileNames, files);
1112
+ return {
1113
+ ...(logo ? { logo } : {}),
1114
+ ...(wordmark ? { wordmark } : {}),
1115
+ ...(favicon ? { favicon } : {}),
1116
+ ...(faviconLight ? { faviconLight } : {}),
1117
+ sidebarMark: favicon ?? logo,
1118
+ fonts,
1119
+ files
1120
+ };
1121
+ }
1122
+ function resolveLightFaviconAsset(favicon, usedOutputFileNames, files) {
1123
+ const mimeType = favicon.mimeType;
1124
+ if (!mimeType?.startsWith("image/")) {
1125
+ return undefined;
1126
+ }
1127
+ const source = fs_1.default.readFileSync(favicon.sourcePath);
1128
+ const outputFileName = uniqueOutputFileName("assets/favicon-light.svg", usedOutputFileNames);
1129
+ files.push({
1130
+ fileName: outputFileName,
1131
+ content: lightModeFaviconSvg(`data:${mimeType};base64,${source.toString("base64")}`)
1132
+ });
1133
+ return {
1134
+ kind: "favicon",
1135
+ sourcePath: favicon.sourcePath,
1136
+ outputFileName,
1137
+ mimeType: "image/svg+xml"
1138
+ };
1139
+ }
1140
+ function resolveImageAsset(root, kind, configuredPath, usedOutputFileNames, files) {
1141
+ const sourcePath = resolveThemeAssetPath(root, configuredPath);
1142
+ if (!sourcePath) {
1143
+ return undefined;
1144
+ }
1145
+ const extension = assetExtension(sourcePath);
1146
+ if (!extension || !["svg", "png", "jpg", "jpeg", "webp", "ico"].includes(extension)) {
1147
+ return undefined;
1148
+ }
1149
+ const outputFileName = uniqueOutputFileName(`assets/${kind}.${extension}`, usedOutputFileNames);
1150
+ files.push({ fileName: outputFileName, content: fs_1.default.readFileSync(sourcePath) });
1151
+ return {
1152
+ kind,
1153
+ sourcePath,
1154
+ outputFileName,
1155
+ mimeType: mimeTypeForExtension(extension)
1156
+ };
1157
+ }
1158
+ function resolveFontAssets(root, fonts, usedOutputFileNames, files) {
1159
+ const resolved = [];
1160
+ for (const font of fonts) {
1161
+ const sourcePath = resolveThemeAssetPath(root, font.path);
1162
+ if (!sourcePath || !font.family.trim()) {
1163
+ continue;
1164
+ }
1165
+ const extension = assetExtension(sourcePath);
1166
+ if (!extension || !["woff2", "woff", "otf", "ttf"].includes(extension)) {
1167
+ continue;
1168
+ }
1169
+ const weight = font.weight?.trim() || "400";
1170
+ const style = font.style?.trim() || "normal";
1171
+ const baseName = safeFileName(`${font.family}-${weight}-${style}`).toLowerCase();
1172
+ const outputFileName = uniqueOutputFileName(`assets/fonts/${baseName}.${extension}`, usedOutputFileNames);
1173
+ files.push({ fileName: outputFileName, content: fs_1.default.readFileSync(sourcePath) });
1174
+ resolved.push({
1175
+ ...font,
1176
+ weight,
1177
+ style,
1178
+ display: font.display?.trim() || "swap",
1179
+ sourcePath,
1180
+ outputFileName,
1181
+ cssUrl: `./fonts/${path_1.default.posix.basename(outputFileName)}`,
1182
+ format: fontFormatForExtension(extension)
1183
+ });
1184
+ }
1185
+ return resolved;
1186
+ }
1187
+ function resolveThemeAssetPath(root, configuredPath) {
1188
+ const normalized = toPosixPath(configuredPath?.trim() ?? "").replace(/^\.\//, "");
1189
+ if (!normalized || path_1.default.isAbsolute(normalized) || normalized.startsWith("../") || normalized.includes("://") || normalized.startsWith("#")) {
1190
+ return undefined;
1191
+ }
1192
+ const rootPath = path_1.default.resolve(root);
1193
+ const fullPath = path_1.default.resolve(rootPath, normalized);
1194
+ if (fullPath !== rootPath && !fullPath.startsWith(`${rootPath}${path_1.default.sep}`)) {
1195
+ return undefined;
1196
+ }
1197
+ try {
1198
+ return fs_1.default.statSync(fullPath).isFile() ? fullPath : undefined;
1199
+ }
1200
+ catch {
1201
+ return undefined;
1202
+ }
1203
+ }
1204
+ function assetExtension(file) {
1205
+ const extension = path_1.default.extname(file).replace(/^\./, "").toLowerCase();
1206
+ return extension || undefined;
1207
+ }
1208
+ function uniqueOutputFileName(fileName, used) {
1209
+ const normalized = toPosixPath(fileName);
1210
+ if (!used.has(normalized)) {
1211
+ used.add(normalized);
1212
+ return normalized;
1213
+ }
1214
+ const directory = path_1.default.posix.dirname(normalized);
1215
+ const extension = path_1.default.posix.extname(normalized);
1216
+ const base = path_1.default.posix.basename(normalized, extension);
1217
+ let index = 2;
1218
+ while (used.has(`${directory}/${base}-${index}${extension}`)) {
1219
+ index += 1;
1220
+ }
1221
+ const result = `${directory}/${base}-${index}${extension}`;
1222
+ used.add(result);
1223
+ return result;
1224
+ }
1225
+ function mimeTypeForExtension(extension) {
1226
+ const types = {
1227
+ svg: "image/svg+xml",
1228
+ png: "image/png",
1229
+ jpg: "image/jpeg",
1230
+ jpeg: "image/jpeg",
1231
+ webp: "image/webp",
1232
+ ico: "image/x-icon"
1233
+ };
1234
+ return types[extension];
1235
+ }
1236
+ function fontFormatForExtension(extension) {
1237
+ const formats = {
1238
+ woff2: "woff2",
1239
+ woff: "woff",
1240
+ otf: "opentype",
1241
+ ttf: "truetype"
1242
+ };
1243
+ return formats[extension];
1244
+ }
981
1245
  function searchIndexJs(model) {
982
1246
  const articleEntries = model.records.article.map((article) => ({
983
1247
  type: "article",
@@ -1047,6 +1311,7 @@ function siteManifest(model) {
1047
1311
  audience: model.audience,
1048
1312
  project_name: model.repoName,
1049
1313
  theme_file: ".wikiwiki/site-theme.json",
1314
+ ...(siteManifestAssets(model) ? { assets: siteManifestAssets(model) } : {}),
1050
1315
  pages: sitePages(model),
1051
1316
  ...(integrations ? { integrations } : {}),
1052
1317
  articles: model.records.article.map((article) => ({
@@ -1063,6 +1328,27 @@ function siteManifest(model) {
1063
1328
  }))
1064
1329
  };
1065
1330
  }
1331
+ function siteManifestAssets(model) {
1332
+ if (!model.assets.logo && !model.assets.wordmark && !model.assets.favicon && model.assets.fonts.length === 0) {
1333
+ return undefined;
1334
+ }
1335
+ return {
1336
+ ...(model.assets.logo ? { logo: model.assets.logo.outputFileName } : {}),
1337
+ ...(model.assets.wordmark ? { wordmark: model.assets.wordmark.outputFileName } : {}),
1338
+ ...(model.assets.favicon ? { favicon: model.assets.favicon.outputFileName } : {}),
1339
+ ...(model.assets.faviconLight ? { favicon_light: model.assets.faviconLight.outputFileName } : {}),
1340
+ ...(model.assets.fonts.length > 0
1341
+ ? {
1342
+ fonts: model.assets.fonts.map((font) => ({
1343
+ family: font.family,
1344
+ weight: font.weight,
1345
+ style: font.style,
1346
+ url: font.outputFileName
1347
+ }))
1348
+ }
1349
+ : {})
1350
+ };
1351
+ }
1066
1352
  function siteManifestIntegrations(model) {
1067
1353
  const beads = model.integrations.beads;
1068
1354
  if (!beads || model.audience === "user") {
@@ -1446,10 +1732,14 @@ function escapeHtml(value) {
1446
1732
  function escapeAttribute(value) {
1447
1733
  return escapeHtml(value);
1448
1734
  }
1449
- function projectThemeCss(theme) {
1735
+ function projectThemeCss(theme, fonts = []) {
1450
1736
  const resolved = resolveThemePalettes(theme);
1737
+ const fontFaces = fontFaceCss(fonts);
1451
1738
  if (!resolved.hasTheme) {
1452
- return "/* Optional project theme. Add .wikiwiki/site-theme.json to override CSS custom properties. */\n";
1739
+ const fontFamily = fonts[0]?.family
1740
+ ? `\n:root {\n --font-family: ${cssString(fonts[0].family)}, Inter, ui-sans-serif, system-ui, sans-serif;\n}\n`
1741
+ : "";
1742
+ return `/* Optional project theme. Add .wikiwiki/site-theme.json to override CSS custom properties. */\n${fontFaces}${fontFamily}`;
1453
1743
  }
1454
1744
  const light = themeWithContrastGuardrails(resolved.light, "light");
1455
1745
  const dark = themeWithContrastGuardrails(resolved.dark, "dark");
@@ -1458,6 +1748,7 @@ function projectThemeCss(theme) {
1458
1748
  ? `\n${comments.map((comment) => `/* ${comment} */`).join("\n")}`
1459
1749
  : "";
1460
1750
  return `/* Generated from .wikiwiki/site-theme.json. Edit the theme file, then run \`wk site\`. */${commentBlock}
1751
+ ${fontFaces}
1461
1752
  ${themeCssBlock(":root,\n:root[data-theme=\"light\"]", light.theme, "light")}
1462
1753
 
1463
1754
  @media (prefers-color-scheme: dark) {
@@ -1467,6 +1758,21 @@ ${themeCssBlock(":root:not([data-theme=\"light\"])", dark.theme, "dark", " ")}
1467
1758
  ${themeCssBlock(":root[data-theme=\"dark\"]", dark.theme, "dark")}
1468
1759
  `;
1469
1760
  }
1761
+ function fontFaceCss(fonts) {
1762
+ if (fonts.length === 0) {
1763
+ return "";
1764
+ }
1765
+ return `${fonts.map((font) => {
1766
+ const format = font.format ? ` format("${font.format}")` : "";
1767
+ return `@font-face {
1768
+ font-family: ${cssString(font.family)};
1769
+ src: url("${cssUrl(font.cssUrl)}")${format};
1770
+ font-weight: ${cssValue(font.weight ?? "400")};
1771
+ font-style: ${cssValue(font.style ?? "normal")};
1772
+ font-display: ${cssValue(font.display ?? "swap")};
1773
+ }`;
1774
+ }).join("\n\n")}\n\n`;
1775
+ }
1470
1776
  function resolveThemePalettes(theme) {
1471
1777
  const flat = themePaletteFrom(theme);
1472
1778
  const modeLight = theme.modes?.light ?? {};
@@ -1727,12 +2033,37 @@ function rgba(color, alpha) {
1727
2033
  function cssValue(value) {
1728
2034
  return value.replace(/[;\n\r]/g, "").trim();
1729
2035
  }
2036
+ function cssString(value) {
2037
+ return `"${value.replace(/["\\\n\r]/g, "")}"`;
2038
+ }
2039
+ function cssUrl(value) {
2040
+ return value.replace(/["\\\n\r]/g, "");
2041
+ }
1730
2042
  function faviconSvg(model) {
1731
2043
  const initials = escapeHtml(model.projectInitials.slice(0, 2));
1732
2044
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
1733
2045
  <rect width="64" height="64" rx="14" fill="#111827"/>
1734
- <circle cx="46" cy="18" r="8" fill="#38bdf8"/>
1735
- <text x="32" y="41" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="22" font-weight="700" fill="#f8fafc">${initials}</text>
2046
+ <path d="M15 17c0-2.2 1.8-4 4-4h12c2.2 0 4 1.8 4 4v32c0 1.1-.9 2-2 2H19c-2.2 0-4-1.8-4-4V17Z" fill="#f8fafc"/>
2047
+ <path d="M29 13h16c2.2 0 4 1.8 4 4v30c0 2.2-1.8 4-4 4H33c1.1 0 2-.9 2-2V17c0-2.2-1.8-4-4-4h-2Z" fill="#38bdf8"/>
2048
+ <path d="M22 23h6M22 30h6M39 23h5M39 30h5" stroke="#111827" stroke-width="2.4" stroke-linecap="round"/>
2049
+ <text x="32" y="44" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="12" font-weight="800" fill="#111827">${initials}</text>
2050
+ </svg>
2051
+ `;
2052
+ }
2053
+ function lightModeFaviconSvg(dataUri) {
2054
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
2055
+ <defs>
2056
+ <filter id="wikiwiki-light-brand-filter" color-interpolation-filters="sRGB">
2057
+ <feComponentTransfer>
2058
+ <feFuncR type="table" tableValues="1 0"/>
2059
+ <feFuncG type="table" tableValues="1 0"/>
2060
+ <feFuncB type="table" tableValues="1 0"/>
2061
+ </feComponentTransfer>
2062
+ <feColorMatrix type="hueRotate" values="180"/>
2063
+ <feColorMatrix type="saturate" values="1.14"/>
2064
+ </filter>
2065
+ </defs>
2066
+ <image href="${escapeAttribute(dataUri)}" width="512" height="512" preserveAspectRatio="xMidYMid meet" filter="url(#wikiwiki-light-brand-filter)"/>
1736
2067
  </svg>
1737
2068
  `;
1738
2069
  }
@@ -1759,6 +2090,7 @@ function siteCss() {
1759
2090
  --card-gradient: linear-gradient(180deg, #fffdf7 0%, #faf9f3 100%);
1760
2091
  --brand-gradient: linear-gradient(135deg, var(--accent), var(--accent-strong));
1761
2092
  --brand-mark-text: #fffaf0;
2093
+ --brand-asset-filter: invert(1) hue-rotate(180deg) saturate(1.14);
1762
2094
  --badge-bg: #e5f0ed;
1763
2095
  --badge-text: var(--accent-strong);
1764
2096
  --tag-bg: #f8e8f1;
@@ -1791,6 +2123,7 @@ function siteCss() {
1791
2123
  --card-gradient: linear-gradient(180deg, #1a261f 0%, #141e18 100%);
1792
2124
  --brand-gradient: linear-gradient(135deg, var(--accent), #2dd4bf);
1793
2125
  --brand-mark-text: #101914;
2126
+ --brand-asset-filter: none;
1794
2127
  --badge-bg: rgba(110, 231, 199, 0.16);
1795
2128
  --badge-text: #d1fae5;
1796
2129
  --tag-bg: rgba(240, 171, 252, 0.18);
@@ -1823,6 +2156,7 @@ function siteCss() {
1823
2156
  --card-gradient: linear-gradient(180deg, #1a261f 0%, #141e18 100%);
1824
2157
  --brand-gradient: linear-gradient(135deg, var(--accent), #2dd4bf);
1825
2158
  --brand-mark-text: #101914;
2159
+ --brand-asset-filter: none;
1826
2160
  --badge-bg: rgba(110, 231, 199, 0.16);
1827
2161
  --badge-text: #d1fae5;
1828
2162
  --tag-bg: rgba(240, 171, 252, 0.18);
@@ -1837,6 +2171,7 @@ function siteCss() {
1837
2171
 
1838
2172
  :root[data-theme="light"] {
1839
2173
  color-scheme: light;
2174
+ --brand-asset-filter: invert(1) hue-rotate(180deg) saturate(1.14);
1840
2175
  }
1841
2176
 
1842
2177
  * {
@@ -1935,6 +2270,21 @@ code {
1935
2270
  letter-spacing: 0;
1936
2271
  }
1937
2272
 
2273
+ .brand-mark.has-image {
2274
+ overflow: hidden;
2275
+ background: color-mix(in srgb, var(--panel) 86%, transparent);
2276
+ padding: 0.22rem;
2277
+ }
2278
+
2279
+ .brand-mark img {
2280
+ display: block;
2281
+ width: 100%;
2282
+ height: 100%;
2283
+ filter: var(--brand-asset-filter);
2284
+ object-fit: contain;
2285
+ transition: filter 160ms ease;
2286
+ }
2287
+
1938
2288
  .brand strong,
1939
2289
  .brand small {
1940
2290
  display: block;
@@ -2062,12 +2412,51 @@ input[type="search"]:focus {
2062
2412
  margin-bottom: 1.5rem;
2063
2413
  }
2064
2414
 
2415
+ .home-brand-asset {
2416
+ display: flex;
2417
+ align-items: center;
2418
+ min-height: 3.5rem;
2419
+ margin-bottom: 0.65rem;
2420
+ }
2421
+
2422
+ .home-brand-asset img {
2423
+ display: block;
2424
+ filter: var(--brand-asset-filter);
2425
+ max-width: min(28rem, 100%);
2426
+ max-height: 5.5rem;
2427
+ object-fit: contain;
2428
+ object-position: left center;
2429
+ transition: filter 160ms ease;
2430
+ }
2431
+
2432
+ .home-brand-asset.logo img {
2433
+ width: 4.5rem;
2434
+ height: 4.5rem;
2435
+ border: 1px solid var(--border);
2436
+ border-radius: var(--radius);
2437
+ background: color-mix(in srgb, var(--panel) 82%, transparent);
2438
+ box-shadow: var(--shadow);
2439
+ padding: 0.35rem;
2440
+ }
2441
+
2065
2442
  .page-header h1 {
2066
2443
  margin: 0.15rem 0 0.45rem;
2067
2444
  font-size: clamp(2rem, 4vw, 3.8rem);
2068
2445
  line-height: 1;
2069
2446
  }
2070
2447
 
2448
+ .visually-hidden {
2449
+ position: absolute;
2450
+ width: 1px;
2451
+ height: 1px;
2452
+ margin: -1px;
2453
+ overflow: hidden;
2454
+ clip: rect(0 0 0 0);
2455
+ white-space: nowrap;
2456
+ border: 0;
2457
+ padding: 0;
2458
+ }
2459
+
2071
2460
  .page-header p {
2072
2461
  max-width: 760px;
2073
2462
  color: var(--muted);
@@ -2322,20 +2711,77 @@ input[type="search"]:focus {
2322
2711
  padding: clamp(1rem, 3vw, 1.6rem);
2323
2712
  }
2324
2713
 
2325
- .prose h2 {
2714
+ .prose h1,
2715
+ .prose h2,
2716
+ .prose h3,
2717
+ .prose h4,
2718
+ .prose h5,
2719
+ .prose h6 {
2326
2720
  margin: 1.3rem 0 0.3rem;
2721
+ }
2722
+
2723
+ .prose h1 {
2724
+ font-size: 1.55rem;
2725
+ }
2726
+
2727
+ .prose h2 {
2327
2728
  font-size: 1.25rem;
2328
2729
  }
2329
2730
 
2330
- .prose h2:first-child {
2731
+ .prose h3 {
2732
+ font-size: 1.1rem;
2733
+ }
2734
+
2735
+ .prose h4,
2736
+ .prose h5,
2737
+ .prose h6 {
2738
+ font-size: 1rem;
2739
+ }
2740
+
2741
+ .prose h1:first-child,
2742
+ .prose h2:first-child,
2743
+ .prose h3:first-child,
2744
+ .prose h4:first-child,
2745
+ .prose h5:first-child,
2746
+ .prose h6:first-child {
2331
2747
  margin-top: 0;
2332
2748
  }
2333
2749
 
2334
2750
  .prose p,
2335
- .prose ul {
2751
+ .prose ul,
2752
+ .prose ol,
2753
+ .prose blockquote,
2754
+ .prose pre {
2336
2755
  color: var(--text);
2337
2756
  }
2338
2757
 
2758
+ .prose ul,
2759
+ .prose ol {
2760
+ padding-left: 1.45rem;
2761
+ }
2762
+
2763
+ .prose blockquote {
2764
+ margin: 1rem 0;
2765
+ border-left: 3px solid var(--accent);
2766
+ padding-left: 1rem;
2767
+ color: var(--muted);
2768
+ }
2769
+
2770
+ .prose pre {
2771
+ overflow-x: auto;
2772
+ border-radius: var(--radius);
2773
+ background: var(--code-bg);
2774
+ padding: 0.85rem;
2775
+ }
2776
+
2777
+ .prose pre code {
2778
+ display: block;
2779
+ overflow-wrap: normal;
2780
+ background: transparent;
2781
+ padding: 0;
2782
+ white-space: pre;
2783
+ }
2784
+
2339
2785
  .record-meta {
2340
2786
  margin-top: 1rem;
2341
2787
  padding: 0.75rem 1rem;
@@ -2562,6 +3008,34 @@ function siteJs() {
2562
3008
  return validThemeModes.includes(value) ? value : "auto";
2563
3009
  }
2564
3010
 
3011
+ function effectiveThemeMode(mode) {
3012
+ const normalizedMode = normalizeThemeMode(mode);
3013
+ if (normalizedMode === "light" || normalizedMode === "dark") {
3014
+ return normalizedMode;
3015
+ }
3016
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
3017
+ }
3018
+
3019
+ function updateFavicon(mode) {
3020
+ const link = document.querySelector("link[data-wikiwiki-favicon]");
3021
+ if (!link) {
3022
+ return;
3023
+ }
3024
+ const effectiveMode = effectiveThemeMode(mode);
3025
+ const href = effectiveMode === "light"
3026
+ ? link.getAttribute("data-favicon-light")
3027
+ : link.getAttribute("data-favicon-dark");
3028
+ const type = effectiveMode === "light"
3029
+ ? link.getAttribute("data-favicon-light-type")
3030
+ : link.getAttribute("data-favicon-dark-type");
3031
+ if (href) {
3032
+ link.setAttribute("href", href);
3033
+ }
3034
+ if (type) {
3035
+ link.setAttribute("type", type);
3036
+ }
3037
+ }
3038
+
2565
3039
  function applyThemeMode(mode, persist) {
2566
3040
  const normalizedMode = normalizeThemeMode(mode);
2567
3041
  if (normalizedMode === "light" || normalizedMode === "dark") {
@@ -2570,6 +3044,7 @@ function siteJs() {
2570
3044
  delete document.documentElement.dataset.theme;
2571
3045
  }
2572
3046
  document.documentElement.dataset.themeMode = normalizedMode;
3047
+ updateFavicon(normalizedMode);
2573
3048
  document.querySelectorAll("[data-theme-choice]").forEach((button) => {
2574
3049
  button.setAttribute("aria-pressed", button.getAttribute("data-theme-choice") === normalizedMode ? "true" : "false");
2575
3050
  });
@@ -2678,6 +3153,19 @@ function siteJs() {
2678
3153
  applyThemeMode(button.getAttribute("data-theme-choice") || "auto", true);
2679
3154
  });
2680
3155
  });
3156
+ if (window.matchMedia) {
3157
+ const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
3158
+ const updateAutoFavicon = () => {
3159
+ if (normalizeThemeMode(document.documentElement.dataset.themeMode || "auto") === "auto") {
3160
+ updateFavicon("auto");
3161
+ }
3162
+ };
3163
+ if (colorSchemeQuery.addEventListener) {
3164
+ colorSchemeQuery.addEventListener("change", updateAutoFavicon);
3165
+ } else if (colorSchemeQuery.addListener) {
3166
+ colorSchemeQuery.addListener(updateAutoFavicon);
3167
+ }
3168
+ }
2681
3169
  }());
2682
3170
  `;
2683
3171
  }