diff --git a/package-lock.json b/package-lock.json
index cf02bea..9e9e816 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"@date-fns/tz": "^1.5.0",
- "@fontsource-variable/source-sans-3": "^5.3.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
@@ -693,15 +692,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
- "node_modules/@fontsource-variable/source-sans-3": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@fontsource-variable/source-sans-3/-/source-sans-3-5.3.0.tgz",
- "integrity": "sha512-dpi0GZk7EQe2tYpg6Q0Fx0OmUgnuGu+0rHPgtXqtKhglYmJuP7jZ12Mu1uN+NfRaJRY1Emn8AQJEWzmoZ4wa9g==",
- "license": "OFL-1.1",
- "funding": {
- "url": "https://github.com/sponsors/ayuhito"
- }
- },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
diff --git a/package.json b/package.json
index 3b06b6d..39cf37c 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,6 @@
},
"dependencies": {
"@date-fns/tz": "^1.5.0",
- "@fontsource-variable/source-sans-3": "^5.3.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
diff --git a/src/app.css b/src/app.css
index 1f5307d..f9aeb6e 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,13 +1,87 @@
@import "tailwindcss";
+@import "@fortawesome/fontawesome-svg-core/styles.css";
@plugin "@tailwindcss/typography";
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/da8a9dd0d68e512b-s.0n8i_inn0i2i9.woff2") format("woff2");
+ unicode-range: U+460-52F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/94b4a5dc1fe84361-s.3wm_n5jv1to7s.woff2") format("woff2");
+ unicode-range: U+301, U+400-45F, U+490-491, U+4B0-4B1, U+2116;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/ff5be760267bb4bc-s.3dkqd4mro2h3_.woff2") format("woff2");
+ unicode-range: U+1F??;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/99596c30eb072d3c-s.2fp9tilo6sy14.woff2") format("woff2");
+ unicode-range: U+370-377, U+37A-37F, U+384-38A, U+38C, U+38E-3A1, U+3A3-3FF;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/e5864b32625b6bc3-s.0m7i6b77o5yxd.woff2") format("woff2");
+ unicode-range: U+102-103, U+110-111, U+128-129, U+168-169, U+1A0-1A1, U+1AF-1B0, U+300-301, U+303-304, U+308-309, U+323, U+329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/e285ad1a914469f0-s.15241mr5-1s6i.woff2") format("woff2");
+ unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: "Source Sans 3";
+ font-style: normal;
+ font-weight: 200 900;
+ font-display: swap;
+ src: url("/fonts/source-sans-3/47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2") format("woff2");
+ unicode-range: U+??, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: "Source Sans 3 Fallback";
+ src: local(Arial);
+ ascent-override: 109.21%;
+ descent-override: 42.66%;
+ line-gap-override: 0%;
+ size-adjust: 93.76%;
+}
+
@theme {
--color-primary: #D72F32;
--color-secondary: #1E1E1E;
- --font-sans: "Source Sans 3 Variable", sans-serif;
+ --font-sans: "Source Sans 3", "Source Sans 3 Fallback";
}
@layer base {
+ html,
body {
font-family: var(--font-sans);
}
@@ -45,6 +119,26 @@
}
}
+ .text-input-root {
+ position: relative;
+ isolation: isolate;
+ }
+
+ .text-input-backdrop {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 0;
+ height: 50%;
+ background-color: #000;
+ }
+
+ .text-input-field {
+ position: relative;
+ z-index: 1;
+ }
+
.links a {
@apply text-primary underline;
}
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..7067dd7
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,11 @@
+import type { SharedHead } from '#lib/head.ts';
+
+declare global {
+ namespace App {
+ interface PageData {
+ sharedHead?: SharedHead;
+ }
+ }
+}
+
+export {};
diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte
new file mode 100644
index 0000000..ad443a9
--- /dev/null
+++ b/src/lib/components/Button.svelte
@@ -0,0 +1,92 @@
+
+
+{#if resolved.kind === 'anchor'}
+
+ {#if children}
+ {@render children()}
+ {:else}
+ {label}
+ {/if}
+
+{:else}
+
+ {#if children}
+ {@render children()}
+ {:else}
+ {label}
+ {/if}
+
+{/if}
diff --git a/src/lib/components/DefaultNav.svelte b/src/lib/components/DefaultNav.svelte
new file mode 100644
index 0000000..bcd0579
--- /dev/null
+++ b/src/lib/components/DefaultNav.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+ Method Conference
+ {date
+ ? `${formatDate(date, 'EEEE, MMMM do, yyyy')} in `
+ : ''}Springfield, MO
+
+
+ An immersive day of code, content, and design
+
+
+
+
+
+
+
diff --git a/src/lib/components/FontAwesomeIcon.svelte b/src/lib/components/FontAwesomeIcon.svelte
new file mode 100644
index 0000000..67fdf7c
--- /dev/null
+++ b/src/lib/components/FontAwesomeIcon.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+{@html markup}
diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte
new file mode 100644
index 0000000..c44b93e
--- /dev/null
+++ b/src/lib/components/Footer.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
+ {#each socialLinks as { icon, url, title } (url)}
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+ We ❤️ Springfield. ©{year} Springfield Devs. 501(c)6
+
+
+
diff --git a/src/lib/components/HomeNav.svelte b/src/lib/components/HomeNav.svelte
new file mode 100644
index 0000000..1efd220
--- /dev/null
+++ b/src/lib/components/HomeNav.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+ Method Conference
+ {date
+ ? `${formatDate(date, 'EEEE, MMMM do, yyyy')} in `
+ : ''}Springfield, MO
+
+
+ An immersive day of code ,
+ content , and
+ more
+
+
+
+
+ Register Now
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/LocationBlock.svelte b/src/lib/components/LocationBlock.svelte
new file mode 100644
index 0000000..b589535
--- /dev/null
+++ b/src/lib/components/LocationBlock.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each details as { heading, content } (heading)}
+ {heading}
+
+
+
+ {@html content}
+
+ {/each}
+
+
+
+
+
diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte
new file mode 100644
index 0000000..42c9d5b
--- /dev/null
+++ b/src/lib/components/Navigation.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
+ {#each links as { url, title } (url)}
+
+ {title}
+
+ {/each}
+
+
+
diff --git a/src/lib/components/RichText.svelte b/src/lib/components/RichText.svelte
new file mode 100644
index 0000000..8b7fb71
--- /dev/null
+++ b/src/lib/components/RichText.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
+ {@html renderedMarkup}
+
diff --git a/src/lib/components/RichTextBlock.svelte b/src/lib/components/RichTextBlock.svelte
new file mode 100644
index 0000000..6e2a9be
--- /dev/null
+++ b/src/lib/components/RichTextBlock.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+ {#if markup}
+
+ {/if}
+
+
diff --git a/src/lib/components/SectionTitleBar.svelte b/src/lib/components/SectionTitleBar.svelte
new file mode 100644
index 0000000..0212e43
--- /dev/null
+++ b/src/lib/components/SectionTitleBar.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/components/SponsorCard.svelte b/src/lib/components/SponsorCard.svelte
new file mode 100644
index 0000000..4b3e7be
--- /dev/null
+++ b/src/lib/components/SponsorCard.svelte
@@ -0,0 +1,82 @@
+
+
+
diff --git a/src/lib/components/SponsorTierItem.svelte b/src/lib/components/SponsorTierItem.svelte
new file mode 100644
index 0000000..5a31602
--- /dev/null
+++ b/src/lib/components/SponsorTierItem.svelte
@@ -0,0 +1,50 @@
+
+
+
+ {#if title}
+
+ {/if}
+
+ {#if sponsors.length > 0}
+
+
+ {#each sponsors as sponsor (sponsor.id)}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/components/SponsorsBlock.svelte b/src/lib/components/SponsorsBlock.svelte
new file mode 100644
index 0000000..2881053
--- /dev/null
+++ b/src/lib/components/SponsorsBlock.svelte
@@ -0,0 +1,64 @@
+
+
+
diff --git a/src/lib/components/TextInput.svelte b/src/lib/components/TextInput.svelte
new file mode 100644
index 0000000..26b07dc
--- /dev/null
+++ b/src/lib/components/TextInput.svelte
@@ -0,0 +1,42 @@
+
+
+
diff --git a/src/lib/components/TextWithButtonsBlock.svelte b/src/lib/components/TextWithButtonsBlock.svelte
new file mode 100644
index 0000000..0c6bfcd
--- /dev/null
+++ b/src/lib/components/TextWithButtonsBlock.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+ {#if text}
+
+ {/if}
+
+ {#each buttons as button, index (button.destinationId ?? button.url ?? index)}
+ {button.title}
+ {/each}
+
+
+
diff --git a/src/lib/components/richText.ts b/src/lib/components/richText.ts
new file mode 100644
index 0000000..0d92be0
--- /dev/null
+++ b/src/lib/components/richText.ts
@@ -0,0 +1,32 @@
+const CMS_MEDIA_URL_PATTERN =
+ /(?\b(?:href|src)=["'])(?(?:https?:\/\/[^"']+)?\/media\/[^"']+)(?["'])/gi;
+
+function rewriteCmsMediaUrl(url: string): string {
+ if (url.startsWith('/cms-media/')) {
+ return url;
+ }
+
+ if (url.startsWith('/media/')) {
+ return `/cms-media${url}`;
+ }
+
+ try {
+ const parsed = new URL(url);
+
+ if (!parsed.hostname.endsWith('methodconf.com')) {
+ return url;
+ }
+
+ return `/cms-media${parsed.pathname}${parsed.search}${parsed.hash}`;
+ } catch {
+ return url;
+ }
+}
+
+export function rewriteRichTextMediaUrls(markup: string): string {
+ return markup.replace(
+ CMS_MEDIA_URL_PATTERN,
+ (_match, attribute: string, url: string, quote: string) =>
+ `${attribute}${rewriteCmsMediaUrl(url)}${quote}`,
+ );
+}
diff --git a/src/lib/components/sponsorCard.ts b/src/lib/components/sponsorCard.ts
new file mode 100644
index 0000000..ca983fb
--- /dev/null
+++ b/src/lib/components/sponsorCard.ts
@@ -0,0 +1,12 @@
+import type { CardSize } from './SponsorCard.svelte';
+
+export function parseCardSize(str: string | null = ''): CardSize | undefined {
+ str = str?.toLowerCase() ?? '';
+
+ switch (str) {
+ case 'large':
+ case 'medium':
+ case 'small':
+ return str;
+ }
+}
diff --git a/src/lib/head.ts b/src/lib/head.ts
new file mode 100644
index 0000000..41eecee
--- /dev/null
+++ b/src/lib/head.ts
@@ -0,0 +1,110 @@
+export const DEFAULT_TITLE =
+ 'Method Conference - October 12th 2024 - Springfield, MO';
+export const NOT_FOUND_BROWSER_TITLE = '404: This page could not be found.';
+const DEFAULT_OG_IMAGE_PATH =
+ '/opengraph-image.jpg?opengraph-image.44rvcdk19e2xk.jpg';
+const DEFAULT_ICON_PATH = '/icon.png?icon.232taq741yhvg.png';
+
+export type SharedHeadImage = {
+ url?: string;
+ type?: string;
+ width?: number;
+ height?: number;
+};
+
+export type SharedHeadMetadata = {
+ title?: string;
+ description?: string;
+ openGraph?: {
+ title?: string;
+ image?: SharedHeadImage;
+ };
+};
+
+export type SharedHead = {
+ title: string;
+ description?: string;
+ openGraph: {
+ title: string;
+ image: Required;
+ };
+ twitter: {
+ card: 'summary_large_image';
+ title: string;
+ image: Required;
+ };
+ icon: {
+ href: string;
+ sizes: string;
+ type: string;
+ };
+};
+
+type BuildSharedHeadOptions = {
+ siteUrl: string;
+ metadata?: SharedHeadMetadata;
+};
+
+type ResolveDocumentTitleOptions = {
+ sharedTitle: string;
+ status: number;
+ errorMessage?: string;
+ clientMounted: boolean;
+};
+
+export function resolveDocumentTitle({
+ sharedTitle,
+ status,
+ errorMessage,
+ clientMounted,
+}: ResolveDocumentTitleOptions): string {
+ if (status === 404) {
+ return clientMounted ? NOT_FOUND_BROWSER_TITLE : sharedTitle;
+ }
+
+ if (status >= 400) {
+ return `${status}: ${errorMessage ?? 'Application error'}`;
+ }
+
+ return sharedTitle;
+}
+
+function resolveFromSiteUrl(siteUrl: string, pathOrUrl: string): string {
+ return new URL(pathOrUrl, siteUrl).toString();
+}
+
+export function buildSharedHead({
+ siteUrl,
+ metadata,
+}: BuildSharedHeadOptions): SharedHead {
+ const title = metadata?.title ?? DEFAULT_TITLE;
+ const image = {
+ url: resolveFromSiteUrl(
+ siteUrl,
+ metadata?.openGraph?.image?.url ?? DEFAULT_OG_IMAGE_PATH,
+ ),
+ type: metadata?.openGraph?.image?.type ?? 'image/jpeg',
+ width: metadata?.openGraph?.image?.width ?? 1200,
+ height: metadata?.openGraph?.image?.height ?? 630,
+ };
+ const openGraphTitle = metadata?.openGraph?.title ?? title;
+
+ return {
+ title,
+ description: metadata?.description,
+ openGraph: {
+ title: openGraphTitle,
+ image,
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: openGraphTitle,
+ image,
+ },
+ icon: {
+ href: DEFAULT_ICON_PATH,
+ sizes: '180x180',
+ type: 'image/png',
+ },
+ };
+}
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..952bd8c
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
{page.status}
+
+ {#if page.status === 404}
+
This page could not be found.
+ {:else}
+ {page.error?.message ?? 'Application error'}
+ {/if}
+
+
+
+
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index c514362..3e6ebea 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -1,5 +1,11 @@
-import { isSearchIndexingEnabled } from '#lib/server/config.ts';
+import { buildSharedHead } from '#lib/head.ts';
+import { getSiteUrl, isSearchIndexingEnabled } from '#lib/server/config.ts';
-export const load = () => ({
- searchIndexingEnabled: isSearchIndexingEnabled(),
-});
+export const load = () => {
+ const siteUrl = getSiteUrl().toString();
+
+ return {
+ searchIndexingEnabled: isSearchIndexingEnabled(),
+ sharedHead: buildSharedHead({ siteUrl }),
+ };
+};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index aed7e77..a057e06 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,18 +1,102 @@
- {#if !data.searchIndexingEnabled}
+ {documentTitle}
+ {#if head.description}
+
+ {/if}
+
+ {#if head.description}
+
+ {/if}
+
+
+
+
+
+
+ {#if head.description}
+
+ {/if}
+
+
+
+
+
+
+ {#if shouldEmitLayoutRobots}
{/if}
+ {#if !dev}
+
+
+ {@html plausibleHeadHtml}
+ {/if}
-
+
{@render children()}
+
+
diff --git a/static/fonts/source-sans-3/47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2 b/static/fonts/source-sans-3/47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2
new file mode 100644
index 0000000..ea8c145
Binary files /dev/null and b/static/fonts/source-sans-3/47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2 differ
diff --git a/static/fonts/source-sans-3/94b4a5dc1fe84361-s.3wm_n5jv1to7s.woff2 b/static/fonts/source-sans-3/94b4a5dc1fe84361-s.3wm_n5jv1to7s.woff2
new file mode 100644
index 0000000..1372ed2
Binary files /dev/null and b/static/fonts/source-sans-3/94b4a5dc1fe84361-s.3wm_n5jv1to7s.woff2 differ
diff --git a/static/fonts/source-sans-3/99596c30eb072d3c-s.2fp9tilo6sy14.woff2 b/static/fonts/source-sans-3/99596c30eb072d3c-s.2fp9tilo6sy14.woff2
new file mode 100644
index 0000000..f08c788
Binary files /dev/null and b/static/fonts/source-sans-3/99596c30eb072d3c-s.2fp9tilo6sy14.woff2 differ
diff --git a/static/fonts/source-sans-3/OFL.txt b/static/fonts/source-sans-3/OFL.txt
new file mode 100644
index 0000000..046fc66
--- /dev/null
+++ b/static/fonts/source-sans-3/OFL.txt
@@ -0,0 +1,93 @@
+Google Inc.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/static/fonts/source-sans-3/da8a9dd0d68e512b-s.0n8i_inn0i2i9.woff2 b/static/fonts/source-sans-3/da8a9dd0d68e512b-s.0n8i_inn0i2i9.woff2
new file mode 100644
index 0000000..1f0841d
Binary files /dev/null and b/static/fonts/source-sans-3/da8a9dd0d68e512b-s.0n8i_inn0i2i9.woff2 differ
diff --git a/static/fonts/source-sans-3/e285ad1a914469f0-s.15241mr5-1s6i.woff2 b/static/fonts/source-sans-3/e285ad1a914469f0-s.15241mr5-1s6i.woff2
new file mode 100644
index 0000000..985553e
Binary files /dev/null and b/static/fonts/source-sans-3/e285ad1a914469f0-s.15241mr5-1s6i.woff2 differ
diff --git a/static/fonts/source-sans-3/e5864b32625b6bc3-s.0m7i6b77o5yxd.woff2 b/static/fonts/source-sans-3/e5864b32625b6bc3-s.0m7i6b77o5yxd.woff2
new file mode 100644
index 0000000..6ee3041
Binary files /dev/null and b/static/fonts/source-sans-3/e5864b32625b6bc3-s.0m7i6b77o5yxd.woff2 differ
diff --git a/static/fonts/source-sans-3/ff5be760267bb4bc-s.3dkqd4mro2h3_.woff2 b/static/fonts/source-sans-3/ff5be760267bb4bc-s.3dkqd4mro2h3_.woff2
new file mode 100644
index 0000000..659091c
Binary files /dev/null and b/static/fonts/source-sans-3/ff5be760267bb4bc-s.3dkqd4mro2h3_.woff2 differ
diff --git a/static/icon.png b/static/icon.png
new file mode 100644
index 0000000..3a90ecd
Binary files /dev/null and b/static/icon.png differ
diff --git a/static/opengraph-image.jpg b/static/opengraph-image.jpg
new file mode 100644
index 0000000..19028b8
Binary files /dev/null and b/static/opengraph-image.jpg differ
diff --git a/tests/browser-shared-ui-visual.mjs b/tests/browser-shared-ui-visual.mjs
new file mode 100644
index 0000000..fff5500
--- /dev/null
+++ b/tests/browser-shared-ui-visual.mjs
@@ -0,0 +1,869 @@
+#!/usr/bin/env node
+/* global WebSocket, console, fetch, process */
+
+import { spawn, spawnSync } from 'node:child_process';
+import {
+ existsSync,
+ mkdirSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { request as httpRequest } from 'node:http';
+import { setTimeout as delay } from 'node:timers/promises';
+
+const root = process.cwd();
+const outputRoot =
+ process.env.SHARED_UI_BROWSER_OUT ??
+ join(tmpdir(), 'methodconf-shared-ui-browser');
+const fixtureRoot = join(outputRoot, 'fixture-copy');
+const port = Number(process.env.PORT ?? 4296);
+const local = `http://127.0.0.1:${port}`;
+const live = process.env.LIVE_SITE_URL ?? 'https://www.methodconf.com';
+const chromeBin = process.env.CHROME_BIN ?? '/usr/bin/google-chrome';
+const chromeUserAgent =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
+
+const fixtureServer = `import { error } from '@sveltejs/kit';
+import { getConference } from '#lib/server/getConference.ts';
+import { getSponsors } from '#lib/server/getSponsors.ts';
+export async function load({ fetch }) {
+ const conference = await getConference('2024', fetch);
+ if (!conference) error(500, 'Missing conference fixture data');
+ const sponsors = await getSponsors(conference.id, fetch);
+ return { conference, sponsors };
+}
+`;
+
+const fixturePage = `
+
+
+
+
+
+
+
Invest In Yourself and Hone Your Craft
+
Method Conference Springfield, MO is an immersive one-day conference with three tracks, two one-hour session tracks, a workshop track, and a keynote. All with an intense focus on digital development, design, UX, content, code, and more.
+
+
Register Now
+
Download Our Mobile App!
+
+
+
+
+
+
+
+
+
Stay in-the-know about event updates and new speaker announcements.
+
+
+
+
+
+`;
+
+const defaultTitle = 'Method Conference - October 12th 2024 - Springfield, MO';
+const notFoundTitle = '404: This page could not be found.';
+const fixtureLeafTitle = 'Fixture leaf title';
+const fixtureLeafOgTitle = 'Fixture leaf OG title';
+
+const headFixtureServer = `import { buildSharedHead } from '#lib/head.ts';
+import { getSiteUrl } from '#lib/server/config.ts';
+
+export function load() {
+ return {
+ sharedHead: buildSharedHead({
+ siteUrl: getSiteUrl().toString(),
+ metadata: {
+ title: '${fixtureLeafTitle}',
+ openGraph: { title: '${fixtureLeafOgTitle}' },
+ },
+ }),
+ };
+}
+`;
+
+const headFixturePage = `Head fixture
+Missing fixture
+`;
+
+const defaultFixturePage = `Default fixture
+Missing fixture
+Server error fixture
+`;
+
+const notFoundFixture = `import { error } from '@sveltejs/kit';
+
+export function load() {
+ error(404, 'Not found');
+}
+`;
+
+const serverErrorFixture = `import { error } from '@sveltejs/kit';
+
+export function load() {
+ error(500, 'Probe failure');
+}
+`;
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd ?? root,
+ env: options.env ?? process.env,
+ stdio: options.stdio ?? 'inherit',
+ });
+ if (result.status !== 0) {
+ throw new Error(
+ `${command} ${args.join(' ')} failed with ${result.status}`,
+ );
+ }
+ return result;
+}
+
+function httpJson(portNumber, path) {
+ return new Promise((resolve, reject) => {
+ const request = httpRequest(
+ { host: '127.0.0.1', port: portNumber, path },
+ (response) => {
+ let data = '';
+ response.setEncoding('utf8');
+ response.on('data', (chunk) => {
+ data += chunk;
+ });
+ response.on('end', () => {
+ try {
+ resolve(JSON.parse(data));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ },
+ );
+ request.on('error', reject);
+ request.end();
+ });
+}
+
+async function waitForHttp(url, attempts = 160) {
+ for (let index = 0; index < attempts; index += 1) {
+ try {
+ const response = await fetch(url, {
+ headers: { 'user-agent': chromeUserAgent },
+ });
+ if (response.status < 500) return;
+ } catch {
+ // retry below
+ }
+ await delay(250);
+ }
+ throw new Error(`server did not become ready: ${url}`);
+}
+
+class ChromeSession {
+ constructor() {
+ this.port = 12000 + Math.floor(Math.random() * 1000);
+ this.profile = join(
+ outputRoot,
+ `chrome-profile-${process.pid}-${this.port}`,
+ );
+ this.nextId = 1;
+ this.pending = new Map();
+ this.consoleMessages = [];
+ }
+
+ async start() {
+ this.process = spawn(
+ chromeBin,
+ [
+ '--headless=new',
+ `--remote-debugging-port=${this.port}`,
+ '--remote-allow-origins=*',
+ `--user-data-dir=${this.profile}`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ '--disable-gpu',
+ '--disable-dev-shm-usage',
+ '--force-device-scale-factor=1',
+ 'about:blank',
+ ],
+ { stdio: ['ignore', 'pipe', 'pipe'] },
+ );
+ this.stderr = '';
+ this.process.stderr.on('data', (data) => {
+ this.stderr += data.toString();
+ });
+
+ for (let index = 0; index < 160; index += 1) {
+ try {
+ await httpJson(this.port, '/json/version');
+ break;
+ } catch {
+ await delay(100);
+ }
+ if (index === 159)
+ throw new Error(
+ `Chrome did not start: ${this.stderr.slice(-1000)}`,
+ );
+ }
+
+ const targets = await httpJson(this.port, '/json/list');
+ const target = targets.find((item) => item.type === 'page');
+ this.ws = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise((resolve, reject) => {
+ this.ws.addEventListener('open', resolve, { once: true });
+ this.ws.addEventListener('error', reject, { once: true });
+ });
+ this.ws.addEventListener('message', (event) =>
+ this.onMessage(JSON.parse(event.data)),
+ );
+ await this.cdp('Page.enable');
+ await this.cdp('Runtime.enable');
+ await this.cdp('Log.enable');
+ await this.cdp('Network.enable');
+ await this.cdp('Network.setUserAgentOverride', {
+ userAgent: chromeUserAgent,
+ });
+ }
+
+ onMessage(message) {
+ if (message.method === 'Runtime.consoleAPICalled') {
+ this.consoleMessages.push({
+ source: 'console',
+ type: message.params.type,
+ text: message.params.args
+ .map((arg) => arg.value ?? arg.description ?? '')
+ .join(' '),
+ });
+ }
+ if (message.method === 'Runtime.exceptionThrown') {
+ this.consoleMessages.push({
+ source: 'exception',
+ type: 'error',
+ text: message.params.exceptionDetails?.text ?? 'exception',
+ });
+ }
+ if (message.method === 'Log.entryAdded') {
+ this.consoleMessages.push({
+ source: 'log',
+ type: message.params.entry.level,
+ text: message.params.entry.text,
+ });
+ }
+ if (message.id && this.pending.has(message.id)) {
+ const { resolve, reject } = this.pending.get(message.id);
+ this.pending.delete(message.id);
+ if (message.error) reject(new Error(JSON.stringify(message.error)));
+ else resolve(message.result ?? {});
+ }
+ }
+
+ cdp(method, params = {}) {
+ const id = this.nextId;
+ this.nextId += 1;
+ this.ws.send(JSON.stringify({ id, method, params }));
+ return new Promise((resolve, reject) =>
+ this.pending.set(id, { resolve, reject }),
+ );
+ }
+
+ async evaluate(expression) {
+ const result = await this.cdp('Runtime.evaluate', {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ });
+ if (result.exceptionDetails)
+ throw new Error(JSON.stringify(result.exceptionDetails));
+ return result.result?.value;
+ }
+
+ async waitForExpression(expression, attempts = 80) {
+ for (let index = 0; index < attempts; index += 1) {
+ if (await this.evaluate(expression)) return;
+ await delay(100);
+ }
+ throw new Error(`timed out waiting for ${expression}`);
+ }
+
+ async navigate(url, viewport) {
+ await this.cdp('Emulation.setDeviceMetricsOverride', {
+ width: viewport.width,
+ height: viewport.height,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await this.cdp(
+ 'Emulation.setTouchEmulationEnabled',
+ viewport.touch
+ ? { enabled: true, maxTouchPoints: 1 }
+ : { enabled: false },
+ );
+ await this.cdp('Page.navigate', { url });
+ for (let index = 0; index < 220; index += 1) {
+ if ((await this.evaluate('document.readyState')) === 'complete')
+ break;
+ await delay(100);
+ }
+ await this.evaluate(
+ 'document.fonts?.ready ? document.fonts.ready.then(() => true) : true',
+ );
+ await delay(250);
+ }
+
+ async settleSponsors() {
+ await this.evaluate(`(async () => {
+ const root = document.querySelector('#sponsor');
+ root.scrollIntoView({ block: 'start', inline: 'nearest' });
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ for (const img of root.querySelectorAll('img')) {
+ img.scrollIntoView({ block: 'center', inline: 'nearest' });
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ if (!img.complete) await new Promise((resolve) => {
+ img.addEventListener('load', resolve, { once: true });
+ img.addEventListener('error', resolve, { once: true });
+ });
+ }
+ root.scrollIntoView({ block: 'start', inline: 'nearest' });
+ await document.fonts.ready;
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ return true;
+ })()`);
+ }
+
+ async sponsorMeta() {
+ return await this.evaluate(`(() => {
+ const root = document.querySelector('#sponsor');
+ const rect = (el) => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top + scrollY, left: r.left + scrollX }; };
+ const css = (el) => { const s = getComputedStyle(el); return { color: s.color, fontFamily: s.fontFamily, fontSize: s.fontSize, fontWeight: s.fontWeight, lineHeight: s.lineHeight, display: s.display, objectFit: s.objectFit, transform: s.transform }; };
+ return {
+ url: location.href,
+ scrollY,
+ viewport: { width: innerWidth, height: innerHeight, devicePixelRatio },
+ body: { box: rect(document.body), css: css(document.body) },
+ main: document.querySelector('main') ? { box: rect(document.querySelector('main')), css: css(document.querySelector('main')) } : null,
+ root: { box: rect(root), css: css(root), text: root.textContent.replace(/\\s+/g, ' ').trim() },
+ imgs: [...root.querySelectorAll('img')].map((img) => ({ alt: img.alt, currentSrc: img.currentSrc, widthAttr: img.getAttribute('width'), heightAttr: img.getAttribute('height'), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, complete: img.complete, box: rect(img), css: css(img) })),
+ headings: [...root.querySelectorAll('h1,h2,h3,p')].map((el) => ({ tag: el.tagName, text: el.textContent.replace(/\\s+/g, ' ').trim(), box: rect(el), css: css(el) })),
+ };
+ })()`);
+ }
+
+ async captureSponsor(url, viewport) {
+ await this.navigate(url, viewport);
+ await this.settleSponsors();
+ return await this.sponsorMeta();
+ }
+
+ async stop() {
+ try {
+ this.ws?.close();
+ } catch {
+ // ignore cleanup errors
+ }
+ this.process?.kill('SIGTERM');
+ await delay(200);
+ if (existsSync(this.profile))
+ rmSync(this.profile, { recursive: true, force: true });
+ }
+}
+
+function boxDelta(liveBox, candidateBox, rootLive, rootCandidate) {
+ return {
+ x: candidateBox.x - liveBox.x,
+ width: candidateBox.width - liveBox.width,
+ height: candidateBox.height - liveBox.height,
+ yRelative: candidateBox.y - rootCandidate.y - (liveBox.y - rootLive.y),
+ };
+}
+
+function assertNear(value, limit, label, failures) {
+ if (Math.abs(value) > limit) failures.push(`${label}: ${value}`);
+}
+
+function compactText(value) {
+ return value.replace(/\s+/g, '');
+}
+
+function headHtml(html) {
+ return /]*>([\s\S]*?)<\/head>/i.exec(html)?.[1] ?? '';
+}
+
+function countHeadTags(head, pattern) {
+ return (head.match(pattern) ?? []).length;
+}
+
+function assertSharedHeadCounts(head, label, failures) {
+ const expectations = [
+ [/property="og:title"/g, 1, 'og:title'],
+ [/property="og:image"/g, 1, 'og:image'],
+ [/name="twitter:card"/g, 1, 'twitter:card'],
+ [/name="twitter:title"/g, 1, 'twitter:title'],
+ [/name="twitter:image"/g, 1, 'twitter:image'],
+ ];
+
+ for (const [pattern, expected, name] of expectations) {
+ const actual = countHeadTags(head, pattern);
+ if (actual !== expected) {
+ failures.push(
+ `${label}: expected ${expected} ${name}, got ${actual}`,
+ );
+ }
+ }
+}
+
+async function assertRawHead(localOrigin, failures) {
+ const cases = [
+ {
+ path: '/fixture-404/',
+ status: 404,
+ title: defaultTitle,
+ robots: ' ([^<]*)<\/title>/.exec(head);
+ const title = titleMatch?.[1] ?? '';
+ const robotsCount = countHeadTags(head, /name="robots"/g);
+
+ results.push({
+ path: entry.path,
+ status: response.status,
+ title,
+ robotsCount,
+ });
+
+ if (response.status !== entry.status) {
+ failures.push(
+ `${entry.path}: expected status ${entry.status}, got ${response.status}`,
+ );
+ }
+ if (countHeadTags(head, /]/g) !== 1) {
+ failures.push(`${entry.path}: expected one title tag`);
+ }
+ if (title !== entry.title) {
+ failures.push(`${entry.path}: title ${title}`);
+ }
+ if (robotsCount !== 1 || !head.includes(entry.robots)) {
+ failures.push(`${entry.path}: robots noindex count/value mismatch`);
+ }
+ assertSharedHeadCounts(head, entry.path, failures);
+ }
+
+ return results;
+}
+
+function prepareFixture() {
+ rmSync(outputRoot, { recursive: true, force: true });
+ mkdirSync(outputRoot, { recursive: true });
+ run('rsync', [
+ '-a',
+ '--exclude',
+ '.git',
+ '--exclude',
+ 'node_modules',
+ '--exclude',
+ '.next',
+ '--exclude',
+ '.svelte-kit',
+ '--exclude',
+ 'build',
+ `${root}/`,
+ `${fixtureRoot}/`,
+ ]);
+ symlinkSync(
+ join(root, 'node_modules'),
+ join(fixtureRoot, 'node_modules'),
+ 'dir',
+ );
+ const routeDir = join(fixtureRoot, 'src/routes/fixture-home');
+ mkdirSync(routeDir, { recursive: true });
+ writeFileSync(join(routeDir, '+page.server.ts'), fixtureServer);
+ writeFileSync(join(routeDir, '+page.svelte'), fixturePage);
+
+ const defaultRouteDir = join(fixtureRoot, 'src/routes/fixture-default');
+ mkdirSync(defaultRouteDir, { recursive: true });
+ writeFileSync(join(defaultRouteDir, '+page.svelte'), defaultFixturePage);
+
+ const headRouteDir = join(fixtureRoot, 'src/routes/fixture-title');
+ mkdirSync(headRouteDir, { recursive: true });
+ writeFileSync(join(headRouteDir, '+page.server.ts'), headFixtureServer);
+ writeFileSync(join(headRouteDir, '+page.svelte'), headFixturePage);
+
+ const notFoundRouteDir = join(fixtureRoot, 'src/routes/fixture-404');
+ mkdirSync(notFoundRouteDir, { recursive: true });
+ writeFileSync(join(notFoundRouteDir, '+page.server.ts'), notFoundFixture);
+
+ const errorRouteDir = join(fixtureRoot, 'src/routes/fixture-500');
+ mkdirSync(errorRouteDir, { recursive: true });
+ writeFileSync(join(errorRouteDir, '+page.server.ts'), serverErrorFixture);
+
+ run('npm', ['run', 'build'], { cwd: fixtureRoot });
+}
+
+async function readBrowserHead(chrome) {
+ return await chrome.evaluate(`(() => ({
+ url: location.pathname,
+ title: document.title,
+ ogTitle: document.querySelector('meta[property="og:title"]')?.content ?? null,
+ twitterTitle: document.querySelector('meta[name="twitter:title"]')?.content ?? null,
+ titleCount: document.querySelectorAll('title').length,
+ robots: [...document.querySelectorAll('meta[name="robots"]')].map((node) => node.content),
+ ogImageCount: document.querySelectorAll('meta[property="og:image"]').length,
+ twitterImageCount: document.querySelectorAll('meta[name="twitter:image"]').length,
+ csrMarker: window.__sharedUiCsrMarker ?? null,
+ }))()`);
+}
+
+async function clickAndWaitForTitle(chrome, selector, marker, title) {
+ await chrome.evaluate(`(() => {
+ window.__sharedUiCsrMarker = ${JSON.stringify(marker)};
+ document.querySelector(${JSON.stringify(selector)}).click();
+ return true;
+ })()`);
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(title)} && window.__sharedUiCsrMarker === ${JSON.stringify(marker)}`,
+ );
+}
+
+async function historyBackAndWaitForTitle(chrome, marker, title) {
+ await chrome.evaluate('history.back(); true');
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(title)} && window.__sharedUiCsrMarker === ${JSON.stringify(marker)}`,
+ );
+}
+
+async function historyForwardAndWaitForTitle(chrome, marker, title) {
+ await chrome.evaluate('history.forward(); true');
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(title)} && window.__sharedUiCsrMarker === ${JSON.stringify(marker)}`,
+ );
+}
+
+async function assertBrowserTitleLifecycle(chrome, failures) {
+ const titleViewport = { name: 'title-probe', width: 1024, height: 768 };
+ const snapshots = [];
+
+ await chrome.navigate(`${local}/fixture-404/`, titleViewport);
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(notFoundTitle)}`,
+ );
+ snapshots.push({
+ step: 'direct hydrated 404',
+ ...(await readBrowserHead(chrome)),
+ });
+
+ await chrome.navigate(`${local}/fixture-default/`, titleViewport);
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(defaultTitle)}`,
+ );
+ snapshots.push({
+ step: 'normal default',
+ ...(await readBrowserHead(chrome)),
+ });
+ await clickAndWaitForTitle(
+ chrome,
+ '[data-fixture="to-404"]',
+ 'default-to-404',
+ notFoundTitle,
+ );
+ snapshots.push({
+ step: 'default to 404',
+ ...(await readBrowserHead(chrome)),
+ });
+ await historyBackAndWaitForTitle(chrome, 'default-to-404', defaultTitle);
+ snapshots.push({
+ step: '404 back to default',
+ ...(await readBrowserHead(chrome)),
+ });
+
+ await chrome.navigate(`${local}/fixture-title/`, titleViewport);
+ await chrome.waitForExpression(
+ `document.title === ${JSON.stringify(fixtureLeafTitle)}`,
+ );
+ snapshots.push({ step: 'leaf custom', ...(await readBrowserHead(chrome)) });
+ await clickAndWaitForTitle(
+ chrome,
+ '[data-fixture="to-404"]',
+ 'leaf-to-404',
+ notFoundTitle,
+ );
+ snapshots.push({ step: 'leaf to 404', ...(await readBrowserHead(chrome)) });
+ await historyBackAndWaitForTitle(chrome, 'leaf-to-404', fixtureLeafTitle);
+ snapshots.push({
+ step: '404 back to leaf',
+ ...(await readBrowserHead(chrome)),
+ });
+ await historyForwardAndWaitForTitle(chrome, 'leaf-to-404', notFoundTitle);
+ snapshots.push({
+ step: 'forward to 404',
+ ...(await readBrowserHead(chrome)),
+ });
+ await historyBackAndWaitForTitle(chrome, 'leaf-to-404', fixtureLeafTitle);
+ snapshots.push({
+ step: 'back again to leaf',
+ ...(await readBrowserHead(chrome)),
+ });
+
+ await chrome.navigate(`${local}/fixture-default/`, titleViewport);
+ await clickAndWaitForTitle(
+ chrome,
+ '[data-fixture="to-500"]',
+ 'default-to-500',
+ '500: Probe failure',
+ );
+ snapshots.push({
+ step: 'default to 500',
+ ...(await readBrowserHead(chrome)),
+ });
+ await historyBackAndWaitForTitle(chrome, 'default-to-500', defaultTitle);
+ snapshots.push({
+ step: '500 back to default',
+ ...(await readBrowserHead(chrome)),
+ });
+
+ const direct404 = snapshots.find(
+ (item) => item.step === 'direct hydrated 404',
+ );
+ const defaultBack = snapshots.find(
+ (item) => item.step === '404 back to default',
+ );
+ const leafBack = snapshots.find((item) => item.step === '404 back to leaf');
+ const errorBack = snapshots.find(
+ (item) => item.step === '500 back to default',
+ );
+
+ if (direct404?.title !== notFoundTitle)
+ failures.push('direct hydrated 404 title mismatch');
+ if (defaultBack?.title !== defaultTitle)
+ failures.push('default title did not restore after 404');
+ if (leafBack?.title !== fixtureLeafTitle)
+ failures.push('leaf title did not restore after 404');
+ if (leafBack?.ogTitle !== fixtureLeafOgTitle)
+ failures.push('leaf og:title did not restore after 404');
+ if (errorBack?.title !== defaultTitle)
+ failures.push('default title did not restore after 500');
+
+ for (const snapshot of snapshots) {
+ if (snapshot.titleCount !== 1)
+ failures.push(
+ `${snapshot.step}: titleCount ${snapshot.titleCount}`,
+ );
+ if (
+ snapshot.title === notFoundTitle ||
+ snapshot.title.startsWith('500:')
+ ) {
+ if (
+ snapshot.robots.length !== 1 ||
+ snapshot.robots[0] !== 'noindex'
+ ) {
+ failures.push(
+ `${snapshot.step}: expected one noindex robots tag`,
+ );
+ }
+ } else if (snapshot.robots.length !== 0) {
+ failures.push(
+ `${snapshot.step}: expected robots tags to be restored away`,
+ );
+ }
+ if (snapshot.ogImageCount !== 1 || snapshot.twitterImageCount !== 1) {
+ failures.push(`${snapshot.step}: OG/Twitter image count mismatch`);
+ }
+ }
+
+ const hydrationConsoleErrors = chrome.consoleMessages.filter((message) => {
+ const text = message.text.toLowerCase();
+ return text.includes('hydration') || text.includes('svelte');
+ });
+ if (hydrationConsoleErrors.length > 0) {
+ failures.push('Svelte hydration/browser console errors were reported');
+ }
+
+ return { snapshots, hydrationConsoleErrors };
+}
+
+prepareFixture();
+
+const server = spawn(process.execPath, ['build'], {
+ cwd: fixtureRoot,
+ env: {
+ ...process.env,
+ PORT: String(port),
+ HOST: '127.0.0.1',
+ UMBRACO_BASE_URL: 'https://cms.methodconf.com/',
+ CMS_PUBLIC_URL: 'https://cms.methodconf.com/',
+ SITE_URL: 'https://www.methodconf.com/',
+ SEARCH_INDEXING_ENABLED: 'true',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+});
+let serverLog = '';
+server.stdout.on('data', (data) => {
+ serverLog += data.toString();
+});
+server.stderr.on('data', (data) => {
+ serverLog += data.toString();
+});
+
+const viewports = [
+ { name: 'desktop-1440x1000', width: 1440, height: 1000 },
+ { name: 'tablet-1024x900', width: 1024, height: 900 },
+ { name: 'mobile-390x844', width: 390, height: 844 },
+ { name: 'narrow-touch-375x844', width: 375, height: 844, touch: true },
+];
+
+const chrome = new ChromeSession();
+try {
+ await waitForHttp(`${local}/fixture-home/`);
+ await chrome.start();
+ const results = [];
+ const failures = [];
+ const rawHead = await assertRawHead(local, failures);
+ const titleLifecycle = await assertBrowserTitleLifecycle(chrome, failures);
+
+ for (const viewport of viewports) {
+ const liveMeta = await chrome.captureSponsor(`${live}/2024/`, viewport);
+ const candidateMeta = await chrome.captureSponsor(
+ `${local}/fixture-home/`,
+ viewport,
+ );
+ const cta = candidateMeta.imgs.find((img) => img.alt === 'Method Logo');
+
+ if (candidateMeta.root.css.color !== 'rgb(0, 0, 0)') {
+ failures.push(
+ `${viewport.name}: sponsor root color ${candidateMeta.root.css.color}`,
+ );
+ }
+ if (candidateMeta.main?.css.color !== 'rgb(0, 0, 0)') {
+ failures.push(
+ `${viewport.name}: main color ${candidateMeta.main?.css.color}`,
+ );
+ }
+ if (cta?.widthAttr !== '359' || cta?.heightAttr !== '78') {
+ failures.push(
+ `${viewport.name}: CTA attrs ${cta?.widthAttr}x${cta?.heightAttr}`,
+ );
+ }
+ if (
+ compactText(liveMeta.root.text) !==
+ compactText(candidateMeta.root.text)
+ ) {
+ failures.push(`${viewport.name}: sponsor text content changed`);
+ }
+ if (
+ !liveMeta.imgs.every((img) => img.complete) ||
+ !candidateMeta.imgs.every((img) => img.complete)
+ ) {
+ failures.push(
+ `${viewport.name}: sponsor images did not finish loading`,
+ );
+ }
+
+ const imageGeometry = liveMeta.imgs.map((liveImg, index) => {
+ const candidateImg = candidateMeta.imgs[index];
+ const delta = boxDelta(
+ liveImg.box,
+ candidateImg.box,
+ liveMeta.root.box,
+ candidateMeta.root.box,
+ );
+ assertNear(
+ delta.x,
+ 0.05,
+ `${viewport.name} ${liveImg.alt} x`,
+ failures,
+ );
+ assertNear(
+ delta.width,
+ 0.05,
+ `${viewport.name} ${liveImg.alt} width`,
+ failures,
+ );
+ assertNear(
+ delta.height,
+ 0.05,
+ `${viewport.name} ${liveImg.alt} height`,
+ failures,
+ );
+ assertNear(
+ delta.yRelative,
+ 0.05,
+ `${viewport.name} ${liveImg.alt} relative y`,
+ failures,
+ );
+ return { label: liveImg.alt, delta };
+ });
+
+ results.push({
+ viewport,
+ colors: {
+ liveRoot: liveMeta.root.css.color,
+ candidateRoot: candidateMeta.root.css.color,
+ candidateMain: candidateMeta.main?.css.color ?? null,
+ },
+ sectionHeight: {
+ live: liveMeta.root.box.height,
+ candidate: candidateMeta.root.box.height,
+ },
+ cta: {
+ widthAttr: cta?.widthAttr ?? null,
+ heightAttr: cta?.heightAttr ?? null,
+ },
+ imageGeometry,
+ text: {
+ live: liveMeta.root.text,
+ candidate: candidateMeta.root.text,
+ },
+ });
+ }
+
+ const summary = {
+ generatedAt: new Date().toISOString(),
+ local,
+ live,
+ results,
+ rawHead,
+ titleLifecycle,
+ failures,
+ serverLog,
+ };
+ writeFileSync(
+ join(outputRoot, 'browser-shared-ui-visual-summary.json'),
+ JSON.stringify(summary, null, 2),
+ );
+ console.log(JSON.stringify(summary, null, 2));
+ if (failures.length > 0) process.exitCode = 1;
+} finally {
+ await chrome.stop();
+ server.kill('SIGTERM');
+}
diff --git a/tests/route-smoke.mjs b/tests/route-smoke.mjs
index 458558d..828a40b 100644
--- a/tests/route-smoke.mjs
+++ b/tests/route-smoke.mjs
@@ -314,6 +314,77 @@ async function expectText(origin, path, status, text, init = {}) {
return response;
}
+function headHtml(html) {
+ const match = /]*>([\s\S]*?)<\/head>/i.exec(html);
+ assert.ok(match, 'missing head');
+ return match[1];
+}
+
+function countHeadTags(head, pattern) {
+ return (head.match(pattern) ?? []).length;
+}
+
+function assertDefaultSharedHead(head, path) {
+ assert.equal(countHeadTags(head, /]/g), 1, path);
+ assert.match(
+ head,
+ /Method Conference - October 12th 2024 - Springfield, MO<\/title>/,
+ path,
+ );
+ assert.equal(countHeadTags(head, /property="og:title"/g), 1, path);
+ assert.match(
+ head,
+ / here<\/a>/,
);
}
+async function runHeadChecks(origin, { searchIndexingEnabled }) {
+ const defaultPage = await expectHead(origin, '/mobile-app/', 200, {
+ headers: { 'user-agent': 'Mozilla/5.0 Macintosh' },
+ });
+ assertDefaultSharedHead(defaultPage.head, '/mobile-app/');
+ assert.equal(
+ countHeadTags(defaultPage.head, /name="robots"/g),
+ searchIndexingEnabled ? 0 : 1,
+ '/mobile-app/ robots',
+ );
+ if (!searchIndexingEnabled) {
+ assert.match(
+ defaultPage.head,
+ / = 0, '404 shell missing');
+ assert.ok(footerIndex > shellIndex, '404 footer should render below shell');
+ const notFoundCss = await stylesheetText(
+ origin,
+ '/2024/nope/',
+ notFound.head,
+ );
+ assert.match(notFoundCss, /height:\s*100vh/);
+ assert.match(notFoundCss, /font-family:\s*system-ui/);
+}
+
async function runRobotChecks(upstreamUrl) {
const before = state.cmsRequests;
const enabledApp = await startApp({
@@ -454,6 +561,9 @@ async function runRobotChecks(upstreamUrl) {
response.headers.get('content-type') ?? '',
/^text\/plain/,
);
+ await runHeadChecks(enabledApp.origin, {
+ searchIndexingEnabled: true,
+ });
} finally {
await enabledApp.close();
}
@@ -469,6 +579,9 @@ async function runRobotChecks(upstreamUrl) {
200,
'User-Agent: *\nDisallow: /\n\n',
);
+ await runHeadChecks(disabledApp.origin, {
+ searchIndexingEnabled: false,
+ });
} finally {
await disabledApp.close();
}
@@ -705,6 +818,7 @@ try {
try {
await runRouteChecks(app.origin);
await runMobileChecks(app.origin);
+ await runHeadChecks(app.origin, { searchIndexingEnabled: true });
await runMediaChecks(app.origin);
await runImageOptimizerChecks(app.origin);
} finally {
diff --git a/tests/unit/shared-ui-assets.test.ts b/tests/unit/shared-ui-assets.test.ts
new file mode 100644
index 0000000..0bd437a
--- /dev/null
+++ b/tests/unit/shared-ui-assets.test.ts
@@ -0,0 +1,133 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_TITLE,
+ NOT_FOUND_BROWSER_TITLE,
+ resolveDocumentTitle,
+} from '../../src/lib/head.ts';
+
+const root = process.cwd();
+
+function readProjectFile(path: string): string {
+ return readFileSync(join(root, path), 'utf8');
+}
+
+describe('shared UI static assets and root shell', () => {
+ it('self-hosts the live Source Sans 3 font contract and license', () => {
+ const css = readProjectFile('src/app.css');
+ const fontDir = join(root, 'static/fonts/source-sans-3');
+ const fonts = [
+ 'da8a9dd0d68e512b-s.0n8i_inn0i2i9.woff2',
+ '94b4a5dc1fe84361-s.3wm_n5jv1to7s.woff2',
+ 'ff5be760267bb4bc-s.3dkqd4mro2h3_.woff2',
+ '99596c30eb072d3c-s.2fp9tilo6sy14.woff2',
+ 'e5864b32625b6bc3-s.0m7i6b77o5yxd.woff2',
+ 'e285ad1a914469f0-s.15241mr5-1s6i.woff2',
+ '47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2',
+ ];
+
+ for (const font of fonts) {
+ expect(existsSync(join(fontDir, font))).toBe(true);
+ expect(css).toContain(`/fonts/source-sans-3/${font}`);
+ }
+
+ expect(css.match(/font-family: "Source Sans 3";/g)).toHaveLength(7);
+ expect(css).toContain('font-weight: 200 900;');
+ expect(css).toContain('font-display: swap;');
+ expect(css).toContain('font-family: "Source Sans 3 Fallback";');
+ expect(css).toContain('ascent-override: 109.21%;');
+ expect(css).toContain('descent-override: 42.66%;');
+ expect(css).toContain('size-adjust: 93.76%;');
+ expect(css).toContain(
+ '--font-sans: "Source Sans 3", "Source Sans 3 Fallback";',
+ );
+
+ const license = readProjectFile('static/fonts/source-sans-3/OFL.txt');
+ expect(license).toContain('SIL OPEN FONT LICENSE');
+ });
+
+ it('keeps root typography from live Next instead of forcing brand text color globally', () => {
+ const layout = readProjectFile('src/routes/+layout.svelte');
+ const css = readProjectFile('src/app.css');
+
+ expect(layout).toContain('');
+ expect(layout).not.toContain(
+ '',
+ );
+ expect(css).toContain('--color-secondary: #1E1E1E;');
+ expect(css).not.toMatch(
+ /body\s*\{[^}]*color:\s*(?:var\(--color-secondary\)|#1E1E1E|rgb\(30,\s*30,\s*30\))/s,
+ );
+ });
+
+ it('preloads only the Latin subset and keeps next-plausible script parity', () => {
+ const layout = readProjectFile('src/routes/+layout.svelte');
+
+ expect(layout).toContain('47df9ba1c7236d3b-s.p.137759vg1sbmi.woff2');
+ expect(layout).not.toContain('e285ad1a914469f0-s.15241mr5-1s6i.woff2');
+ expect(layout).toContain('next-plausible-script');
+ expect(layout).toContain(
+ 'https://plausible.sgf.dev/js/pa-MKQsdxqo5_oFk0NmM56b1.js',
+ );
+ expect(layout).toContain('plausible.init()');
+ expect(layout).toContain('');
+ expect(layout).not.toContain('page.status >= 400');
+ });
+
+ it('keeps a root-owned title lifecycle for hydrated 404s and errors', () => {
+ const layout = readProjectFile('src/routes/+layout.svelte');
+ const errorPage = readProjectFile('src/routes/+error.svelte');
+
+ expect(layout).toContain('clientMounted = $state(false)');
+ expect(layout).toContain('resolveDocumentTitle({');
+ expect(layout).toContain('status: page.status');
+ expect(layout).toContain('errorMessage: page.error?.message');
+ expect(layout).toContain('{documentTitle} ');
+ expect(errorPage).not.toContain(' {
+ expect(
+ resolveDocumentTitle({
+ sharedTitle: DEFAULT_TITLE,
+ status: 404,
+ clientMounted: false,
+ }),
+ ).toBe(DEFAULT_TITLE);
+ expect(
+ resolveDocumentTitle({
+ sharedTitle: DEFAULT_TITLE,
+ status: 404,
+ clientMounted: true,
+ }),
+ ).toBe(NOT_FOUND_BROWSER_TITLE);
+ expect(
+ resolveDocumentTitle({
+ sharedTitle: 'Fixture leaf title',
+ status: 200,
+ clientMounted: true,
+ }),
+ ).toBe('Fixture leaf title');
+ expect(
+ resolveDocumentTitle({
+ sharedTitle: DEFAULT_TITLE,
+ status: 500,
+ errorMessage: 'Probe failure',
+ clientMounted: true,
+ }),
+ ).toBe('500: Probe failure');
+ expect(
+ resolveDocumentTitle({
+ sharedTitle: DEFAULT_TITLE,
+ status: 500,
+ clientMounted: false,
+ }),
+ ).toBe('500: Application error');
+ });
+});
diff --git a/tests/unit/shared-ui-components.test.ts b/tests/unit/shared-ui-components.test.ts
new file mode 100644
index 0000000..69e1755
--- /dev/null
+++ b/tests/unit/shared-ui-components.test.ts
@@ -0,0 +1,187 @@
+import { render } from 'svelte/server';
+import { describe, expect, it } from 'vitest';
+import Button from '../../src/lib/components/Button.svelte';
+import DefaultNav from '../../src/lib/components/DefaultNav.svelte';
+import HomeNav from '../../src/lib/components/HomeNav.svelte';
+import Navigation from '../../src/lib/components/Navigation.svelte';
+import RichText from '../../src/lib/components/RichText.svelte';
+import SponsorCard from '../../src/lib/components/SponsorCard.svelte';
+import SponsorsBlock from '../../src/lib/components/SponsorsBlock.svelte';
+import TextInput from '../../src/lib/components/TextInput.svelte';
+import { rewriteRichTextMediaUrls } from '../../src/lib/components/richText.ts';
+import type {
+ CmsLink,
+ ParsedConference,
+ Sponsor,
+ Sponsors,
+} from '../../src/lib/types.ts';
+
+const conference = {
+ contentType: 'conference',
+ name: '2024',
+ id: 'conference-id',
+ route: { path: '/2024/', startItem: { id: 'root', path: '/' } },
+ properties: {
+ date: new Date('2024-10-12T05:00:00.000Z'),
+ },
+} as ParsedConference;
+
+describe('shared UI components', () => {
+ it('renders URL, CMS content, external, and media buttons with original classes', () => {
+ expect(
+ render(Button, { props: { url: '/2024/register/' } }).body,
+ ).toContain('class="button block"');
+ expect(
+ render(Button, { props: { url: 'https://example.com' } }).body,
+ ).toContain('target="_blank"');
+
+ const contentLink: CmsLink = {
+ linkType: 'Content',
+ title: 'Read more',
+ queryString: '?ref=nav',
+ route: {
+ path: '/2024/code-of-conduct/',
+ startItem: { id: 'root', path: '/' },
+ },
+ };
+ expect(
+ render(Button, { props: { cmsLink: contentLink } }).body,
+ ).toContain('href="/2024/code-of-conduct/?ref=nav"');
+
+ const mediaLink: CmsLink = {
+ linkType: 'Media',
+ title: 'Map',
+ url: '/media/wixhir55/building-map.pdf',
+ };
+ expect(
+ render(Button, { props: { cmsLink: mediaLink } }).body,
+ ).toContain('href="/cms-media/media/wixhir55/building-map.pdf"');
+ });
+
+ it('keeps text input label, input name, and peer focus classes', () => {
+ const html = render(TextInput, {
+ props: {
+ label: 'Email',
+ name: 'email',
+ type: 'email',
+ required: true,
+ },
+ }).body;
+
+ expect(html).toContain('text-input-root');
+ expect(html).toContain('text-input-backdrop');
+ expect(html).toContain('text-input-field');
+ expect(html).toContain('name="email"');
+ expect(html).toContain('type="email"');
+ expect(html).toContain('required');
+ expect(html).toContain('peer-focus:text-black');
+ expect(html).toContain('>Email');
+ });
+
+ it('renders navigation and home header links with responsive skyline picture', () => {
+ const navHtml = render(Navigation, {
+ props: { links: [{ url: '#schedule', title: 'Schedule' }] },
+ }).body;
+ expect(navHtml).toContain('class="w-full bg-primary"');
+ expect(navHtml).toContain('href="#schedule"');
+
+ const homeHtml = render(HomeNav, {
+ props: { conference, params: { conference: '2024' } },
+ }).body;
+ expect(homeHtml).toContain('src="/method-logo.svg"');
+ expect(homeHtml).toContain('media="(min-width: 640px)"');
+ expect(homeHtml).toContain('srcset="/skyline.svg"');
+ expect(homeHtml).toContain('src="/skyline-mobile.svg"');
+ expect(homeHtml).toContain('href="/2024/register/"');
+ });
+
+ it('renders default nav date and links using the shared date formatter', () => {
+ const html = render(DefaultNav, {
+ props: { conference, params: { conference: '2024' } },
+ }).body;
+
+ expect(html).toContain(
+ 'Saturday, October 12th, 2024 in Springfield, MO',
+ );
+ expect(html).toContain('href="/2024/"');
+ expect(html).toContain('href="/2024/register/"');
+ });
+
+ it('rewrites only CMS rich text media URLs before trusted HTML rendering', () => {
+ expect(
+ rewriteRichTextMediaUrls(
+ 'Map External
',
+ ),
+ ).toBe(
+ 'Map External
',
+ );
+
+ const html = render(RichText, {
+ props: {
+ markup: '
',
+ class: 'mb-5',
+ },
+ }).body;
+ expect(html).toContain('prose-a:text-primary');
+ expect(html).toContain('class="prose');
+ expect(html).toContain('src="/cms-media/media/logo.png"');
+ });
+
+ it('keeps the sponsor CTA Method logo intrinsic dimensions', () => {
+ const sponsors = {
+ id: 'sponsors-id',
+ contentType: 'sponsors',
+ name: 'Sponsors',
+ properties: {
+ tiers: { items: [] },
+ opportunitiesUrl: [
+ {
+ linkType: 'Media',
+ title: 'See Opportunities',
+ url: '/media/sponsor-packet.pdf',
+ },
+ ],
+ },
+ } as unknown as Sponsors;
+
+ const html = render(SponsorsBlock, { props: { sponsors } }).body;
+
+ expect(html).toContain('src="/method-logo.svg"');
+ expect(html).toContain('alt="Method Logo"');
+ expect(html).toContain('width="359"');
+ expect(html).toContain('height="78"');
+ });
+
+ it('uses the fixed-width CMS image helper at quality 100 for sponsor logos', () => {
+ const sponsor = {
+ id: 'sponsor-id',
+ contentType: 'sponsor',
+ properties: {
+ title: 'Acme',
+ url: 'https://example.com',
+ logo: [
+ {
+ id: 'logo-id',
+ name: 'Acme',
+ mediaType: 'Image',
+ url: '/media/logo.svg',
+ width: 300,
+ height: 120,
+ properties: null,
+ },
+ ],
+ },
+ } as Sponsor;
+
+ const html = render(SponsorCard, {
+ props: { sponsor, cardSize: 'medium' },
+ }).body;
+
+ expect(html).toContain('href="https://example.com"');
+ expect(html).toContain('alt="Acme logo"');
+ expect(html).toContain('q=100');
+ expect(html).toContain('width="300"');
+ expect(html).toContain('height="120"');
+ expect(html).toContain('min-h-[80px] sm:min-h-[150px]');
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 4a3cf18..539a64e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,6 +15,7 @@
".svelte-kit/ambient.d.ts",
".svelte-kit/non-ambient.d.ts",
".svelte-kit/types/**/*.d.ts",
+ "src/app.d.ts",
"src/app.html",
"src/app.css",
"src/env.ts",