Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/www/app/_utils/config/generate-feeds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { cwd } from 'node:process';
import { getBlogPosts } from './get-blog-posts';
import { buildRFC822Date } from './rfc-822';
import { rfc3339 } from './rfc-3339';

const baseUrl = 'https://designsystemet.no';
const ttl = 60 * 24 * 7; // 1 week

// Source - https://stackoverflow.com/a/27979933
// Posted by hgoebl, modified by community. See post 'Timeline' for change history
// Retrieved 2026-02-11, License - CC BY-SA 4.0
function escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '&':
return '&amp;';
case "'":
return '&apos;';
case '"':
return '&quot;';
}
return c;
});
}

export async function generateFeeds(lang: string): Promise<void> {
const dirname = cwd();
const now = new Date();

try {
const rssSource = `${baseUrl}/${lang}/blog/feed.rss`;
const atomSource = `${baseUrl}/${lang}/blog/feed.atom`;

const blogPosts = await getBlogPosts(lang);
const title = blogPosts.metadata[0].title; // <title />
const description = blogPosts.metadata[1].content; // <description />
const url = blogPosts.metadata[5].content; // <meta name="og:url" />
const image = blogPosts.metadata[2].content; // <meta name="og:image" />

const rssOutput = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(title)}</title>
<description>${escapeXml(description)}</description>
<link>${escapeXml(url)}</link>
<language>${lang}</language>
<image>
<title>${escapeXml(title)}</title>
<link>${escapeXml(url)}</link>
<url>${escapeXml(`${baseUrl}/${image}`)}</url>
</image>
<ttl>${ttl}</ttl>
<pubDate>${buildRFC822Date(now)}</pubDate>
<lastBuildDate>${buildRFC822Date(now)}</lastBuildDate>
<atom:link href="${escapeXml(rssSource)}" rel="self" type="application/rss+xml" />
${blogPosts.posts
.map(
({ url, description, title, date, searchTerms }) => ` <item>
<title>${escapeXml(title)}</title>
<link>${escapeXml(`${baseUrl}/${lang}/blog/${url}`)}</link>
<guid isPermaLink="true">${escapeXml(`${baseUrl}/${lang}/blog/${url}`)}</guid>
<pubDate>${buildRFC822Date(date)}</pubDate>
<description>${escapeXml(description)}</description>
<source url="${escapeXml(rssSource)}">${escapeXml(title)}</source>
${searchTerms.map((term) => ` <category>${escapeXml(term.trim())}</category>`).join('\n')}
</item>`,
)
.join('\n')}
</channel>
</rss>`;

const atomOutput = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(title)}</title>
<subtitle>${escapeXml(description)}</subtitle>
<link href="${escapeXml(url)}" />
<link rel="self" href="${escapeXml(atomSource)}" />
<icon>${escapeXml(image)}</icon>
<id>${escapeXml(atomSource)}</id>
<updated>${escapeXml(rfc3339(now))}</updated>
${blogPosts.posts
.map(
({ url, description, title, date, author, searchTerms }) => ` <entry>
<title>${escapeXml(title)}</title>
<link rel="alternate" href="${escapeXml(`${baseUrl}/${lang}/blog/${url}`)}" />
<link rel="self" href="${escapeXml(atomSource)}" />
<id>${escapeXml(`${baseUrl}/${lang}/blog/${url}`)}</id>
<updated>${escapeXml(rfc3339(new Date(date)))}</updated>
<summary>${escapeXml(description)}</summary>
<author>
<name>${escapeXml(author)}</name>
</author>
${searchTerms.map((term) => ` <category term="${escapeXml(term.trim())}" />`).join('\n')}
</entry>`,
)
.join('\n')}
</feed>`;

const rssClientPath = join(
dirname,
'dist',
'client',
lang,
'blog',
'feed.rss',
);
console.log(
`Writing feed.rss to ${rssClientPath} with ${blogPosts.posts.length} URLs`,
);
writeFileSync(rssClientPath, rssOutput);

const atomClientPath = join(
dirname,
'dist',
'client',
lang,
'blog',
'feed.atom',
);
console.log(
`Writing feed.atom to ${atomClientPath} with ${blogPosts.posts.length} URLs`,
);
writeFileSync(atomClientPath, atomOutput);
} catch (error) {
console.error(`Error generating feeds: ${error}`);
}
}
81 changes: 81 additions & 0 deletions apps/www/app/_utils/config/get-blog-posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { join } from 'node:path';
import { bundleMDX } from 'mdx-bundler';
import i18n from '../../i18next.server';
import { getFileFromContentDir, getFilesFromContentDir } from '../files.server';
import { generateMetadata } from '../metadata';

