Frozen in amber
Some checks are pending
Publish preview / build (push) Waiting to run
Release / release (push) Waiting to run
Release / notify-downstream (push) Blocked by required conditions

This commit is contained in:
Ben Aultowski 2026-02-16 20:16:27 -05:00
parent 1d7d05aea3
commit da66707338
38 changed files with 812 additions and 1152 deletions

6
.gitattributes vendored Normal file
View file

@ -0,0 +1,6 @@
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.flac filter=lfs diff=lfs merge=lfs -text
*.ait filter=lfs diff=lfs merge=lfs -text
*.ai filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text

4
.gitignore vendored
View file

@ -188,6 +188,10 @@ dist
# SvelteKit build / generate output # SvelteKit build / generate output
.svelte-kit .svelte-kit
/build
# Copied story assets (generated by scripts/copy-assets.js)
/static/stories
# End of https://www.toptal.com/developers/gitignore/api/node,macos,linux # End of https://www.toptal.com/developers/gitignore/api/node,macos,linux

View file

@ -0,0 +1,23 @@
headline: Hello World
dek: Is this thing on?
section: Test
publishTime: 2026-02-16T12:00:00Z
[+body]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sem nulla, interdum eu justo et, scelerisque vehicula mi. Vivamus turpis purus, elementum eu iaculis eget, finibus varius est. In rhoncus eros id dolor tempor efficitur. Fusce nec bibendum sapien. In in mattis felis. Fusce nec massa faucibus, gravida libero vel, ultricies justo. Vivamus sagittis dui eget urna pharetra, ut convallis leo viverra. Integer a risus viverra, tristique enim nec, gravida ligula. Ut id sem non ligula sodales venenatis.
Curabitur eu gravida neque. Proin tempus dui non fringilla interdum. Morbi dui sem, auctor ac condimentum vestibulum, vehicula eu nunc. Nunc pellentesque sit amet massa in mattis. Nulla tincidunt arcu sed nulla bibendum, ut lobortis neque luctus. Etiam auctor imperdiet mattis. Aliquam pretium accumsan vestibulum. Nulla facilisi.
Pellentesque commodo ullamcorper felis sed semper. Sed id metus sed tortor cursus molestie id id purus. Curabitur vestibulum, nisi in iaculis venenatis, leo massa mattis urna, ac mollis lacus est eu ipsum. In eget urna diam. Mauris vehicula molestie risus in molestie. Nullam erat erat, auctor ac ligula vel, iaculis venenatis neque. Sed rutrum suscipit purus. Phasellus id nulla id mi suscipit faucibus sed in leo. Quisque dictum justo sit amet tellus venenatis, ut tristique justo volutpat. Curabitur quis purus sapien. Nam blandit congue sem, in feugiat justo fermentum quis. Aliquam sagittis vehicula nulla, quis tincidunt neque fringilla ut.
Sed consequat dui nec elit consequat semper. Ut nec sapien interdum, suscipit ante et, porta odio. Cras a mattis ligula, vitae euismod urna. Aenean auctor felis quis lacus semper, in rutrum quam blandit. Pellentesque aliquet quis dolor id maximus. Donec porttitor varius imperdiet. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent egestas vitae neque fermentum tempor. Vivamus quis scelerisque sapien. Proin ac nisl tristique, porta arcu quis, ullamcorper felis. Mauris et mi ipsum. Vestibulum molestie eget velit vitae fermentum.
Nam facilisis rutrum dolor nec pretium. Nam finibus tristique dolor at cursus. Fusce est massa, dictum a hendrerit ut, sollicitudin ac nunc. Curabitur tincidunt scelerisque auctor. Pellentesque congue erat at purus molestie pretium. Ut eros augue, posuere a dolor eu, convallis aliquet erat. Cras eu nibh sed nulla imperdiet ullamcorper. Cras ut malesuada nisl, at gravida sapien. Aliquam vitae justo at tortor fringilla elementum. Etiam aliquam purus ut dolor rhoncus, ac tempor elit feugiat. Donec maximus elit vestibulum odio ullamcorper, sed gravida enim semper. Cras dignissim iaculis odio, non posuere justo imperdiet nec. Vivamus finibus scelerisque ornare. Suspendisse nunc tortor, condimentum quis eros non, facilisis blandit diam. Ut ac varius nisl, at viverra odio. Vivamus enim orci, rutrum sit amet ex nec, feugiat sollicitudin metus.
[/body]
[endNotes]
title: End Note
text: Get wrecked, nerd!
[/endNotes]

View file

