diff --git a/.gitignore b/.gitignore index 039fe5e..205c953 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ +.env +!.env.example .cache +.DS_Store +.react-router build node_modules git-feed.json latest-release.json +*.tsbuildinfo +.tool-versions \ No newline at end of file diff --git a/README.md b/README.md index 544e7ec..a9e60e8 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,16 @@ This website hosts the documentation for the [reoserv][reoserv] project Before you can run this project, you need to have the following installed on your machine: -- [Node.js][node] v20+ (for local development with the Remix web framework) -- [Bun][bun] v1.0.36+ (a modern package manager & script runner for Node.js +- [Node.js][node] v22+ (for local development with the Remix web framework) +- [Bun][bun] v1.1.36+ (a modern package manager & script runner for Node.js projects) Verify that both are setup on your system and meet our version requirements by running: ```sh -bun --version # eg: 1.0.36 -node --version # eg: v20.4.0 +bun --version # eg: 1.1.36 +node --version # eg: v22.10.0 ``` ## Installation @@ -47,6 +47,24 @@ To build the project, run: bun run build ``` +## Deployment + +Deploy the output of `bun run build` + +``` +├── package.json +├── bun.lockb +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +Serve in production with: + +``` +bun run start +``` + ## Linting / Formatting To format, lint and apply fixes at the same time, use: @@ -61,6 +79,10 @@ To format markdown files (docs, news), use: bun run lint:docs ``` +--- + +Built with ❤️ using React Router. + [reoserv]: https://github.com/sorokya/reoserv [sorokya]: https://github.com/sorokya [node]: https://nodejs.org/ diff --git a/app/.server/content.ts b/app/.server/content.ts new file mode 100644 index 0000000..7c484c7 --- /dev/null +++ b/app/.server/content.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs/promises'; +import { glob } from 'glob'; +import { parseMarkdown } from './utils/parse-markdown'; + +type Content = Awaited>; +type ContentValue = { content: Content; mtimeMs: number }; +type ContentStore = Record; + +// Simple in-memory store +let contentStore: ContentStore | null = null; + +async function getFileContent( + filePath: string, + cached?: ContentValue, +): Promise { + const stats = await fs.stat(filePath); + + if (cached && cached.mtimeMs === stats.mtimeMs) { + return cached; + } + + const content = await parseMarkdown(filePath); + return { content, mtimeMs: stats.mtimeMs }; +} + +async function loadAllContent(): Promise { + const files = await glob('content/**/*.md'); + + const contents = await Promise.all( + files.map(async (filePath) => { + const key = filePath.replace(/^content\//, '').replace(/\.md$/, ''); + + try { + const content = await getFileContent(filePath, contentStore?.[key]); + return [key, content] as const; + } catch (error) { + console.error(`Failed to load content for ${key}:`, error); + return undefined; + } + }), + ); + + return Object.fromEntries(contents.filter(Boolean)); +} + +async function getStore(): Promise { + if (process.env.NODE_ENV === 'development' || !contentStore) { + contentStore = await loadAllContent(); + } + return contentStore; +} + +async function getContentByType( + type: string, +): Promise> { + const store = await getStore(); + return Object.fromEntries( + Object.entries(store) + .filter(([key]) => key.startsWith(`${type}/`)) + .map(([key, data]) => [key, data.content]), + ); +} + +async function getContentByKey(key: string): Promise { + const store = await getStore(); + return store[key]?.content; +} + +export { getContentByKey, getContentByType }; diff --git a/app/.server/etag.js b/app/.server/etag.js deleted file mode 100644 index 3ecf9a2..0000000 --- a/app/.server/etag.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'etag'; diff --git a/app/.server/fs.js b/app/.server/fs.js deleted file mode 100644 index 6ce73cc..0000000 --- a/app/.server/fs.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'node:fs'; diff --git a/app/.server/get-docs-page.js b/app/.server/get-docs-page.js deleted file mode 100644 index 47cb21a..0000000 --- a/app/.server/get-docs-page.js +++ /dev/null @@ -1,10 +0,0 @@ -import { parseMarkdown } from './parse-markdown'; - -const DOCS_PATH = 'content/docs'; - -async function getDocsPage(name) { - const file = `${DOCS_PATH}/${name}.md`; - return await parseMarkdown(file); -} - -export { getDocsPage }; diff --git a/app/.server/get-docs-page.ts b/app/.server/get-docs-page.ts new file mode 100644 index 0000000..37e6707 --- /dev/null +++ b/app/.server/get-docs-page.ts @@ -0,0 +1,7 @@ +import { getContentByKey } from './content'; + +async function getDocsPage(slug: string) { + return await getContentByKey(`docs/${slug}`); +} + +export { getDocsPage }; diff --git a/app/.server/get-git-feed.js b/app/.server/get-git-feed.js deleted file mode 100644 index 5b929f3..0000000 --- a/app/.server/get-git-feed.js +++ /dev/null @@ -1,45 +0,0 @@ -import rssToJson from 'rss-to-json'; -import fs from './fs'; -const { parse } = rssToJson; -import { getClockOffset } from './get-clock-offset'; -import { getPrettyDate } from './get-pretty-date'; - -const GITHUB_FEED = 'https://github.com/sorokya/reoserv/commits/master.atom'; -const DATA_FILE_PATH = 'git-feed.json'; -const MAX_FILE_AGE = 5 * 60 * 1000; // 5 minutes in milliseconds - -async function getGitFeed(request) { - // Check file age or existence - const fileStats = - fs.existsSync(DATA_FILE_PATH) && fs.statSync(DATA_FILE_PATH); - const fileAge = fileStats - ? Date.now() - fileStats.mtime.getTime() - : Number.POSITIVE_INFINITY; - - if (!fileStats || fileAge > MAX_FILE_AGE) { - const gitFeed = await fetchGitFeed(request); - const json = JSON.stringify(gitFeed); - fs.writeFileSync(DATA_FILE_PATH, json); - return gitFeed; - } - - const json = fs.readFileSync(DATA_FILE_PATH); - return JSON.parse(json); -} - -async function fetchGitFeed(request) { - const clockOffset = getClockOffset(request); - const feed = await parse(GITHUB_FEED); - if (!feed) { - return []; - } - - return feed.items.map((item) => ({ - id: item.id, - link: item.link, - content: item.title, - timestamp: getPrettyDate(item.created, clockOffset), - })); -} - -export { getGitFeed }; diff --git a/app/.server/get-git-feed.ts b/app/.server/get-git-feed.ts new file mode 100644 index 0000000..6aac49a --- /dev/null +++ b/app/.server/get-git-feed.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import { z } from 'zod'; + +const GithubCommitsAPISchema = z.array( + z.object({ + sha: z.string().min(1), + html_url: z.string().url().min(1), + commit: z.object({ + message: z.string().min(1), + committer: z.object({ + date: z.string().datetime().min(1), + }), + }), + }), +); + +const StoredCommitsSchema = z.array( + z.object({ + id: z.string().min(1), + link: z.string().url().min(1), + content: z.string().min(1), + timestamp: z.string().datetime().min(1), + }), +); + +const GITHUB_URL = + 'https://api.github.com/repos/sorokya/reoserv/commits?sha=master&per_page=20'; +const DATA_FILE_PATH = 'git-feed.json'; +const MAX_FILE_AGE = 5 * 60 * 1000; // 5 minutes in milliseconds + +async function getGitFeed() { + // Check file age or existence + const fileStats = + fs.existsSync(DATA_FILE_PATH) && fs.statSync(DATA_FILE_PATH); + const fileAge = fileStats + ? Date.now() - fileStats.mtime.getTime() + : Number.POSITIVE_INFINITY; + + if (!fileStats || fileAge > MAX_FILE_AGE) { + const commits = await fetchGitFeed(); + const json = JSON.stringify(commits); + fs.writeFileSync(DATA_FILE_PATH, json); + return commits; + } + + const fileContents = fs.readFileSync(DATA_FILE_PATH, { encoding: 'utf8' }); + const json = JSON.parse(fileContents); + const commits = StoredCommitsSchema.parse(json); + return commits; +} + +async function fetchGitFeed() { + const response = await fetch(GITHUB_URL); + + if (!response.ok) { + throw new Error('Failed to fetch commits from github'); + } + + const commits = GithubCommitsAPISchema.parse(await response.json()); + + return commits.map((commit) => ({ + id: commit.sha, + link: commit.html_url, + content: commit.commit.message.split(/\r?\n/)[0], + timestamp: new Date(commit.commit.committer.date).toISOString(), + })); +} + +export { getGitFeed }; diff --git a/app/.server/get-latest-release.js b/app/.server/get-latest-release.js deleted file mode 100644 index 546de52..0000000 --- a/app/.server/get-latest-release.js +++ /dev/null @@ -1,46 +0,0 @@ -import fs from './fs'; -import { getClockOffset } from './get-clock-offset'; -import { getPrettyDate } from './get-pretty-date'; - -const GITHUB_URL = - 'https://api.github.com/repos/sorokya/reoserv/releases/latest'; -const DATA_FILE_PATH = 'latest-release.json'; -const MAX_FILE_AGE = 5 * 60 * 1000; // 5 minutes in milliseconds - -async function getLatestRelease(request) { - // Check file age or existence - const fileStats = - fs.existsSync(DATA_FILE_PATH) && fs.statSync(DATA_FILE_PATH); - const fileAge = fileStats - ? Date.now() - fileStats.mtime.getTime() - : Number.POSITIVE_INFINITY; - - if (!fileStats || fileAge > MAX_FILE_AGE) { - const latestRelease = await fetchLatestRelease(request); - const json = JSON.stringify(latestRelease); - fs.writeFileSync(DATA_FILE_PATH, json); - return latestRelease; - } - - const json = fs.readFileSync(DATA_FILE_PATH); - return JSON.parse(json); -} - -async function fetchLatestRelease(request) { - const clockOffset = getClockOffset(request); - const response = await fetch(GITHUB_URL); - - if (!response.ok) { - return { error: 'Failed to fetch github release' }; - } - - const release = await response.json(); - - return { - name: release.name, - timestamp: getPrettyDate(release.published_at, clockOffset), - link: release.html_url, - }; -} - -export { getLatestRelease }; diff --git a/app/.server/get-latest-release.ts b/app/.server/get-latest-release.ts new file mode 100644 index 0000000..0a70a24 --- /dev/null +++ b/app/.server/get-latest-release.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import { z } from 'zod'; + +const GithubReleaseAPISchema = z.object({ + name: z.string().min(1), + published_at: z.string().datetime().min(1), + html_url: z.string().url().min(1), +}); + +const LatestReleaseSchema = z.object({ + name: z.string().min(1), + timestamp: z.string().datetime(), + link: z.string().url().min(1), +}); + +const GITHUB_URL = + 'https://api.github.com/repos/sorokya/reoserv/releases/latest'; +const DATA_FILE_PATH = 'latest-release.json'; +const MAX_FILE_AGE = 5 * 60 * 1000; // 5 minutes in milliseconds + +async function getLatestRelease() { + // Check file age or existence + const fileStats = + fs.existsSync(DATA_FILE_PATH) && fs.statSync(DATA_FILE_PATH); + const fileAge = fileStats + ? Date.now() - fileStats.mtime.getTime() + : Number.POSITIVE_INFINITY; + + if (!fileStats || fileAge > MAX_FILE_AGE) { + const latestRelease = await fetchLatestRelease(); + const json = JSON.stringify(latestRelease); + fs.writeFileSync(DATA_FILE_PATH, json); + return latestRelease; + } + + const fileContents = fs.readFileSync(DATA_FILE_PATH, { encoding: 'utf8' }); + const json = JSON.parse(fileContents); + const latestRelease = LatestReleaseSchema.parse(json); + return latestRelease; +} + +async function fetchLatestRelease() { + const response = await fetch(GITHUB_URL); + + if (!response.ok) { + throw new Error('Failed to fetch the latest release from github'); + } + + const release = GithubReleaseAPISchema.parse(await response.json()); + + return { + name: release.name, + timestamp: new Date(release.published_at).toISOString(), + link: release.html_url, + }; +} + +export { getLatestRelease }; diff --git a/app/.server/get-news-article.js b/app/.server/get-news-article.js deleted file mode 100644 index ab55538..0000000 --- a/app/.server/get-news-article.js +++ /dev/null @@ -1,12 +0,0 @@ -import { getClockOffset } from './get-clock-offset'; -import { parseMarkdown } from './parse-markdown'; - -const NEWS_PATH = 'content/news'; - -async function getNewsArticle(name, request) { - const clockOffset = getClockOffset(request); - const file = `${NEWS_PATH}/${name}.md`; - return await parseMarkdown(file, { clockOffset }); -} - -export { getNewsArticle }; diff --git a/app/.server/get-news-article.ts b/app/.server/get-news-article.ts new file mode 100644 index 0000000..1e0e68c --- /dev/null +++ b/app/.server/get-news-article.ts @@ -0,0 +1,7 @@ +import { getContentByKey } from './content'; + +async function getNewsArticle(slug: string) { + return await getContentByKey(`news/${slug}`); +} + +export { getNewsArticle }; diff --git a/app/.server/get-news-feed.js b/app/.server/get-news-feed.js deleted file mode 100644 index 400c8c2..0000000 --- a/app/.server/get-news-feed.js +++ /dev/null @@ -1,46 +0,0 @@ -import matter from 'gray-matter'; - -import fs from 'node:fs/promises'; -import { getClockOffset } from './get-clock-offset'; -import { getPrettyDate } from './get-pretty-date'; - -const NEWS_PATH = 'content/news'; - -async function getNewsFeed(request) { - const files = await fs.readdir(NEWS_PATH); - if (!files) { - return []; - } - - files.sort((a, b) => b.localeCompare(a)); - - const clockOffset = getClockOffset(request); - - const newsItems = await Promise.all( - files.map((file) => - getNewsFile( - `${NEWS_PATH}/${file}`, - file.substring(0, file.length - 3), - clockOffset, - ), - ), - ); - - return newsItems; -} - -async function getNewsFile(path, name, clockOffset) { - const file = await fs.open(path, 'r'); - const content = await file.readFile('utf-8'); - - const fm = matter(content); - await file.close(); - return { - title: fm.data.title, - name, - description: fm.data.description, - date: getPrettyDate(fm.data.date, clockOffset), - }; -} - -export { getNewsFeed }; diff --git a/app/.server/get-news-feed.ts b/app/.server/get-news-feed.ts new file mode 100644 index 0000000..e9a8348 --- /dev/null +++ b/app/.server/get-news-feed.ts @@ -0,0 +1,16 @@ +import { getContentByType } from './content'; + +async function getNewsFeed() { + const articles = await getContentByType('news'); + + return Object.entries(articles) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([key, article]) => ({ + key, + title: article.title, + description: article.description, + date: article.date, + })); +} + +export { getNewsFeed }; diff --git a/app/.server/get-pretty-date.js b/app/.server/get-pretty-date.js deleted file mode 100644 index 36efdd8..0000000 --- a/app/.server/get-pretty-date.js +++ /dev/null @@ -1,41 +0,0 @@ -const getMonthName = (date) => { - switch (date.getMonth()) { - case 0: - return 'Jan'; - case 1: - return 'Feb'; - case 2: - return 'Mar'; - case 3: - return 'Apr'; - case 4: - return 'May'; - case 5: - return 'Jun'; - case 6: - return 'Jul'; - case 7: - return 'Aug'; - case 8: - return 'Sep'; - case 9: - return 'Oct'; - case 10: - return 'Nov'; - case 11: - return 'Dec'; - } -}; - -const offsetDate = (timestamp, offset) => { - const date = new Date(timestamp); - date.setMinutes(date.getMinutes() + offset); - return date; -}; - -const getPrettyDate = (timestamp, clockOffset) => { - const date = offsetDate(timestamp, clockOffset); - return `${getMonthName(date)} ${date.getDate()}, ${date.getFullYear()}`; -}; - -export { getPrettyDate }; diff --git a/app/.server/parse-markdown.js b/app/.server/parse-markdown.js deleted file mode 100644 index 8be78c9..0000000 --- a/app/.server/parse-markdown.js +++ /dev/null @@ -1,49 +0,0 @@ -import fs from 'node:fs/promises'; -import matter from 'gray-matter'; -import { Marked } from 'marked'; -import { createHighlighter } from 'shiki'; -import etag from './etag'; -import { getPrettyDate } from './get-pretty-date'; -import { replaceVideoTags } from './replace-video-tags'; - -let highlighter; - -async function parseMarkdown(filepath, { clockOffset } = { clockOffset: 0 }) { - if (!highlighter) { - highlighter = await createHighlighter({ - langs: ['md', 'sh', 'rust', 'text', 'yaml'], - themes: ['github-dark-dimmed'], - }); - } - - const contents = await fs.readFile(filepath, { encoding: 'utf-8' }); - - const fm = matter(contents); - - const markdown = await new Marked() - .use({ async: true, gfm: true }) - .use({ - renderer: { - code(code) { - return highlighter.codeToHtml(code.text, { - lang: code.lang ?? 'text', - theme: 'github-dark-dimmed', - }); - }, - }, - }) - .parse(fm.content); - - return { - title: fm.data.title, - description: fm.data.description, - date: fm.data.date ? getPrettyDate(fm.data.date, clockOffset) : null, - lastmod: fm.data.lastmod - ? getPrettyDate(fm.data.lastmod, clockOffset) - : null, - etag: etag(markdown), - content: replaceVideoTags(markdown), - }; -} - -export { parseMarkdown }; diff --git a/app/.server/replace-video-tags.js b/app/.server/replace-video-tags.js deleted file mode 100644 index bc703f1..0000000 --- a/app/.server/replace-video-tags.js +++ /dev/null @@ -1,18 +0,0 @@ -function replaceVideoTags(html) { - while (html.includes('{{Your browser does not support video`, - ); - } - - return html; -} - -export { replaceVideoTags }; diff --git a/app/.server/theme.js b/app/.server/theme.js deleted file mode 100644 index fbc5aa1..0000000 --- a/app/.server/theme.js +++ /dev/null @@ -1,11 +0,0 @@ -import { createCookie } from '@remix-run/node'; - -export const themeCookie = createCookie('theme', { - httpOnly: true, - secure: true, -}); - -export async function getThemeFromCookies(request) { - const theme = await themeCookie.parse(request.headers.get('Cookie')); - return theme || 'light'; -} diff --git a/app/.server/theme.ts b/app/.server/theme.ts new file mode 100644 index 0000000..3b12510 --- /dev/null +++ b/app/.server/theme.ts @@ -0,0 +1,13 @@ +import { createCookie } from 'react-router'; + +const themeCookie = createCookie('theme', { + httpOnly: true, + secure: true, +}); + +async function getThemeFromCookies(request: Request) { + const theme = await themeCookie.parse(request.headers.get('Cookie')); + return theme === 'dark' ? 'dark' : 'light'; +} + +export { getThemeFromCookies, themeCookie }; diff --git a/app/.server/get-clock-offset.js b/app/.server/utils/clock-offset.ts similarity index 59% rename from app/.server/get-clock-offset.js rename to app/.server/utils/clock-offset.ts index a23de20..7ffe74a 100644 --- a/app/.server/get-clock-offset.js +++ b/app/.server/utils/clock-offset.ts @@ -1,12 +1,12 @@ -// The clockOffset cookie is set in the entry.server.js file +// The clockOffset cookie is set in the server/app.ts file // use a named capture group const regex = /clockOffset=(?[+-\d]+)/; -function getClockOffset(request) { +function getClockOffset(request: Request) { const cookie = request.headers.get('Cookie'); const clockOffset = cookie?.match(regex)?.groups?.clockOffset; - return Number.parseInt(clockOffset ?? 0, 10); + return Number.parseInt(clockOffset ?? '0', 10); } export { getClockOffset }; diff --git a/app/.server/utils/etag.ts b/app/.server/utils/etag.ts new file mode 100644 index 0000000..13b14be --- /dev/null +++ b/app/.server/utils/etag.ts @@ -0,0 +1,7 @@ +import crypto from 'node:crypto'; + +function etag(str: string) { + return crypto.createHash('md5').update(str).digest('hex'); +} + +export { etag }; diff --git a/app/.server/utils/parse-markdown.ts b/app/.server/utils/parse-markdown.ts new file mode 100644 index 0000000..51826c5 --- /dev/null +++ b/app/.server/utils/parse-markdown.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import { remember } from '@epic-web/remember'; +import frontmatter from 'gray-matter'; +import { Marked } from 'marked'; +import { getHeadingList, gfmHeadingId } from 'marked-gfm-heading-id'; +import { createHighlighter } from 'shiki'; +import { etag } from './etag'; + +const highlighter = await remember('highlighter', () => + createHighlighter({ + langs: ['md', 'sh', 'rust', 'text', 'yaml'], + themes: ['github-dark-dimmed'], + }), +); + +const marked = remember('marked', () => + new Marked() + .use({ async: true, gfm: true }) + .use(gfmHeadingId()) + .use({ + renderer: { + code(code) { + return highlighter.codeToHtml(code.text, { + lang: code.lang ?? 'text', + theme: 'github-dark-dimmed', + }); + }, + }, + }), +); + +async function parseMarkdown(filepath: string) { + const fileContents = await fs.readFile(filepath, { encoding: 'utf-8' }); + + const fm = frontmatter(fileContents); + + const html = await marked.parse(fm.content); + + const content = replaceVideoTags(html); + + const toc = getHeadingList(); + + return { + title: fm.data.title, + description: fm.data.description, + date: new Date(fm.data.date).toISOString(), + lastmod: new Date(fm.data.lastmod).toISOString(), + etag: etag(content), + content, + toc, + }; +} + +function replaceVideoTags(html: string) { + while (html.includes('{{Your browser does not support video`, + ); + } + } + + return html; +} + +export { parseMarkdown }; diff --git a/app/.server/utils/pretty-date.ts b/app/.server/utils/pretty-date.ts new file mode 100644 index 0000000..25497dc --- /dev/null +++ b/app/.server/utils/pretty-date.ts @@ -0,0 +1,32 @@ +type Timestamp = string | number | Date; + +const getMonthName = (date: Date) => { + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] as const; + return monthNames[date.getMonth()]; +}; + +const offsetDate = (timestamp: Timestamp, offset: number) => { + const date = new Date(timestamp); + date.setMinutes(date.getMinutes() + offset); + return date; +}; + +const getPrettyDate = (timestamp: Timestamp, clockOffset: number) => { + const date = offsetDate(timestamp, clockOffset); + return `${getMonthName(date)} ${date.getDate()}, ${date.getFullYear()}`; +}; + +export { getPrettyDate }; diff --git a/app/components/git-feed.jsx b/app/components/git-feed.jsx deleted file mode 100644 index 5105f77..0000000 --- a/app/components/git-feed.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Link } from '@remix-run/react'; - -export function GitFeed({ commits }) { - return ( -
    - {commits.map(({ id, link, content, timestamp }) => ( -
  • - {timestamp} - - - {content} - - -
  • - ))} -
- ); -} diff --git a/app/components/git-feed.tsx b/app/components/git-feed.tsx new file mode 100644 index 0000000..8e9bd8a --- /dev/null +++ b/app/components/git-feed.tsx @@ -0,0 +1,45 @@ +import { PiScrollLight } from 'react-icons/pi'; +import { Link } from 'react-router'; + +type GitFeedProps = { + commits: Array<{ + id: string; + link: string; + content: string; + timestamp: string; + localDate: string; + }>; +}; + +function GitFeed({ commits }: GitFeedProps) { + return ( +
+

+ + + + Recent changes +

+
    + {commits.map((commit) => ( +
  • + + + + {commit.content} + + +
  • + ))} +
+
+ ); +} + +export { GitFeed }; diff --git a/app/components/header.jsx b/app/components/header.tsx similarity index 90% rename from app/components/header.jsx rename to app/components/header.tsx index f116a5e..1cb1386 100644 --- a/app/components/header.jsx +++ b/app/components/header.tsx @@ -1,8 +1,9 @@ -import { Link, NavLink, useFetcher } from '@remix-run/react'; import { FaGithub } from 'react-icons/fa6'; import { FcDocument, FcHome, FcList } from 'react-icons/fc'; import { LuMoon, LuSun } from 'react-icons/lu'; import { SiKofi } from 'react-icons/si'; +import { Link, NavLink, useFetcher } from 'react-router'; +// @ts-ignore import logo from '../assets/images/logo-full.png?as=metadata'; const getLinkClasses = (active = false) => @@ -12,7 +13,7 @@ const getLinkClasses = (active = false) => : 'bg-amber-2 text-amber-12 border-amber-8 hover:bg-amber-3 hover:text-amber-11' }`; -const links = [ +const LINKS = [ { href: '/', label: 'Home', icon: }, { href: '/docs', @@ -36,14 +37,18 @@ const links = [ }, ]; -export function Header({ theme }) { +type HeaderProps = { + theme: 'light' | 'dark'; +}; + +function Header({ theme }: HeaderProps) { const fetcher = useFetcher(); return (
Reoserv