export interface BlogPosts {
lang: string;
posts: {
title: string;
author: string;
description: string;
url: string;
date: string;
image: {
src: string;
alt: string;
};
searchTerms: string[];
}[];
metadata: ReturnType<typeof generateMetadata>;
}

export async function getBlogPosts(lang: string): Promise<BlogPosts> {
/* Get all files in /content/blog for the lang we have selected */
const files = getFilesFromContentDir(join('blog', lang));

/* Filter out files that are not .mdx */
const mdxFiles = files.filter((file) => file.relativePath.endsWith('.mdx'));

/* Get titles and URLs for all files */
const posts: BlogPosts['posts'] = [];

/* Map over files with mdx parser to get title */
for (const file of mdxFiles) {
const fileContent = getFileFromContentDir(
join('blog', lang, file.relativePath),
);
const result = await bundleMDX({
source: fileContent,
});

const title =
result.frontmatter.title || file.relativePath.replace('.mdx', '');
const url = file.relativePath.replace('.mdx', '');
const searchTerms: string[] = [];
if (typeof result.frontmatter.search_terms === 'string')
result.frontmatter.search_terms
.split(',')
.map((term) => searchTerms.push(term));

posts.push({
title,
author: result.frontmatter.author || 'Unknown Author',
description: result.frontmatter.description || 'No description available',
url,
date: result.frontmatter.date || '2000-01-01',
image: {
src: result.frontmatter.imageSrc || '',
alt: result.frontmatter.imageAlt || '',
},
searchTerms,
});
}

/* Sort posts by date */
posts.sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
});

const t = await i18n.getFixedT(lang);

return {
lang,
posts,
metadata: generateMetadata({
title: t('blog.title'),
description: t('blog.description'),
}),
};
}
32 changes: 32 additions & 0 deletions apps/www/app/_utils/config/rfc-3339.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Authored by @pjdietz on GitHub
// https://gist.github.com/pjdietz/e0545332e2fc67a9a460

export function rfc3339(d: Date): string {
function pad(n: number): string | number {
return n < 10 ? '0' + n : n;
}

function timezoneOffset(offset: number): string {
if (offset === 0) {
return 'Z';
}
const sign = offset > 0 ? '-' : '+';
offset = Math.abs(offset);
return sign + pad(Math.floor(offset / 60)) + ':' + pad(offset % 60);
}

return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
'T' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds()) +
timezoneOffset(d.getTimezoneOffset())
);
}
31 changes: 31 additions & 0 deletions apps/www/app/_utils/config/rfc-822.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* MIT License Copyright (c) 2022 Salma Alam-Naylor
* https://github.com/whitep4nth3r/rfc-822/blob/781aee2019a6a05d2fe91631bce00b41fc17a80e/index.js
*/

const weekdayFormat = new Intl.DateTimeFormat('en-US', { weekday: 'short' })
.format;
const monthFormat = new Intl.DateTimeFormat('en-US', { month: 'short' }).format;

// add a leading 0 to a number if it is only one digit
function addLeadingZero(num: string | number): string {
num = num.toString();
while (num.length < 2) num = '0' + num;
return num;
}