@ -1,52 +1,27 @@
{ {
"name": "@reuters-graphics/graphics-components", "name": "hypnagaga",
"version": "3.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"private": false, "private": true,
"homepage": "https://reuters-graphics.github.io/graphics-components",
"repository": {
"type": "git",
"url": "git+https://github.com/reuters-graphics/graphics-components.git"
},
"packageManager": "pnpm@9.13.2", "packageManager": "pnpm@9.13.2",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"scripts": { "scripts": {
"start": "storybook dev -p 3000", "dev": "svelte-kit sync && vite dev",
"build": "svelte-kit sync && node scripts/copy-assets.js && vite build",
"preview": "vite preview",
"storybook": "svelte-kit sync && storybook dev -p 3000",
"build:storybook": "svelte-kit sync && storybook build -o docs",
"lint": "eslint --fix", "lint": "eslint --fix",
"format": "prettier . --write", "format": "prettier . --write",
"build": "rimraf ./dist && svelte-package -i ./src && publint",
"build:docs": "storybook build -o docs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"changeset:version": "changeset version",
"changeset:publish": "git add --all && changeset publish",
"knip": "knip", "knip": "knip",
"test": "vitest" "test": "vitest"
}, },
"license": "MIT", "license": "MIT",
"files": [
"dist",
"!dist/**/*.stories.*",
"!dist/**/*.mdx",
"!dist/**/demo",
"!dist/docs",
"!dist/**/*.test.*",
"!dist/**/*.spec.*",
"!dist/**/*.mp4",
"!dist/**/*.mov",
"!dist/**/images"
],
"engines": { "engines": {
"node": ">=20.18" "node": ">=20.18"
}, },
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.29.2",
"@chromatic-com/storybook": "^3.2.6", "@chromatic-com/storybook": "^3.2.6",
"@reuters-graphics/yaks-eslint": "^0.1.1", "@reuters-graphics/yaks-eslint": "^0.1.1",
"@reuters-graphics/yaks-prettier": "^0.1.1", "@reuters-graphics/yaks-prettier": "^0.1.1",
@ -61,7 +36,7 @@
"@storybook/sveltekit": "^8.6.12", "@storybook/sveltekit": "^8.6.12",
"@storybook/test": "^8.6.12", "@storybook/test": "^8.6.12",
"@storybook/theming": "^8.6.12", "@storybook/theming": "^8.6.12",
"@sveltejs/package": "^2.3.11", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/css": "^0.0.37", "@types/css": "^0.0.37",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
@ -91,12 +66,10 @@
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prism-themes": "^1.9.0", "prism-themes": "^1.9.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"publint": "^0.3.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rimraf": "^6.0.1",
"sass": "^1.86.3", "sass": "^1.86.3",
"storybook": "^8.6.12", "storybook": "^8.6.12",
"svelte": "^5.28.1", "svelte": "^5.28.1",
@ -109,6 +82,7 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@lottiefiles/dotlottie-web": "^0.52.2", "@lottiefiles/dotlottie-web": "^0.52.2",
"@reuters-graphics/svelte-markdown": "^0.0.3", "@reuters-graphics/svelte-markdown": "^0.0.3",
"@rferl/veronica": "github:rferl/veronica",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"es-toolkit": "^1.35.0", "es-toolkit": "^1.35.0",
@ -122,18 +96,5 @@
"svelte-intersection-observer": "^1.0.0", "svelte-intersection-observer": "^1.0.0",
"ua-parser-js": "^2.0.3", "ua-parser-js": "^2.0.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js",
"default": "./dist/index.js"
},
"./scss/*": "./dist/scss/*"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"bugs": {
"url": "https://github.com/reuters-graphics/graphics-components/issues"
} }
} }

File diff suppressed because it is too large Load diff

61
scripts/copy-assets.js Normal file
View file

