@gjsify/site 0.64.11 → 0.64.12-next.279

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.
Files changed (49) hide show
  1. package/README.md +85 -19
  2. package/lib/cli.js +4 -14
  3. package/lib/client/site.css +283 -3
  4. package/lib/index.d.ts +5 -0
  5. package/lib/index.js +5 -0
  6. package/lib/render/blog.d.ts +62 -0
  7. package/lib/render/blog.js +240 -0
  8. package/lib/render/developer.d.ts +30 -0
  9. package/lib/render/developer.js +285 -0
  10. package/lib/render/feed.d.ts +25 -0
  11. package/lib/render/feed.js +97 -0
  12. package/lib/render/i18n.d.ts +2 -2
  13. package/lib/render/i18n.js +2 -0
  14. package/lib/render/icons.d.ts +1 -1
  15. package/lib/render/icons.js +3 -0
  16. package/lib/render/image-backend.d.ts +2 -1
  17. package/lib/render/image-backend.js +2 -2
  18. package/lib/render/images.d.ts +2 -0
  19. package/lib/render/images.js +3 -2
  20. package/lib/render/layout.d.ts +94 -0
  21. package/lib/render/layout.js +216 -0
  22. package/lib/render/markdown.d.ts +15 -1
  23. package/lib/render/markdown.js +245 -20
  24. package/lib/render/pages.d.ts +21 -8
  25. package/lib/render/pages.js +99 -263
  26. package/lib/render/seo.d.ts +34 -1
  27. package/lib/render/seo.js +50 -4
  28. package/lib/site-data/blog.d.ts +30 -0
  29. package/lib/site-data/blog.js +226 -0
  30. package/lib/site-data/config.d.ts +7 -2
  31. package/lib/site-data/config.js +118 -23
  32. package/lib/site-data/developer.d.ts +16 -0
  33. package/lib/site-data/developer.js +12 -0
  34. package/lib/site-data/frontmatter.d.ts +7 -0
  35. package/lib/site-data/frontmatter.js +242 -0
  36. package/lib/site-data/index.d.ts +4 -0
  37. package/lib/site-data/index.js +4 -0
  38. package/lib/site-data/related.d.ts +4 -2
  39. package/lib/site-data/related.js +8 -3
  40. package/lib/site-data/resume.d.ts +64 -0
  41. package/lib/site-data/resume.js +277 -0
  42. package/lib/site-data/types.d.ts +57 -15
  43. package/lib/site.d.ts +2 -26
  44. package/lib/site.js +211 -78
  45. package/package.json +13 -12
  46. package/po/de.po +88 -25
  47. package/po/site.pot +86 -23
  48. package/lib/render/overview.d.ts +0 -19
  49. package/lib/render/overview.js +0 -53