export function buildRFC822Date(dateString: string | Date) {
const date =
dateString instanceof Date ? dateString : new Date(Date.parse(dateString));
// Convert to GMT
date.setTime(date.getTime() + date.getTimezoneOffset() * 60_000);

const day = weekdayFormat(date);
const dayNumber = addLeadingZero(date.getDate());
const month = monthFormat(date);
const year = date.getFullYear();
const time = `${addLeadingZero(date.getHours())}:${addLeadingZero(date.getMinutes())}:00`;

//Wed, 02 Oct 2002 13:00:00 GMT
return `${day}, ${dayNumber} ${month} ${year} ${time} GMT`;
}
2 changes: 1 addition & 1 deletion apps/www/app/_utils/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ export const generateMetadata = ({
property: 'twitter:image',
content: image,
},
];
] as const;
};
6 changes: 3 additions & 3 deletions apps/www/app/i18next.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RemixI18Next } from 'remix-i18next/server';
import i18n from '~/i18n';
import en from '~/locales/en';
import no from '~/locales/no';
import i18n from './i18n';
import en from './locales/en';
import no from './locales/no';
Comment on lines +2 to +4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for changing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build process doesn't like it when these use path aliases

It's also why I changed @internal/components, but that's actually not a path alias and works as expected. So I'll revert that

$ pnpm build:www

> root@0.0.0 build:www /home/luca/git_clones/designsystemet
> pnpm --filter @web/www build


> @web/www@ build /home/luca/git_clones/designsystemet/apps/www
> react-router typegen && react-router build

Error: Error loading /home/luca/git_clones/designsystemet/apps/www/react-router.config.ts: Error: Cannot find module '~/i18n' imported from '/home/luca/git_clones/designsystemet/apps/www/app/i18next.server.ts'
    at createConfigLoader (/home/luca/git_clones/designsystemet/node_modules/.pnpm/@react-router+dev@7.13.0_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1__da506b19369c0bc5234f64cfdd1fd83c/node_modules/@react-router/dev/dist/vite.js:625:11)
    at BasicMinimalPluginContext.config (/home/luca/git_clones/designsystemet/node_modules/.pnpm/@react-router+dev@7.13.0_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1__da506b19369c0bc5234f64cfdd1fd83c/node_modules/@react-router/dev/dist/vite.js:3236:35)
    at runConfigHook (file:///home/luca/git_clones/designsystemet/node_modules/.pnpm/vite@7.3.1_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1_sass@1.87.0_st_96f85193d5e7f20965a220e556791264/node_modules/vite/dist/node/chunks/config.js:35936:15)
    at Module.resolveConfig (file:///home/luca/git_clones/designsystemet/node_modules/.pnpm/vite@7.3.1_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1_sass@1.87.0_st_96f85193d5e7f20965a220e556791264/node_modules/vite/dist/node/chunks/config.js:35438:13)
    at hasReactRouterRscPlugin (/home/luca/git_clones/designsystemet/node_modules/.pnpm/@react-router+dev@7.13.0_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1__da506b19369c0bc5234f64cfdd1fd83c/node_modules/@react-router/dev/dist/cli/index.js:1355:22)
    at typegen (/home/luca/git_clones/designsystemet/node_modules/.pnpm/@react-router+dev@7.13.0_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1__da506b19369c0bc5234f64cfdd1fd83c/node_modules/@react-router/dev/dist/cli/index.js:2392:15)
    at run2 (/home/luca/git_clones/designsystemet/node_modules/.pnpm/@react-router+dev@7.13.0_@types+node@24.10.9_jiti@2.4.2_less@4.3.0_lightningcss@1.30.1__da506b19369c0bc5234f64cfdd1fd83c/node_modules/@react-router/dev/dist/cli/index.js:2575:7)
/home/luca/git_clones/designsystemet/apps/www:
 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @web/www@ build: `react-router typegen && react-router build`
Exit status 1ELIFECYCLECommand failed with exit code 1.


const i18next = new RemixI18Next({
detection: {
Expand Down
Loading
Loading