@ -0,0 +1,61 @@
/**
* Copies story assets (images, etc.) from content/stories/<slug>/
* into static/stories/<slug>/ so they're available as static files
* during the build.
*
* Run before `vite build`: node scripts/copy-assets.js
*/
import { readdirSync, cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
const CONTENT_DIR = resolve('content/stories');
const STATIC_DIR = resolve('static/stories');
if (!existsSync(CONTENT_DIR)) {
console.log('No content/stories directory found, skipping asset copy.');
process.exit(0);
}
if (!existsSync(STATIC_DIR)) {
mkdirSync(STATIC_DIR, { recursive: true });
}
const slugs = readdirSync(CONTENT_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
let copied = 0;
for (const slug of slugs) {
const storyDir = join(CONTENT_DIR, slug);
const destDir = join(STATIC_DIR, slug);
const entries = readdirSync(storyDir, { withFileTypes: true });
for (const entry of entries) {
// Skip the .aml file itself
if (entry.name.endsWith('.aml')) continue;
const srcPath = join(storyDir, entry.name);
const destPath = join(destDir, entry.name);
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
if (entry.isDirectory()) {
cpSync(srcPath, destPath, { recursive: true });
copied++;
} else if (entry.isFile()) {
const srcStat = statSync(srcPath);
// Only copy if dest doesn't exist or is older
if (!existsSync(destPath) || statSync(destPath).mtimeMs < srcStat.mtimeMs) {
cpSync(srcPath, destPath);
copied++;
}
}
}
}
console.log(`Copied ${copied} asset(s) from ${slugs.length} story folder(s).`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 KiB

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 131 B

View file

@ -1,197 +1,110 @@
<!-- @component `SEO` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-ads-analytic-seo--docs) --> <!-- @component `SEO` — Provides page-level metadata, Open Graph, and JSON-LD structured data. -->
<script lang="ts"> <script lang="ts">
interface GraphicAuthor { interface Author {
name: string; name: string;
link: string; link?: string;
} }
interface Props { interface Props {
/** /** Page title for search engines */
* Base url for the page, which in [Vite-based projects](https://vitejs.dev/guide/build.html#public-base-path) is globally available as `import.meta.env.BASE_URL`.
*/
baseUrl: string;
/**
* [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object for the page.
*/
pageUrl: URL;
/**
* SEO title
*/
seoTitle: string; seoTitle: string;
/** /** Meta description */
* SEO description
*/
seoDescription: string; seoDescription: string;
/** /** Canonical URL for the page */
* Share title canonicalUrl?: string;
*/ /** Title for social sharing (defaults to seoTitle) */
shareTitle: string; shareTitle?: string;
/** /** Description for social sharing (defaults to seoDescription) */
* Share description shareDescription?: string;
*/ /** Absolute URL to share image */
shareDescription: string; shareImgPath?: string;
/** /** Alt text for share image */
* Share image path. **Must be an absolute path.**
*/
shareImgPath: string;
/**
* Share image alt text, up to 420 characters.
*/
shareImgAlt?: string; shareImgAlt?: string;
/** /** Publish time as ISO string */
* Publish time as an [ISO string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
*/
publishTime?: string; publishTime?: string;
/** /** Updated time as ISO string */
* Updated time as an [ISO string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
*/
updateTime?: string; updateTime?: string;
/** /** Authors */
* Array of authors for the piece. Each author object must have `name` and `link` attributes. authors?: Author[];
*/ /** Site name for og:site_name */
authors?: GraphicAuthor[]; siteName?: string;
} }
let { let {
baseUrl,
pageUrl,
seoTitle, seoTitle,
seoDescription, seoDescription,
canonicalUrl = '',
shareTitle, shareTitle,
shareDescription, shareDescription,
shareImgPath, shareImgPath = '',
shareImgAlt = '', shareImgAlt = '',
publishTime = '', publishTime = '',
updateTime = '', updateTime = '',
authors = [], authors = [],
siteName = 'Hypnagaga',
}: Props = $props(); }: Props = $props();
const getOrigin = (baseUrl: string) => {
try {
return new URL(baseUrl).origin;
} catch {
// This handles a weird case where Vite's base path is
// reset to './' after the app hydrates...
if (typeof window !== 'undefined') return getOrigin(window.location.href);
return '';
}
};
let origin = $derived(getOrigin(baseUrl)); let ogTitle = $derived(shareTitle ?? seoTitle);
let canonicalUrl = $derived( let ogDescription = $derived(shareDescription ?? seoDescription);
(origin + (pageUrl?.pathname || '')).replace(/index\.html\/$/, '')
);
const orgLdJson = {
'@context': 'http://schema.org',
'@type': 'NewsMediaOrganization',
'@id': 'https://www.reuters.com/#publisher',
name: 'Reuters',
logo: {
'@type': 'ImageObject',
url: 'https://s3.reutersmedia.net/resources_v2/images/reuters_social_logo.png',
width: '200',
height: '200',
},
url: 'https://www.reuters.com/',
};
let articleLdJson = $derived({ let articleLdJson = $derived({
'@context': 'http://schema.org', '@context': 'http://schema.org',
'@type': 'NewsArticle', '@type': 'Article',
headline: seoTitle, headline: seoTitle,
url: canonicalUrl, url: canonicalUrl || undefined,
mainEntityOfPage: { datePublished: publishTime || undefined,
'@type': 'WebPage', dateModified: updateTime || publishTime || undefined,
'@id': canonicalUrl,
},
thumbnailUrl: shareImgPath,
image: [
{
'@context': 'http://schema.org',
'@type': 'ImageObject',
url: shareImgPath,
},
],
publisher: { '@id': 'https://www.reuters.com/#publisher' },
copyrightHolder: { '@id': 'https://www.reuters.com/#publisher' },
sourceOrganization: { '@id': 'https://www.reuters.com/#publisher' },
copyrightYear: new Date().getFullYear(),
dateCreated: publishTime,
datePublished: publishTime,
dateModified: updateTime,
author: authors.map(({ name, link }) => ({ author: authors.map(({ name, link }) => ({
'@type': 'Person', '@type': 'Person',
name, name,
url: link, ...(link ? { url: link } : {}),
})), })),
creator: authors.map(({ name }) => name), ...(shareImgPath
articleSection: 'Graphics', ? {
isAccessibleForFree: true, image: {
keywords: ['Reuters graphics', 'Reuters', 'graphics', 'Interactives'], '@type': 'ImageObject',
url: shareImgPath,
},
}
: {}),
}); });
</script> </script>
<svelte:head> <svelte:head>
{#key canonicalUrl}
<title>{seoTitle}</title> <title>{seoTitle}</title>
<meta name="description" content={seoDescription} /> <meta name="description" content={seoDescription} />
{#if canonicalUrl}
<link rel="canonical" href={canonicalUrl} /> <link rel="canonical" href={canonicalUrl} />
<link {/if}
rel="icon"
type="image/png"
href="https://graphics.thomsonreuters.com/style-assets/images/logos/favicon/favicon-96x96.png"
sizes="96x96"
/>
<link
rel="icon"
type="image/svg+xml"
href="https://graphics.thomsonreuters.com/style-assets/images/logos/favicon/kinesis.svg"
/>
<link
rel="shortcut icon"
type="image/x-icon"
href="https://graphics.thomsonreuters.com/style-assets/images/logos/favicon/favicon.ico"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="https://graphics.thomsonreuters.com/style-assets/images/logos/favicon/apple-touch-icon.png"
/>
<meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:title" content={shareTitle} itemprop="name" /> <meta property="og:title" content={ogTitle} />
<meta <meta property="og:description" content={ogDescription} />
property="og:description" {#if shareImgPath}
content={shareDescription} <meta property="og:image" content={shareImgPath} />
itemprop="description" {/if}
/> <meta property="og:site_name" content={siteName} />
<meta property="og:image" content={shareImgPath} itemprop="image" /> {#if canonicalUrl}
<meta property="og:site_name" content="Reuters" /> <meta property="og:url" content={canonicalUrl} />
{/if}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content={shareImgPath ? 'summary_large_image' : 'summary'} />
<meta name="twitter:site" content="@ReutersGraphics" /> <meta name="twitter:title" content={ogTitle} />
<meta name="twitter:creator" content="@ReutersGraphics" /> <meta name="twitter:description" content={ogDescription} />
<meta name="twitter:domain" content={origin} /> {#if shareImgPath}
<meta name="twitter:title" content={shareTitle} />
<meta name="twitter:description" content={shareDescription} />
<meta name="twitter:image" content={shareImgPath} /> <meta name="twitter:image" content={shareImgPath} />
{/if}
{#if shareImgAlt} {#if shareImgAlt}
<meta name="twitter:image:alt" content={shareImgAlt} /> <meta name="twitter:image:alt" content={shareImgAlt} />
{/if} {/if}
<meta property="fb:app_id" content="319194411438328" /> {#if publishTime}
<meta property="fb:admins" content="616167736" /> <meta property="article:published_time" content={publishTime} />
<meta property="fb:admins" content="625796953" /> {/if}
<meta property="fb:admins" content="571759798" /> {#if updateTime}
<meta property="article:modified_time" content={updateTime} />
{/if}
<!-- svelte-ignore hydration_html_changed --> <!-- svelte-ignore hydration_html_changed -->
{@html `<${'script'} type="application/ld+json">${JSON.stringify( {@html `<${'script'} type="application/ld+json">${JSON.stringify(articleLdJson)}</script>`}
orgLdJson
)}</script>`}
<!-- svelte-ignore hydration_html_changed -->
{@html `<${'script'} type="application/ld+json">${JSON.stringify(
articleLdJson
)}</script>`}
{/key}
</svelte:head> </svelte:head>

View file

@ -1,85 +1,59 @@
<!-- @component `SiteFooter` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-furniture-sitefooter--docs) --> <!-- @component Simple site footer for Hypnagaga -->
<script lang="ts"> <script lang="ts">
import QuickLinks from './QuickLinks.svelte';
import CompanyLinks from './CompanyLinks.svelte';
import LegalLinks from './LegalLinks.svelte';
import ReferralBlock from '../ReferralBlock/ReferralBlock.svelte';
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
import starterData from './data.json';
import { onMount } from 'svelte';
interface Props { interface Props {
/** /** Copyright holder name */
* Set to `false` to remove graphics referrals name?: string;
*/
includeReferrals?: boolean;
} }
let { includeReferrals = true }: Props = $props(); let { name = 'Hypnagaga' }: Props = $props();
let data = $state(starterData); const year = new Date().getFullYear();
onMount(async () => {
if (new URL(document.location.href).origin !== 'https://www.reuters.com') {
return;
}
try {
const response = await fetch(
'https://www.reuters.com/site-api/footer/?' +
new URLSearchParams({
_website: 'reuters',
outputType: 'json',
})
);
const footerData = await response.json();
// Dumb verification...
if (!footerData[0].company_description) return;
data = footerData;
} catch {
console.warn('Unable to fetch site footer data');
}
});
</script> </script>
<footer <footer>
class="my-0" <div class="footer-inner">
style={` <p class="copyright">&copy; {year} {name}</p>
--nav-background: var(--theme-colour-background, #fff); <nav class="footer-links">
--nav-primary: var(--theme-colour-text-primary, #404040); <a href="/feed.xml">RSS</a>
--nav-rules: var(--theme-colour-brand-rules, #d0d0d0); </nav>
--theme-font-family-sans-serif: Knowledge, sans-serif;
`}
>
<div>
{#if includeReferrals}
<PaddingReset>
<ReferralBlock
heading="More from Reuters Graphics"
collection="graphics"
class="fpy-4"
/>
</PaddingReset>
{/if}
<QuickLinks links={data[0]} />
<CompanyLinks links={data[0]} />
<LegalLinks links={data[0]} />
</div> </div>
</footer> </footer>
<style lang="scss"> <style lang="scss">
footer { footer {
margin-block-start: 0; border-top: 1px solid var(--theme-colour-brand-rules, #d0d0d0);
background-color: var(--nav-background, #fff); background-color: var(--theme-colour-background, #fff);
div { margin-top: 2rem;
max-width: 1400px; }
.footer-inner {
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
} }
:global(a) {
.copyright {
margin: 0;
font-size: 0.85rem;
color: var(--theme-colour-text-secondary, #888);
}
.footer-links {
display: flex;
gap: 1rem;
a {
text-decoration: none; text-decoration: none;
font-size: 0.85rem;
color: var(--theme-colour-text-secondary, #888);
&:hover {
color: var(--theme-colour-text-primary, #222);
} }
:global(a:hover) {
text-decoration: underline;
} }
} }
</style> </style>

View file

@ -1,256 +1,90 @@
<!-- @component `SiteHeader` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-furniture-siteheader--docs) --> <!-- @component Simple site header for Hypnagaga -->
<script lang="ts"> <script lang="ts">
import ReutersLogo from '../ReutersLogo/ReutersLogo.svelte'; import type { Snippet } from 'svelte';
import NavBar from './NavBar/index.svelte';
import starterData from './data.json';
import { onMount, setContext } from 'svelte';
import { writable } from 'svelte/store';
import MenuIcon from './svgs/Menu.svelte';
import MobileMenu from './MobileMenu/index.svelte';
setContext('nav-active-section', writable<null | string>(null)); interface Props {
/** Site title */
let data = $state(starterData); siteTitle?: string;
/** Navigation links */
let sections = $derived(data[0].sections); links?: { href: string; label: string }[];
/** Optional custom content in the header */
let isMobileMenuOpen = $state(false); children?: Snippet;
onMount(async () => {
// Only fire on prod...
if (new URL(document.location.href).origin !== 'https://www.reuters.com') {
return;
} }
try {
const response = await fetch( let {
'https://www.reuters.com/site-api/header/?' + siteTitle = 'Hypnagaga',
new URLSearchParams({ links = [
_website: 'reuters', { href: '/', label: 'Stories' },
outputType: 'json', { href: '/feed.xml', label: 'RSS' },
}) ],
); children,
const headerData = await response.json(); }: Props = $props();
// Dumb verification...
if (!headerData[0].sections) return;
data = headerData;
} catch {
console.warn('Unable to fetch site header data');
}
});
</script> </script>
<header <header>
style={`
--nav-background: var(--theme-colour-background, #fff);
--nav-primary: var(--theme-colour-text-primary, #404040);
--nav-rules: var(--theme-colour-brand-rules, #d0d0d0);
--nav-accent: var(--theme-colour-brand-logo, #fa6400);
--nav-shadow: 0 1px 4px 2px var(--theme-colour-brand-shadow, rgb(255 255 255 / 10%));
--theme-font-family-sans-serif: Knowledge, sans-serif;
`}
>
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
<div class="nav-container show-nav"> <nav class="site-nav">
<div class="scroll-container"> <a href="/" class="site-title">{siteTitle}</a>
<div class="inner"> <ul class="nav-links">
<div class="main-bar"> {#each links as link}
<div class="logo-container"> <li><a href={link.href}>{link.label}</a></li>
<div class="logo"> {/each}
<a href="https://www.reuters.com" aria-label="Reuters home"> </ul>
<ReutersLogo {#if children}
logoColour="var(--nav-accent)" {@render children()}
textColour="var(--nav-primary)" {/if}
/> </nav>
</a>
</div>
</div>
<NavBar {sections} />
<!-- Space takes the place of the MyViewMenu, NavSearchBar & Account components... -->
<div class="spacer-container">
<div class="spacer"></div>
</div>
<div class="mobile-button-group">
<div class="mobile-menu">
<button
class="menu-button"
aria-label="Menu"
aria-haspopup="true"
aria-expanded={isMobileMenuOpen}
onclick={() => {
isMobileMenuOpen = !isMobileMenuOpen;
}}
>
<div class="button-container">
<MenuIcon />
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</header> </header>
<MobileMenu
{isMobileMenuOpen}
releaseMobileMenu={() => {
isMobileMenuOpen = false;
}}
data={data[0]}
/>
<style lang="scss"> <style lang="scss">
@use './scss/_grids.scss' as grids; header {
@use './scss/_colors.scss' as *; border-bottom: 1px solid var(--theme-colour-brand-rules, #d0d0d0);
@use './scss/_eases.scss' as *; background-color: var(--theme-colour-background, #fff);
@use './scss/_breakpoints.scss' as *; position: sticky;
@use './scss/_z-indexes.scss' as *; top: 0;
z-index: 100;
$nav-height: 64px;
$mobile-nav-height: 56px;
$subnav-height: 48px;
.nav-container {
background-color: var(--nav-background, $white);
position: relative;
height: $nav-height;
z-index: $zindex-sticky;
--page-height: 0px;
@include for-tablet-down {
height: $mobile-nav-height;
} }
.site-nav {
max-width: 1200px;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.site-title {
font-size: 1.25rem;
font-weight: 700;
text-decoration: none;
color: var(--theme-colour-text-primary, #222);
letter-spacing: -0.01em;
&:hover {
opacity: 0.8;
}
}
.nav-links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1.25rem;
a { a {
text-decoration: none; text-decoration: none;
} color: var(--theme-colour-text-secondary, #555);
} font-size: 0.9rem;
.scroll-container { &:hover {
height: calc(var(--page-height) - 50vh); color: var(--theme-colour-text-primary, #222);
pointer-events: none;
.inner {
position: sticky;
top: 0;
background: var(--nav-background, $white);
pointer-events: auto;
border-bottom: 1px solid var(--nav-rules, var(--tr-muted-grey));
// @include for-tablet-down {
// border-bottom: none;
// transition: transform 0.25s $principleDefaultEase;
// transform: translateY(-100%);
// will-change: transform;
// }
}
}
.main-bar {
margin: 0 auto;
box-sizing: border-box;
display: flex;
height: $nav-height;
justify-content: space-between;
@include max-width;
@include grids.spacing-single(padding-inline-start padding-inline-end);
@include for-mobile {
height: $mobile-nav-height;
}
}
.logo-container {
align-self: center;
.logo {
display: block;
font-size: 0;
width: 126px;
min-width: 126px;
@media (max-width: 768px) {
width: 94px;
min-width: 94px;
} }
} }
} }
.spacer-container {
margin-inline-start: auto;
display: flex;
align-items: center;
justify-content: flex-end;
@include for-mobile {
display: none;
}
.spacer {
width: 193.47px;
height: 64px;
@media (max-width: 1225px) {
width: 88px;
}
}
}
.mobile-button-group {
margin-inline-start: auto;
display: flex;
align-items: center;
justify-content: flex-end;
@include for-tablet-up {
display: none;
}
}
.mobile-menu {
margin-inline-start: 8px;
@include for-tablet-up {
display: none;
}
.menu-button {
width: 40px;
height: 40px;
display: inline-block;
vertical-align: top;
outline: none;
border: none;
margin: 0;
padding: 0;
overflow: visible;
background: transparent;
color: inherit;
font: inherit;
line-height: normal;
.button-container {
border-radius: 8px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
box-sizing: border-box;
border: 2px solid var(--nav-background);
}
&:hover .button-container {
box-shadow: var(--nav-shadow);
}
&:focus-visible .button-container {
border: 2px solid var(--nav-accent);
}
}
}
//Skip link styling. More about what a skip-link is and why we have it: https://www.w3schools.com/accessibility/accessibility_skip_links.php#:~:text=The%20HTML%20of%20a%20skip,to%20it%20with%20an%20anchor.
.skip-link { .skip-link {
position: absolute; position: absolute;
left: -10000px; left: -10000px;
@ -258,11 +92,11 @@
width: 1px; width: 1px;
height: 1px; height: 1px;
overflow: hidden; overflow: hidden;
}
.skip-link:focus { &:focus {
position: static; position: static;
width: auto; width: auto;
height: auto; height: auto;
} }
}
</style> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

After

Width:  |  Height:  |  Size: 131 B

198
src/lib/content.ts Normal file
View file

@ -0,0 +1,198 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { parse } from '@rferl/veronica';
const CONTENT_DIR = resolve('content/stories');
// -- Types ------------------------------------------------------------------
export type ContainerWidth =
| 'narrower'
| 'narrow'
| 'normal'
| 'wide'
| 'wider'
| 'widest'
| 'fluid';
export interface TextBlock {
type: 'text';
value: string;
}
export interface PhotoBlock {
type: 'photo';
src: string;
alt: string;
caption?: string;
width?: ContainerWidth;
}
export interface GraphicBlock {
type: 'graphic';
title?: string;
description?: string;
notes?: string;
width?: ContainerWidth;
}
export type BodyBlock = TextBlock | PhotoBlock | GraphicBlock;
export interface EndNote {
title: string;
text: string;
}
export interface StoryMeta {
slug: string;
headline: string;
dek?: string;
section?: string;
publishTime: string;
updateTime?: string;
authors: string[];
coverImage?: string;
coverAlt?: string;
}
export interface StoryData extends StoryMeta {
body: BodyBlock[];
endNotes: EndNote[];
}
// -- Helpers ----------------------------------------------------------------
interface FreeformBlock {
type: string;
value: string | Record<string, string>;
}
function parseAuthors(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(',')
.map((a) => a.trim())
.filter(Boolean);
}
function resolveImagePath(slug: string, src: string): string {
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')) {
return src;
}
return `/stories/${slug}/${src}`;
}
function parseRaw(slug: string, raw: Record<string, unknown>): StoryData {
const body: BodyBlock[] = [];
const rawBody = (raw.body ?? []) as FreeformBlock[];
for (const block of rawBody) {
switch (block.type) {
case 'text':
body.push({ type: 'text', value: String(block.value ?? '') });
break;
case 'photo': {
const v = block.value as Record<string, string>;
body.push({
type: 'photo',
src: resolveImagePath(slug, v.src ?? ''),
alt: v.alt ?? '',
caption: v.caption,
width: (v.width as ContainerWidth) ?? undefined,
});
break;
}
case 'graphic': {
const v = block.value as Record<string, string>;
body.push({
type: 'graphic',
title: v.title,
description: v.description,
notes: v.notes,
width: (v.width as ContainerWidth) ?? undefined,
});
break;
}
default:
if (block.type) {
console.warn(`Unknown body block type: ${block.type}`);
}
}
}
const endNotes: EndNote[] = ((raw.endNotes ?? []) as Record<string, string>[]).map((n) => ({
title: n.title ?? '',
text: n.text ?? '',
}));
const coverImage = raw.coverImage
? resolveImagePath(slug, raw.coverImage as string)
: undefined;
return {
slug,
headline: (raw.headline as string) ?? 'Untitled',
dek: raw.dek as string | undefined,
section: raw.section as string | undefined,
publishTime: (raw.publishTime as string) ?? new Date().toISOString(),
updateTime: (raw.updateTime as string) || undefined,
authors: parseAuthors(raw.authors as string | undefined),
coverImage,
coverAlt: (raw.coverAlt as string) || undefined,
body,
endNotes,
};
}
// -- Public API -------------------------------------------------------------
export function listStories(): StoryMeta[] {
if (!existsSync(CONTENT_DIR)) return [];
const slugs = readdirSync(CONTENT_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const stories: StoryMeta[] = [];
for (const slug of slugs) {
const amlPath = join(CONTENT_DIR, slug, 'story.aml');
if (!existsSync(amlPath)) continue;
const text = readFileSync(amlPath, 'utf-8');
const raw = parse(text) as Record<string, unknown>;
const data = parseRaw(slug, raw);
stories.push({
slug: data.slug,
headline: data.headline,
dek: data.dek,
section: data.section,
publishTime: data.publishTime,
updateTime: data.updateTime,
authors: data.authors,
coverImage: data.coverImage,
coverAlt: data.coverAlt,
});
}
return stories.sort(
(a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime()
);
}
export function loadStory(slug: string): StoryData | null {
const amlPath = join(CONTENT_DIR, slug, 'story.aml');
if (!existsSync(amlPath)) return null;
const text = readFileSync(amlPath, 'utf-8');
const raw = parse(text) as Record<string, unknown>;
return parseRaw(slug, raw);
}
export function getAllSlugs(): string[] {
if (!existsSync(CONTENT_DIR)) return [];
return readdirSync(CONTENT_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(CONTENT_DIR, d.name, 'story.aml')))
.map((d) => d.name);
}

22
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,22 @@
<script lang="ts">
import Theme from '$lib/components/Theme/Theme.svelte';
import SiteHeader from '$lib/components/SiteHeader/SiteHeader.svelte';
import SiteFooter from '$lib/components/SiteFooter/SiteFooter.svelte';
import '$lib/scss/main.scss';
let { children } = $props();
</script>
<Theme base="light">
<SiteHeader />
<div id="main-content" class="site-content">
{@render children()}
</div>
<SiteFooter />
</Theme>
<style lang="scss">
.site-content {
min-height: 70vh;
}
</style>

1
src/routes/+layout.ts Normal file
View file

@ -0,0 +1 @@
export const prerender = true;

View file

@ -0,0 +1,7 @@
import { listStories } from '$lib/lib/content';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const stories = listStories();
return { stories };
};

127
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,127 @@
<script lang="ts">
import Block from '$lib/components/Block/Block.svelte';
import { apdate } from 'journalize';
let { data } = $props();
</script>
<svelte:head>
<title>Hypnagaga</title>
<meta name="description" content="Stories and visual journalism" />
</svelte:head>
<main class="index">
<Block width="normal">
<header class="index-header">
<h1>Stories</h1>
</header>
{#if data.stories.length === 0}
<p class="empty">No stories yet.</p>
{:else}
<ul class="story-list">
{#each data.stories as story}
<li class="story-card">
<a href="/{story.slug}">
{#if story.coverImage}
<img
class="story-cover"
src={story.coverImage}
alt={story.coverAlt ?? story.headline}
loading="lazy"
/>
{/if}
<div class="story-info">
{#if story.section}
<span class="story-section">{story.section}</span>
{/if}
<h2 class="story-title">{story.headline}</h2>
{#if story.dek}
<p class="story-dek">{story.dek}</p>
{/if}
<time class="story-date" datetime={story.publishTime}>
{apdate(new Date(story.publishTime))}
</time>
</div>
</a>
</li>
{/each}
</ul>
{/if}
</Block>
</main>
<style lang="scss">
.index-header {
padding: 3rem 0 2rem;
h1 {
font-size: var(--text-4xl);
font-weight: 700;
margin: 0;
}
}
.empty {
padding: 2rem 0;
color: var(--color-text-secondary, #666);
}
.story-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2.5rem;
padding-bottom: 4rem;
}
.story-card {
a {
text-decoration: none;
color: inherit;
display: block;
&:hover .story-title {
text-decoration: underline;
}
}
}
.story-cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.story-section {
display: inline-block;
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-accent, #d84315);
margin-bottom: 0.25rem;
}
.story-title {
font-size: var(--text-2xl);
font-weight: 700;
margin: 0 0 0.25rem;
line-height: 1.25;
}
.story-dek {
font-size: var(--text-base);
color: var(--color-text-secondary, #555);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.story-date {
font-size: var(--text-sm);
color: var(--color-text-secondary, #888);
}
</style>

View file

@ -0,0 +1,15 @@
import { loadStory, getAllSlugs } from '$lib/lib/content';
import { error } from '@sveltejs/kit';
import type { PageServerLoad, EntryGenerator } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const story = loadStory(params.slug);
if (!story) {
error(404, `Story not found: ${params.slug}`);
}
return { story };
};
export const entries: EntryGenerator = async () => {
return getAllSlugs().map((slug) => ({ slug }));
};

View file

@ -0,0 +1,73 @@
<script lang="ts">
import Article from '$lib/components/Article/Article.svelte';
import Headline from '$lib/components/Headline/Headline.svelte';
import BodyText from '$lib/components/BodyText/BodyText.svelte';
import FeaturePhoto from '$lib/components/FeaturePhoto/FeaturePhoto.svelte';
import GraphicBlock from '$lib/components/GraphicBlock/GraphicBlock.svelte';
import EndNotes from '$lib/components/EndNotes/EndNotes.svelte';
let { data } = $props();
const story = data.story;
</script>
<svelte:head>
<title>{story.headline}</title>
{#if story.dek}
<meta name="description" content={story.dek} />
{/if}
<meta property="og:title" content={story.headline} />
{#if story.dek}
<meta property="og:description" content={story.dek} />
{/if}
{#if story.coverImage}
<meta property="og:image" content={story.coverImage} />
{/if}
<meta property="og:type" content="article" />
<meta property="article:published_time" content={story.publishTime} />
{#if story.updateTime}
<meta property="article:modified_time" content={story.updateTime} />
{/if}
</svelte:head>
<Article>
<Headline
hed={story.headline}
dek={story.dek}
section={story.section}
authors={story.authors}
publishTime={story.publishTime}
updateTime={story.updateTime}
/>
{#if story.coverImage}
<FeaturePhoto
src={story.coverImage}
altText={story.coverAlt ?? story.headline}
width="wide"
/>
{/if}
{#each story.body as block}
{#if block.type === 'text'}
<BodyText text={block.value} />
{:else if block.type === 'photo'}
<FeaturePhoto
src={block.src}
altText={block.alt}
caption={block.caption}
width={block.width ?? 'normal'}
/>
{:else if block.type === 'graphic'}
<GraphicBlock
title={block.title}
description={block.description}
notes={block.notes}
width={block.width ?? 'normal'}
/>
{/if}
{/each}
{#if story.endNotes.length > 0}
<EndNotes notes={story.endNotes} />
{/if}
</Article>

View file

@ -0,0 +1,47 @@
import { listStories } from '$lib/lib/content';
import type { RequestHandler } from './$types';
export const prerender = true;
export const GET: RequestHandler = async () => {
const stories = listStories();
const siteUrl = 'https://hypnagaga.com'; // Update with your actual URL
const siteTitle = 'Hypnagaga';
const siteDescription = 'Stories and visual journalism';
const items = stories
.map(
(story) => `
<item>
<title><![CDATA[${story.headline}]]></title>
<link>${siteUrl}/${story.slug}</link>
<guid isPermaLink="true">${siteUrl}/${story.slug}</guid>
${story.dek ? `<description><![CDATA[${story.dek}]]></description>` : ''}
<pubDate>${new Date(story.publishTime).toUTCString()}</pubDate>
${story.authors.length > 0 ? `<author>${story.authors.join(', ')}</author>` : ''}
${story.section ? `<category>${story.section}</category>` : ''}
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${siteTitle}</title>
<description>${siteDescription}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(xml.trim(), {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=0, s-maxage=3600',
},
});
};

View file

@ -1,5 +1,3 @@
import slugify from 'slugify';
/** Helper function to generate a random 4-character string */ /** Helper function to generate a random 4-character string */
export const random4 = () => export const random4 = () =>
Math.floor((1 + Math.random()) * 0x10000) Math.floor((1 + Math.random()) * 0x10000)
@ -8,10 +6,11 @@ export const random4 = () =>
/** /**
* Custom function that returns an author page URL. * Custom function that returns an author page URL.
* Returns an empty string by default (no link). Override in Byline's
* getAuthorPage prop if you want to link author names somewhere.
*/ */
export const getAuthorPageUrl = (author: string): string => { export const getAuthorPageUrl = (_author: string): string => {
const authorSlug = slugify(author.trim(), { lower: true }); return '';
return `https://www.reuters.com/authors/${authorSlug}/`;
}; };
/** Formats a string containing a full or 3-letter abbreviated month, AM/PM, and am/pm to match the Reuters style. /** Formats a string containing a full or 3-letter abbreviated month, AM/PM, and am/pm to match the Reuters style.

View file

@ -1,21 +1,26 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
kit: { kit: {
paths: { adapter: adapter({
base: '/graphics-components', pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
}),
files: {
lib: 'src',
}, },
alias: { alias: {
$lib: 'src',
'$lib/*': 'src/*',
$docs: 'src/docs', $docs: 'src/docs',
'$docs/*': 'src/docs/*', '$docs/*': 'src/docs/*',
}, },
}, },
/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig['onwarn']} */ /** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig['onwarn']} */
onwarn: (warning, handler) => { onwarn: (warning, handler) => {
// Triggered by our use of SCSS mixins ...
if (warning.code === 'vite-plugin-svelte-preprocess-many-dependencies') if (warning.code === 'vite-plugin-svelte-preprocess-many-dependencies')
return; return;
handler(warning); handler(warning);

View file

@ -4,18 +4,14 @@
"lib": ["DOM", "ESNext"], "lib": ["DOM", "ESNext"],
"module": "ESNext", "module": "ESNext",
"target": "ESNext", "target": "ESNext",
"declaration": true,
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"emitDeclarationOnly": true,
"jsx": "react", "jsx": "react",
"rootDir": ".", "rootDir": ".",
"rootDirs": [".", "docs/docs-components"],
"outDir": "dist",
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -27,12 +23,11 @@
"src/**/*.ts", "src/**/*.ts",
"src/**/*.svelte", "src/**/*.svelte",
"src/**/*.{jsx,tsx}", "src/**/*.{jsx,tsx}",
"bin/**/*.{js,cjs}",
"*.ts", "*.ts",
"*.js", "*.js",
"*.cjs", "*.cjs",
"src/journalize.d.ts", "src/journalize.d.ts",
"src/docs/**/*.css" "src/docs/**/*.css"
], ],
"exclude": ["dist", "eslint.config.js"] "exclude": ["build", "dist", "eslint.config.js"]
} }