@@ -0,0 +1,97 @@
1
+ import { esc } from './html.js';
2
+ function formatDateRFC3339(date) {
3
+ // date-only YYYY-MM-DD means 00:00:00Z
4
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
5
+ return date + 'T00:00:00Z';
6
+ }
7
+ // Try to parse as ISO date-time
8
+ const d = new Date(date);
9
+ if (isNaN(d.getTime())) {
10
+ // Fallback: if it looks like ISO, try to normalize
11
+ // Otherwise just append Z if no timezone?
12
+ return date;
13
+ }
14
+ // Format as RFC3339 UTC
15
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
16
+ }
17
+ /** A complete Atom document. Dates are given as YYYY-MM-DD or ISO; output RFC 3339 (date-only → T00:00:00Z). Feed <updated> = newest entry updated/published, or 1970-01-01T00:00:00Z when empty. */
18
+ export function atomFeed(info, entries) {
19
+ let feedUpdated = '1970-01-01T00:00:00Z';
20
+ if (entries.length > 0) {
21
+ let newest = -Infinity;
22
+ for (const entry of entries) {
23
+ const updatedDate = entry.updated ?? entry.published;
24
+ const t = formatDateRFC3339(updatedDate);
25
+ // For comparison, use Date
26
+ const d = new Date(t);
27
+ if (d.getTime() > newest) {
28
+ newest = d.getTime();
29
+ feedUpdated = t.endsWith('Z') && t.includes('T') && t.length > 10 ? t : formatDateRFC3339(updatedDate);
30
+ // ensure proper format
31
+ if (!feedUpdated.includes('T')) {
32
+ feedUpdated = feedUpdated + 'T00:00:00Z';
33
+ }
34
+ else if (!feedUpdated.endsWith('Z') && !feedUpdated.match(/[+-]\d{2}:?\d{2}$/)) {
35
+ // try to convert
36
+ feedUpdated = new Date(feedUpdated).toISOString().replace(/\.\d{3}Z$/, 'Z');
37
+ }
38
+ }
39
+ }
40
+ // Recompute properly
41
+ let maxTime = -Infinity;
42
+ let maxStr = '1970-01-01T00:00:00Z';
43
+ for (const entry of entries) {
44
+ const updatedDate = entry.updated ?? entry.published;
45
+ const formatted = formatDateRFC3339(updatedDate);
46
+ const d = new Date(formatted);
47
+ if (!isNaN(d.getTime()) && d.getTime() > maxTime) {
48
+ maxTime = d.getTime();
49
+ maxStr = formatted.includes('T') ? formatted : formatted + 'T00:00:00Z';
50
+ if (maxStr.match(/\.\d{3}Z$/)) {
51
+ maxStr = maxStr.replace(/\.\d{3}Z$/, 'Z');
52
+ }
53
+ }
54
+ }
55
+ feedUpdated = maxStr;
56
+ }
57
+ let xml = '<?xml version="1.0" encoding="utf-8"?>\n';
58
+ xml += `<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${esc(info.lang)}">\n`;
59
+ xml += ` <id>${esc(info.id)}</id>\n`;
60
+ xml += ` <title>${esc(info.title)}</title>\n`;
61
+ if (info.subtitle) {
62
+ xml += ` <subtitle>${esc(info.subtitle)}</subtitle>\n`;
63
+ }
64
+ xml += ` <updated>${feedUpdated}</updated>\n`;
65
+ xml += ` <link rel="self" type="application/atom+xml" href="${esc(info.selfUrl)}"/>\n`;
66
+ xml += ` <link rel="alternate" type="text/html" href="${esc(info.siteUrl)}"/>\n`;
67
+ if (info.icon) {
68
+ xml += ` <icon>${esc(info.icon)}</icon>\n`;
69
+ }
70
+ xml += ` <author>\n`;
71
+ xml += ` <name>${esc(info.author.name)}</name>\n`;
72
+ if (info.author.uri) {
73
+ xml += ` <uri>${esc(info.author.uri)}</uri>\n`;
74
+ }
75
+ xml += ` </author>\n`;
76
+ for (const entry of entries) {
77
+ xml += ` <entry>\n`;
78
+ xml += ` <id>${esc(entry.id)}</id>\n`;
79
+ xml += ` <title>${esc(entry.title)}</title>\n`;
80
+ xml += ` <link rel="alternate" href="${esc(entry.url)}"/>\n`;
81
+ const published = formatDateRFC3339(entry.published);
82
+ xml += ` <published>${published.includes('T') ? published.replace(/\.\d{3}Z$/, 'Z') : published + 'T00:00:00Z'}</published>\n`;
83
+ const updated = entry.updated ? formatDateRFC3339(entry.updated) : published;
84
+ const updatedOut = updated.includes('T') ? updated.replace(/\.\d{3}Z$/, 'Z') : updated + 'T00:00:00Z';
85
+ xml += ` <updated>${updatedOut}</updated>\n`;
86
+ if (entry.summary) {
87
+ xml += ` <summary>${esc(entry.summary)}</summary>\n`;
88
+ }
89
+ xml += ` <content type="html">${esc(entry.contentHtml)}</content>\n`;
90
+ for (const tag of entry.tags) {
91
+ xml += ` <category term="${esc(tag)}"/>\n`;
92
+ }
93
+ xml += ` </entry>\n`;
94
+ }
95
+ xml += `</feed>`;
96
+ return xml;
97
+ }
@@ -1,4 +1,4 @@
1
- import { type Catalog, type SiteConfig } from '../site-data/index.js';
1
+ import { type Catalog, type AnySiteConfig } from '../site-data/index.js';
2
2
  /**
3
3
  * The msgids the renderer itself can emit: every translator call in its sources, plus the default
4
4
  * download labels. They belong to this package and are translated in its own `po/`.
@@ -9,7 +9,7 @@ export declare function rendererMsgids(renderDir: string): Promise<string[]>;
9
9
  * consumer and are translated in the consumer's `pageTranslations`. Screenshot captions are not
10
10
  * among them — they live in the metainfo and are translated there.
11
11
  */
12
- export declare function configMsgids(config: SiteConfig): string[];
12
+ export declare function configMsgids(config: AnySiteConfig): string[];
13
13
  /** Every msgid in a `.po`/`.pot` file, translated or not, header excluded. */
14
14
  export declare function catalogMsgids(file: string): Promise<Set<string>>;
15
15
  /** Writes a catalogue: the translations in `known` are kept, everything else is left empty. */
@@ -44,6 +44,8 @@ export async function rendererMsgids(renderDir) {
44
44
  */
45
45
  export function configMsgids(config) {
46
46
  const msgids = new Set();
47
+ if (config.kind !== 'app')
48
+ return [];
47
49
  for (const download of config.downloads)
48
50
  if (download.label)
49
51
  msgids.add(download.label);
@@ -3,7 +3,7 @@
3
3
  * theme: inlining them costs no request and no licence notice, and a symbolic 16×16 grid is
4
4
  * little enough geometry to keep in one file. Every path uses `currentColor` and is sized by CSS.
5
5
  */
6
- export type IconName = 'linux' | 'macos' | 'windows' | 'android' | 'web' | 'firefox' | 'chromium' | 'package' | 'download' | 'license' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'close' | 'copy' | 'expand' | 'external' | 'code' | 'bug' | 'heart' | 'help' | 'mail' | 'people' | 'translate';
6
+ export type IconName = 'linux' | 'macos' | 'windows' | 'android' | 'web' | 'firefox' | 'chromium' | 'package' | 'download' | 'license' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'close' | 'copy' | 'expand' | 'external' | 'code' | 'bug' | 'heart' | 'help' | 'mail' | 'people' | 'translate' | 'blog' | 'cv' | 'feed';
7
7
  export interface IconOptions {
8
8
  /** Added to the `<svg>`, on top of the shared `icon` class. */
9
9
  class?: string;
@@ -31,6 +31,9 @@ const PATHS = {
31
31
  mail: `<g ${STROKE}><rect x="1.8" y="3.4" width="12.4" height="9.2" rx="1.6"/><path d="m2.4 4.6 5.6 4.2 5.6-4.2"/></g>`,
32
32
  people: `<g ${STROKE}><circle cx="6.2" cy="5.2" r="2.6"/><path d="M1.6 13.8c0-2.5 2.1-4.2 4.6-4.2s4.6 1.7 4.6 4.2"/><path d="M10.6 3.1a2.6 2.6 0 0 1 0 5.1M12.1 9.9c1.4.5 2.3 1.7 2.3 3.3"/></g>`,
33
33
  translate: `<g ${STROKE}><path d="M2 4.4c0-.9.7-1.6 1.6-1.6h5.1c.9 0 1.6.7 1.6 1.6v2.3c0 .9-.7 1.6-1.6 1.6H6L3.6 10.4V8.3h-.1c-.8 0-1.5-.7-1.5-1.6V4.4Z"/><path d="M12.3 6h.1c.9 0 1.6.7 1.6 1.6v2.3c0 .9-.7 1.6-1.6 1.6h-.1v2.1L9.9 11.5H8"/></g>`,
34
+ blog: `<g ${STROKE}><path d="M10.6 2.4a1.5 1.5 0 0 1 2.1 2.1L6 11.2l-2.8.7.7-2.8 6.7-6.7Z"/><path d="M2.2 14.2h11.6"/></g>`,
35
+ cv: `<g ${STROKE}><rect x="2.6" y="1.8" width="10.8" height="12.4" rx="1.6"/><circle cx="8" cy="6" r="1.8"/><path d="M5.2 10.6c.4-1.2 1.5-1.9 2.8-1.9s2.4.7 2.8 1.9"/></g>`,
36
+ feed: `<g ${STROKE}><path d="M3 7.2a5.8 5.8 0 0 1 5.8 5.8M3 2.6A10.4 10.4 0 0 1 13.4 13"/></g><circle cx="3.9" cy="12.1" r="1.3"/>`,
34
37
  };
35
38
  /** Renders one icon as an inline `<svg>`; decorative by default. */
36
39
  export function icon(name, options = {}) {
@@ -19,7 +19,8 @@ export interface RasterImage {
19
19
  opaqueBounds(threshold: number): Rect | null;
20
20
  cropped(rect: Rect): RasterImage;
21
21
  /** Keeps the aspect ratio. Never enlarges: a width at or above this one answers the same image. */
22
- resizedToWidth(width: number): RasterImage;
22
+ /** `nearest` keeps pixel art's hard edges; the default resamples smoothly. */
23
+ resizedToWidth(width: number, filter?: 'smooth' | 'nearest'): RasterImage;
23
24
  encode(format: ImageFormat): Uint8Array;
24
25
  }
25
26
  export declare function decodeImageFile(path: string): RasterImage;
@@ -91,14 +91,14 @@ function wrap(pixbuf) {
91
91
  throw new Error(`Could not crop an image to ${rect.width}x${rect.height}`);
92
92
  return wrap(sub);
93
93
  },
94
- resizedToWidth(target) {
94
+ resizedToWidth(target, filter = 'smooth') {
95
95
  if (target >= width)
96
96
  return wrap(pixbuf);
97
97
  const scaled = pixbuf.scale_simple(target, Math.max(1, Math.round((height * target) / width)),
98
98
  // HYPER over BILINEAR: these are large screenshots scaled far
99
99
  // down, where bilinear drops thin UI lines (1px borders, text
100
100
  // stems) that the page is meant to show.
101
- GdkPixbuf.InterpType.HYPER);
101
+ filter === 'nearest' ? GdkPixbuf.InterpType.NEAREST : GdkPixbuf.InterpType.HYPER);
102
102
  if (!scaled)
103
103
  throw new Error(`Could not scale an image to ${target}px`);
104
104
  return wrap(scaled);
@@ -20,6 +20,8 @@ export interface ImageOptions {
20
20
  /** Persistent store of encoded renditions; AVIF is slow, so it is reused while the source is unchanged. */
21
21
  cacheDir: string;
22
22
  urlBase: string;
23
+ /** `theme.imageRendering: pixelated` — nearest-neighbour renditions for pixel art. */
24
+ pixelated?: boolean;
23
25
  }
24
26
  /**
25
27
  * The widest the page ever shows a screenshot, in CSS pixels. A screenshot is shown at its natural
@@ -75,9 +75,10 @@ export async function processImage(source, slug, options) {
75
75
  let scaled;
76
76
  for (const format of FORMATS) {
77
77
  const name = `${slug}-${width}.${format}`;
78
- const cached = join(options.cacheDir, name);
78
+ // The filter is part of the cache key: switching the theme must not serve stale renditions.
79
+ const cached = join(options.cacheDir, options.pixelated ? `px-${name}` : name);
79
80
  if (!(await isFresh(cached, source))) {
80
- scaled ??= image.resizedToWidth(width);
81
+ scaled ??= image.resizedToWidth(width, options.pixelated ? 'nearest' : 'smooth');
81
82
  await writeFile(cached, scaled.encode(format));
82
83
  }
83
84
  await copyFile(cached, join(options.outDir, name));
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The frame every page kind shares: `<head>`, the top bar and the footer. App pages, blog pages and
3
+ * developer pages all render through it, so a fix to the head or the footer lands on all of them
4
+ * (ADR 0120 §6). What differs between the kinds is the `Shell` they build; what differs between two
5
+ * pages of one site is their `PageMeta`.
6
+ */
7
+ import type { SiteCommonConfig } from '../site-data/index.js';
8
+ import { type IconName } from './icons.js';
9
+ export type Translate = (msgid: string) => string;
10
+ export interface ClientAssets {
11
+ js: string;
12
+ css: string[];
13
+ }
14
+ /** A link in the top bar next to the language switch. */
15
+ export interface NavLink {
16
+ href: string;
17
+ label: string;
18
+ glyph: IconName;
19
+ /** The page being rendered is this link's target or below it. */
20
+ current?: boolean;
21
+ }
22
+ /** What stays the same on every page of one site in one language. */
23
+ export interface Shell {
24
+ config: SiteCommonConfig;
25
+ lang: string;
26
+ t: Translate;
27
+ assets: ClientAssets;
28
+ /** The site's name: the bar brand and `og:site_name`. */
29
+ name: string;
30
+ /** Root-relative address the brand links to. */
31
+ home: string;
32
+ /** Published URL of the icon: bar brand, favicon. */
33
+ iconUrl?: string;
34
+ /** Paints the masthead and the bar; `undefined` keeps the stylesheet's palette. */
35
+ branding: {
36
+ light?: string;
37
+ dark?: string;
38
+ };
39
+ /** Address of the source code, not `owner/name`; see `githubUrl`. */
40
+ repository?: string;
41
+ /** Translated footer sentences about licences. */
42
+ licenses: readonly string[];
43
+ nav: readonly NavLink[];
44
+ /** Atom feeds announced in `<head>` of every page. */
45
+ feeds: ReadonlyArray<{
46
+ title: string;
47
+ href: string;
48
+ }>;
49
+ /** Root-relative URL of the content-hashed `theme.displayFont`. */
50
+ displayFontUrl?: string;
51
+ }
52
+ /** What makes one page different from its siblings. */
53
+ export interface PageMeta {
54
+ title: string;
55
+ description: string;
56
+ /** Root-relative address of this page in every language it exists in, including the shell's. */
57
+ urls: Readonly<Record<string, string>>;
58
+ /** Where the language switch leads for a language this page has no translation in. */
59
+ fallback?: (lang: string) => string;
60
+ ogType?: 'website' | 'article' | 'profile';
61
+ /** Root-relative URL of the link-preview card. */
62
+ ogImage?: string;
63
+ /** Further `<meta property>` pairs, e.g. `article:published_time`. */
64
+ meta?: ReadonlyArray<[string, string]>;
65
+ /** Ready `<script type="application/ld+json">` markup. */
66
+ jsonLd?: string;
67
+ }
68
+ /** `/<lang>/` under the site's base, plus an optional sub path such as `privacy/`. */
69
+ export declare function sitePageUrl(config: Pick<SiteCommonConfig, 'base'>, lang: string, path?: string): string;
70
+ export declare function absoluteUrl(config: Pick<SiteCommonConfig, 'url'>, rootRelative: string): string;
71
+ export declare function languageName(code: string): string;
72
+ export declare function languageSwitch(shell: Shell, page: PageMeta, className: string, style: 'code' | 'name'): string;
73
+ export declare function documentHead(shell: Shell, page: PageMeta): string;
74
+ /** `gjsify.site.repository` is `owner/name`; a link needs the address. */
75
+ export declare function githubUrl(repository: string | undefined): string | undefined;
76
+ export declare function topBar(shell: Shell, page: PageMeta): string;
77
+ export declare function footer(shell: Shell, page: PageMeta): string;
78
+ /** A whole document: head, bar, the page's own markup, footer. */
79
+ export declare function documentPage(shell: Shell, page: PageMeta, body: string, bodyClass?: string): string;
80
+ /** The small masthead of a secondary page: the site's icon, a heading and who it belongs to. */
81
+ export declare function pageMasthead(shell: Pick<Shell, 'iconUrl'>, heading: string, byline?: string): string;
82
+ /** A text page — support, privacy, legal notice, a blog post — in one column under a small masthead. */
83
+ export declare function simplePage(shell: Shell, page: PageMeta, options: {
84
+ heading: string;
85
+ byline?: string;
86
+ columnClass?: string;
87
+ body: string;
88
+ back?: {
89
+ href: string;
90
+ label: string;
91
+ };
92
+ }): string;
93
+ /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
94
+ export declare function rootPage(config: Pick<SiteCommonConfig, 'base' | 'url' | 'languages' | 'defaultLanguage'>, name: string, assets: ClientAssets, iconUrl: string | undefined): string;
@@ -0,0 +1,216 @@
1
+ import { readableText } from './color.js';
2
+ import { esc, plain } from './html.js';
3
+ import { icon } from './icons.js';
4
+ import { OG_HEIGHT, OG_WIDTH } from './og-card.js';
5
+ import { ogLocale } from './seo.js';
6
+ /** `/<lang>/` under the site's base, plus an optional sub path such as `privacy/`. */
7
+ export function sitePageUrl(config, lang, path = '') {
8
+ return `${config.base}${lang}/${path}`;
9
+ }
10
+ export function absoluteUrl(config, rootRelative) {
11
+ return new URL(rootRelative.replace(/^\//, ''), `${new URL(config.url).origin}/`).href;
12
+ }
13
+ export function languageName(code) {
14
+ return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
15
+ }
16
+ function switchUrl(shell, page, code) {
17
+ return page.urls[code] ?? page.fallback?.(code) ?? sitePageUrl(shell.config, code);
18
+ }
19
+ export function languageSwitch(shell, page, className, style) {
20
+ const { config, lang, t } = shell;
21
+ const items = config.languages.map((code) => {
22
+ const label = style === 'code' ? code.toUpperCase() : languageName(code);
23
+ // Only a real translation is the same page in another language; a fallback is not.
24
+ const hreflang = page.urls[code] ? ` hreflang="${esc(code)}"` : '';
25
+ return code === lang
26
+ ? `<li><span aria-current="true">${esc(label)}</span></li>`
27
+ : `<li><a href="${esc(switchUrl(shell, page, code))}"${hreflang} lang="${esc(code)}">${esc(label)}</a></li>`;
28
+ });
29
+ return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
30
+ }
31
+ /**
32
+ * The theme as inline CSS: accent and display font as custom properties the stylesheet reads, and
33
+ * nearest-neighbour scaling for pixel art. Empty without a theme, so the default look is untouched.
34
+ */
35
+ function themeStyle(shell) {
36
+ const { theme } = shell.config;
37
+ const { light, dark } = shell.branding;
38
+ const vars = [
39
+ light ? `--brand-light:${light};--band-fg-light:${readableText(light)}` : '',
40
+ dark ? `--brand-dark:${dark};--band-fg-dark:${readableText(dark)}` : '',
41
+ theme.accent
42
+ ? `--accent-light:${theme.accent.light};--accent-dark:${theme.accent.dark};--accent-fg-light:${readableText(theme.accent.light)};--accent-fg-dark:${readableText(theme.accent.dark)}`
43
+ : '',
44
+ shell.displayFontUrl ? '--display-font:"Site Display"' : '',
45
+ ]
46
+ .filter(Boolean)
47
+ .join(';');
48
+ const rules = [
49
+ shell.displayFontUrl
50
+ ? `@font-face{font-family:"Site Display";src:url(${JSON.stringify(shell.displayFontUrl)}) format("woff2");font-display:swap}`
51
+ : '',
52
+ vars ? `:root{${vars}}` : '',
53
+ theme.imageRendering === 'pixelated' ? 'img{image-rendering:pixelated}' : '',
54
+ ].join('');
55
+ return rules ? `<style>${rules}</style>` : '';
56
+ }
57
+ export function documentHead(shell, page) {
58
+ const { config, lang, name } = shell;
59
+ const { title, description } = page;
60
+ const url = absoluteUrl(config, page.urls[lang] ?? sitePageUrl(config, lang));
61
+ const ogImage = page.ogImage ? absoluteUrl(config, page.ogImage) : undefined;
62
+ const languages = config.languages.filter((code) => page.urls[code] !== undefined);
63
+ const alternates = languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absoluteUrl(config, page.urls[code]))}">`);
64
+ const xDefault = page.urls[config.defaultLanguage];
65
+ const meta = [
66
+ ['og:type', page.ogType ?? 'website'],
67
+ ['og:title', title],
68
+ ['og:description', description],
69
+ ['og:url', url],
70
+ ['og:site_name', name],
71
+ ['og:locale', ogLocale(lang)],
72
+ ...languages
73
+ .filter((code) => code !== lang)
74
+ .map((code) => ['og:locale:alternate', ogLocale(code)]),
75
+ ];
76
+ if (ogImage) {
77
+ meta.push(['og:image', ogImage], ['og:image:width', String(OG_WIDTH)], ['og:image:height', String(OG_HEIGHT)], ['og:image:alt', title]);
78
+ }
79
+ meta.push(...(page.meta ?? []));
80
+ const { light, dark } = shell.branding;
81
+ return `<meta charset="utf-8">
82
+ <meta name="viewport" content="width=device-width, initial-scale=1">
83
+ <title>${esc(title)}</title>
84
+ <meta name="description" content="${esc(description)}">
85
+ <link rel="canonical" href="${esc(url)}">
86
+ ${alternates.join('\n')}
87
+ ${xDefault ? `<link rel="alternate" hreflang="x-default" href="${esc(absoluteUrl(config, xDefault))}">` : ''}
88
+ ${meta.map(([property, content]) => `<meta property="${property}" content="${esc(content)}">`).join('\n')}
89
+ <meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
90
+ <meta name="twitter:title" content="${esc(title)}">
91
+ <meta name="twitter:description" content="${esc(description)}">
92
+ ${ogImage ? `<meta name="twitter:image" content="${esc(ogImage)}">` : ''}
93
+ <meta name="theme-color" content="${esc(light ?? '#ffffff')}" media="(prefers-color-scheme: light)">
94
+ <meta name="theme-color" content="${esc(dark ?? '#1e1e1e')}" media="(prefers-color-scheme: dark)">
95
+ ${shell.iconUrl ? `<link rel="icon"${shell.iconUrl.endsWith('.svg') ? ' type="image/svg+xml"' : ''} href="${esc(shell.iconUrl)}">` : ''}
96
+ ${shell.feeds.map((feed) => `<link rel="alternate" type="application/atom+xml" title="${esc(feed.title)}" href="${esc(feed.href)}">`).join('\n')}
97
+ ${page.jsonLd ?? ''}
98
+ ${themeStyle(shell)}
99
+ ${shell.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
100
+ <script type="module" src="${esc(shell.assets.js)}"></script>`;
101
+ }
102
+ /** `gjsify.site.repository` is `owner/name`; a link needs the address. */
103
+ export function githubUrl(repository) {
104
+ return repository ? `https://github.com/${repository}` : undefined;
105
+ }
106
+ function onGitHub(url) {
107
+ return /(^|\.)github\.com$/.test(new URL(url).host);
108
+ }
109
+ export function topBar(shell, page) {
110
+ const { t, repository } = shell;
111
+ const nav = shell.nav.map((link) => `<a class="bar-link" href="${esc(link.href)}"${link.current ? ' aria-current="page"' : ''}>${icon(link.glyph)}<span>${esc(link.label)}</span></a>`);
112
+ return `<div class="topbar">
113
+ <div class="bar-inner">
114
+ <a class="bar-brand" href="${esc(shell.home)}">
115
+ ${shell.iconUrl ? `<img src="${esc(shell.iconUrl)}" alt="" width="24" height="24">` : ''}
116
+ <span>${esc(shell.name)}</span>
117
+ </a>
118
+ <div class="bar-end">
119
+ ${nav.join('\n')}
120
+ ${languageSwitch(shell, page, 'bar-languages', 'code')}
121
+ ${repository ? `<a class="bar-link" href="${esc(repository)}">${icon('code')}<span>${onGitHub(repository) ? 'GitHub' : esc(t('Source code'))}</span></a>` : ''}
122
+ </div>
123
+ </div>
124
+ </div>`;
125
+ }
126
+ export function footer(shell, page) {
127
+ const { config, lang, t, repository } = shell;
128
+ // The privacy and legal-notice links are derived, never configured: a consumer with a
129
+ // `gjsify.site.privacy`/`legalNotice` block gets the link on every page without also listing
130
+ // it in `legal`, which would be a second place for the two to disagree.
131
+ const legal = [
132
+ ...(config.legalNotice
133
+ ? [`<li><a href="${esc(sitePageUrl(config, lang, 'legal-notice/'))}">${esc(t('Legal Notice'))}</a></li>`]
134
+ : []),
135
+ ...(config.privacy
136
+ ? [`<li><a href="${esc(sitePageUrl(config, lang, 'privacy/'))}">${esc(t('Privacy'))}</a></li>`]
137
+ : []),
138
+ ...config.legal.map((link) => `<li><a href="${esc(link.url)}">${esc(link.label)}</a></li>`),
139
+ ].join('');
140
+ return `<footer class="footer">
141
+ <div class="footer-inner">
142
+ ${languageSwitch(shell, page, 'footer-languages', 'name')}
143
+ ${repository ? `<p class="footer-source"><a href="${esc(repository)}">${icon('code')}<span>${esc(t('Source code'))}</span></a></p>` : ''}
144
+ ${shell.licenses.length > 0 ? `<p class="footer-license">${shell.licenses.map(esc).join(' ')}</p>` : ''}
145
+ <p class="footer-made"><a href="https://github.com/gjsify/gjsify">${esc(plain(t('Made with {project}'), { project: 'gjsify' }))}</a></p>
146
+ ${legal ? `<ul class="footer-legal">${legal}</ul>` : ''}
147
+ </div>
148
+ </footer>`;
149
+ }
150
+ /** A whole document: head, bar, the page's own markup, footer. */
151
+ export function documentPage(shell, page, body, bodyClass) {
152
+ return `<!doctype html>
153
+ <html lang="${esc(shell.lang)}">
154
+ <head>
155
+ ${documentHead(shell, page)}
156
+ </head>
157
+ <body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
158
+ ${topBar(shell, page)}
159
+ ${body}
160
+ ${footer(shell, page)}
161
+ </body>
162
+ </html>
163
+ `;
164
+ }
165
+ /** The small masthead of a secondary page: the site's icon, a heading and who it belongs to. */
166
+ export function pageMasthead(shell, heading, byline) {
167
+ return `<header class="masthead">
168
+ <div class="hero-inner">
169
+ ${shell.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(shell.iconUrl)})"><img src="${esc(shell.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
170
+ <div class="hero-text">
171
+ <h1>${esc(heading)}</h1>
172
+ ${byline ? `<p class="hero-by">${esc(byline)}</p>` : ''}
173
+ </div>
174
+ </div>
175
+ </header>`;
176
+ }
177
+ /** A text page — support, privacy, legal notice, a blog post — in one column under a small masthead. */
178
+ export function simplePage(shell, page, options) {
179
+ const back = options.back
180
+ ? `\n<p class="support-back"><a href="${esc(options.back.href)}">${options.back.label}</a></p>`
181
+ : '';
182
+ return documentPage(shell, page, `${pageMasthead(shell, options.heading, options.byline)}
183
+ <main>
184
+ <div class="column${options.columnClass ? ` ${options.columnClass}` : ''}">
185
+ ${options.body}${back}
186
+ </div>
187
+ </main>`);
188
+ }
189
+ /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
190
+ export function rootPage(config, name, assets, iconUrl) {
191
+ const json = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
192
+ const choose = `var l=${json(config.languages)},d=${json(config.defaultLanguage)},p=(navigator.languages||[navigator.language]).map(function(x){return String(x).toLowerCase()}),c=d;for(var i=0;i<p.length;i++){var m=l.filter(function(x){return p[i]===x.toLowerCase()||p[i].split("-")[0]===x.toLowerCase()})[0];if(m){c=m;break}}location.replace(${json(config.base)}+c+"/")`;
193
+ const home = (code) => sitePageUrl(config, code);
194
+ const alternates = config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absoluteUrl(config, home(code)))}">`);
195
+ return `<!doctype html>
196
+ <html lang="${esc(config.defaultLanguage)}">
197
+ <head>
198
+ <meta charset="utf-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1">
200
+ <title>${esc(name)}</title>
201
+ <link rel="canonical" href="${esc(absoluteUrl(config, home(config.defaultLanguage)))}">
202
+ ${alternates.join('\n')}
203
+ <link rel="alternate" hreflang="x-default" href="${esc(absoluteUrl(config, home(config.defaultLanguage)))}">
204
+ ${iconUrl ? `<link rel="icon"${iconUrl.endsWith('.svg') ? ' type="image/svg+xml"' : ''} href="${esc(iconUrl)}">` : ''}
205
+ ${assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
206
+ <script>${choose}</script>
207
+ </head>
208
+ <body class="root">
209
+ <main>
210
+ <h1>${esc(name)}</h1>
211
+ <ul class="languages-list">${config.languages.map((code) => `<li><a href="${esc(home(code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(languageName(code))}</a></li>`).join('')}</ul>
212
+ </main>
213
+ </body>
214
+ </html>
215
+ `;
216
+ }
@@ -1 +1,15 @@
1
- export declare function renderMarkdown(source: string): string;
1
+ /**
2
+ * The subset of Markdown a privacy/legal statement actually uses: headings up to three levels,
3
+ * paragraphs (wrapped lines joined back into one), ordered and unordered lists, GFM pipe tables,
4
+ * a horizontal rule, inline links/bold/code, fenced code blocks, images, blockquotes, and
5
+ * emphasis. Also used for blog posts. Nothing else — this is not a Markdown engine,
6
+ * it is the smallest renderer that can show the statement files consumers already write by hand.
7
+ *
8
+ * Heading levels are shifted up by one (`#`→`h2`, `##`→`h3`, `###`→`h4`): the page already has its
9
+ * own `<h1>` (the page title), and a statement file's own top-level heading restates it.
10
+ */
11
+ export interface MarkdownOptions {
12
+ /** Rewrites a link href or image src; absolute and `#` URLs are passed in too. */
13
+ resolveUrl?: (url: string) => string;
14
+ }
15
+ export declare function renderMarkdown(source: string, options?: MarkdownOptions): string;