@rm-industries/create-forge 0.3.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/index.mjs +511 -0
  4. package/dist/index.mjs.map +1 -0
  5. package/dist/template/.editorconfig +10 -0
  6. package/dist/template/.markdownlint.json +10 -0
  7. package/dist/template/README.md +169 -0
  8. package/dist/template/astro.config.ts +15 -0
  9. package/dist/template/cspell.config.ts +6 -0
  10. package/dist/template/oxfmt.config.ts +9 -0
  11. package/dist/template/package-lock.json +10896 -0
  12. package/dist/template/package.json +61 -0
  13. package/dist/template/playwright.config.ts +24 -0
  14. package/dist/template/public/favicon.svg +4 -0
  15. package/dist/template/public/social-card.svg +8 -0
  16. package/dist/template/src/components/navigation/Footer.astro +33 -0
  17. package/dist/template/src/components/navigation/Header.astro +11 -0
  18. package/dist/template/src/components/navigation/Navbar.astro +60 -0
  19. package/dist/template/src/components/navigation/Pagination.astro +37 -0
  20. package/dist/template/src/components/seo/SeoHead.astro +48 -0
  21. package/dist/template/src/components/theme/ThemeHead.astro +6 -0
  22. package/dist/template/src/components/ui/Card.astro +26 -0
  23. package/dist/template/src/components/ui/Container.astro +17 -0
  24. package/dist/template/src/config/content-models/articles.ts +39 -0
  25. package/dist/template/src/config/content-models/registry.ts +5 -0
  26. package/dist/template/src/config/site.test.ts +37 -0
  27. package/dist/template/src/config/site.ts +95 -0
  28. package/dist/template/src/content/articles/accessible-by-default.md +22 -0
  29. package/dist/template/src/content/articles/content-that-travels-well.md +17 -0
  30. package/dist/template/src/content/articles/designing-a-calm-starting-point.md +22 -0
  31. package/dist/template/src/content/articles/future-draft.md +11 -0
  32. package/dist/template/src/content.config.ts +5 -0
  33. package/dist/template/src/integrations/sveltia/config.test.ts +35 -0
  34. package/dist/template/src/integrations/sveltia/config.ts +28 -0
  35. package/dist/template/src/integrations/sveltia/preview-collections.test.ts +8 -0
  36. package/dist/template/src/integrations/sveltia/preview-collections.ts +3 -0
  37. package/dist/template/src/integrations/sveltia/previews.ts +13 -0
  38. package/dist/template/src/layouts/BaseLayout.astro +41 -0
  39. package/dist/template/src/lib/articles.ts +20 -0
  40. package/dist/template/src/lib/navigation.test.ts +20 -0
  41. package/dist/template/src/lib/navigation.ts +22 -0
  42. package/dist/template/src/pages/404.astro +17 -0
  43. package/dist/template/src/pages/about.astro +22 -0
  44. package/dist/template/src/pages/admin/index.astro +24 -0
  45. package/dist/template/src/pages/articles/[slug].astro +58 -0
  46. package/dist/template/src/pages/articles/index.astro +34 -0
  47. package/dist/template/src/pages/index.astro +64 -0
  48. package/dist/template/src/pages/robots.txt.ts +8 -0
  49. package/dist/template/src/pages/rss.xml.ts +17 -0
  50. package/dist/template/src/pages/site.webmanifest.ts +19 -0
  51. package/dist/template/src/styles/global.css +73 -0
  52. package/dist/template/src/styles/print.css +34 -0
  53. package/dist/template/src/themes/catppuccinTheme.frappe.ts +5 -0
  54. package/dist/template/src/themes/catppuccinTheme.latte.ts +5 -0
  55. package/dist/template/src/themes/catppuccinTheme.macchiato.ts +5 -0
  56. package/dist/template/src/themes/catppuccinTheme.mocha.ts +5 -0
  57. package/dist/template/src/themes/site-theme.test.ts +19 -0
  58. package/dist/template/src/themes/site-theme.ts +13 -0
  59. package/dist/template/stylelint.config.ts +19 -0
  60. package/dist/template/tests/accessibility.spec.ts +47 -0
  61. package/dist/template/tests/routes.spec.ts +87 -0
  62. package/dist/template/tests/theme.spec.ts +47 -0
  63. package/dist/template/tsconfig.json +5 -0
  64. package/dist/template/vitest.config.ts +8 -0
  65. package/package.json +52 -0
@@ -0,0 +1,28 @@
1
+ import { createSveltiaCollection } from '@rm-industries/content-model/sveltia';
2
+ import type { CmsConfig } from '@sveltia/cms';
3
+
4
+ import { contentModels } from '../../config/content-models/registry.ts';
5
+ import { cmsBranding } from '../../config/site.ts';
6
+
7
+ export const sveltiaConfig = {
8
+ load_config_file: false,
9
+ app_title: cmsBranding.appTitle,
10
+ logo: { src: '/favicon.svg' },
11
+ backend: {
12
+ name: 'github',
13
+ repo: 'your-github-user/your-repository',
14
+ branch: 'main',
15
+ auth_methods: ['token'],
16
+ commit_messages: {
17
+ create: 'content({{collection}}): create {{slug}}',
18
+ update: 'content({{collection}}): update {{slug}}',
19
+ delete: 'content({{collection}}): delete {{slug}}',
20
+ uploadMedia: 'content(assets): upload {{path}}',
21
+ deleteMedia: 'content(assets): delete {{path}}',
22
+ },
23
+ },
24
+ media_folder: 'public/assets',
25
+ public_folder: '/assets',
26
+ output: { omit_empty_optional_fields: true },
27
+ collections: contentModels.map(createSveltiaCollection),
28
+ } satisfies CmsConfig;
@@ -0,0 +1,8 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { articleContentModel } from '../../config/content-models/articles.ts';
4
+ import { previewCollectionNames } from './preview-collections.ts';
5
+
6
+ test('registers a preview for every collection that renders rich content', () => {
7
+ expect(previewCollectionNames).toEqual([articleContentModel.name]);
8
+ });
@@ -0,0 +1,3 @@
1
+ import { articleContentModel } from '../../config/content-models/articles.ts';
2
+
3
+ export const previewCollectionNames = [articleContentModel.name] as const;
@@ -0,0 +1,13 @@
1
+ import { registerPreviewTemplate, type CustomPreviewTemplateProps } from '@sveltia/cms';
2
+
3
+ import { previewCollectionNames } from './preview-collections';
4
+
5
+ const renderArticlePreview = ({ document, widgetFor }: CustomPreviewTemplateProps) => {
6
+ document.body.classList.add('prose', 'mx-auto');
7
+
8
+ return widgetFor('body');
9
+ };
10
+
11
+ export const registerSveltiaPreviews = (register = registerPreviewTemplate) => {
12
+ for (const name of previewCollectionNames) register(name, renderArticlePreview);
13
+ };
@@ -0,0 +1,41 @@
1
+ ---
2
+ import Footer from '../components/navigation/Footer.astro';
3
+ import Header from '../components/navigation/Header.astro';
4
+ import SeoHead from '../components/seo/SeoHead.astro';
5
+ import ThemeHead from '../components/theme/ThemeHead.astro';
6
+ import { site } from '../config/site';
7
+ import '../styles/global.css';
8
+
9
+ interface Props {
10
+ title?: string;
11
+ description?: string;
12
+ canonicalPath?: string;
13
+ type?: 'article' | 'website';
14
+ publishedTime?: string;
15
+ }
16
+
17
+ const { title, description, canonicalPath, type, publishedTime } = Astro.props;
18
+ ---
19
+
20
+ <!doctype html>
21
+ <html lang={site.language}>
22
+ <head>
23
+ <meta charset="UTF-8" />
24
+ <meta name="viewport" content="width=device-width" />
25
+ <SeoHead {title} {description} {canonicalPath} {type} {publishedTime} />
26
+ <ThemeHead />
27
+ </head>
28
+ <body>
29
+ <a
30
+ href="#main-content"
31
+ class="btn btn-primary btn-sm sr-only opacity-0 focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:opacity-100"
32
+ >
33
+ Skip to content
34
+ </a>
35
+ <Header />
36
+ <main id="main-content" tabindex="-1">
37
+ <slot />
38
+ </main>
39
+ <Footer />
40
+ </body>
41
+ </html>
@@ -0,0 +1,20 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+
3
+ export type Article = CollectionEntry<'articles'>;
4
+
5
+ export const sortArticles = (entries: readonly Article[]): Article[] =>
6
+ [...entries].sort((left, right) => right.data.publishedAt.getTime() - left.data.publishedAt.getTime());
7
+
8
+ export const getArticles = async ({ includeDrafts = false }: { includeDrafts?: boolean } = {}): Promise<Article[]> =>
9
+ sortArticles(await getCollection('articles', ({ data }) => includeDrafts || !data.draft));
10
+
11
+ export const getArticleNeighbors = (id: string, entries: readonly Article[]) => {
12
+ const index = entries.findIndex((entry) => entry.id === id);
13
+
14
+ if (index < 0) return {};
15
+
16
+ return {
17
+ previous: entries[index + 1],
18
+ next: entries[index - 1],
19
+ };
20
+ };
@@ -0,0 +1,20 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { isCurrentPath, isExternalHref } from './navigation.ts';
4
+
5
+ test('matches exact and nested navigation paths at segment boundaries', () => {
6
+ expect(isCurrentPath('/articles/', '/articles')).toBe(true);
7
+ expect(isCurrentPath('/articles/example/', '/articles/')).toBe(true);
8
+ expect(isCurrentPath('/articles-summary/', '/articles')).toBe(false);
9
+ });
10
+
11
+ test('matches the home route only at the site root', () => {
12
+ expect(isCurrentPath('/', '/')).toBe(true);
13
+ expect(isCurrentPath('/articles/', '/')).toBe(false);
14
+ });
15
+
16
+ test('does not treat external URLs as current paths', () => {
17
+ expect(isCurrentPath('/', 'https://example.com')).toBe(false);
18
+ expect(isExternalHref('https://example.com')).toBe(true);
19
+ expect(isExternalHref('/about/')).toBe(false);
20
+ });
@@ -0,0 +1,22 @@
1
+ /** Canonicalize local paths so active navigation is stable across URL variants. */
2
+ const normalizePath = (path: string) => {
3
+ const pathname = path.split(/[?#]/, 1)[0] || '/';
4
+ const withLeadingSlash = pathname.startsWith('/') ? pathname : `/${pathname}`;
5
+
6
+ if (withLeadingSlash === '/') return withLeadingSlash;
7
+
8
+ return withLeadingSlash.replace(/\/+$/, '');
9
+ };
10
+
11
+ export const isExternalHref = (href: string) => /^https?:\/\//u.test(href);
12
+
13
+ export const isCurrentPath = (currentPath: string, href: string) => {
14
+ if (!href.startsWith('/')) return false;
15
+
16
+ const current = normalizePath(currentPath);
17
+ const target = normalizePath(href);
18
+
19
+ if (target === '/') return current === target;
20
+
21
+ return current === target || current.startsWith(`${target}/`);
22
+ };
@@ -0,0 +1,17 @@
1
+ ---
2
+ import Container from '../components/ui/Container.astro';
3
+ import BaseLayout from '../layouts/BaseLayout.astro';
4
+ ---
5
+
6
+ <BaseLayout title="Page not found" description="The requested page could not be found." canonicalPath="/404/">
7
+ <Container size="sm" class="pb-16">
8
+ <section class="hero rounded-box bg-base-200 py-16 text-center">
9
+ <div class="hero-content flex-col">
10
+ <div class="badge badge-outline">404</div>
11
+ <h1 class="text-4xl font-bold">That page is not here.</h1>
12
+ <p>The address may have changed, or the page may no longer exist.</p>
13
+ <a href="/" class="btn btn-primary">Return home</a>
14
+ </div>
15
+ </section>
16
+ </Container>
17
+ </BaseLayout>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import Container from '../components/ui/Container.astro';
3
+ import BaseLayout from '../layouts/BaseLayout.astro';
4
+ ---
5
+
6
+ <BaseLayout title="About" description="Learn how this generic Forge starter is structured and how to make it your own.">
7
+ <Container size="md" class="pb-16">
8
+ <article class="prose mx-auto">
9
+ <div class="badge badge-outline">About this starter</div>
10
+ <h1>A useful foundation, not a finished identity.</h1>
11
+ <p class="lead">Forge provides an approachable structure for a content-driven website while leaving its subject, voice, and visual identity up to you.</p>
12
+
13
+ <h2>What to replace first</h2>
14
+ <p>Start with the typed site configuration, then replace these example articles with material that reflects your project. Shared layouts and metadata will update every route consistently.</p>
15
+
16
+ <h2>What is already connected</h2>
17
+ <p>Semantic navigation, responsive layout primitives, local fonts, theme-aware styling, canonical metadata, a sitemap, an RSS feed, and automated accessibility checks are ready to extend.</p>
18
+
19
+ <p><a href="/articles/">Explore the example articles</a> or return to the <a href="/">homepage</a>.</p>
20
+ </article>
21
+ </Container>
22
+ </BaseLayout>
@@ -0,0 +1,24 @@
1
+ ---
2
+ import SeoHead from '../../components/seo/SeoHead.astro';
3
+ import { site } from '../../config/site';
4
+ ---
5
+
6
+ <!doctype html>
7
+ <html lang={site.language}>
8
+ <head>
9
+ <meta charset="UTF-8" />
10
+ <meta name="viewport" content="width=device-width" />
11
+ <meta name="robots" content="noindex, nofollow" />
12
+ <SeoHead title="Content Manager" description={`Content management for ${site.name}.`} canonicalPath="/admin/" />
13
+ <script>
14
+ import { init } from '@sveltia/cms';
15
+
16
+ import { sveltiaConfig } from '../../integrations/sveltia/config';
17
+ import { registerSveltiaPreviews } from '../../integrations/sveltia/previews';
18
+
19
+ registerSveltiaPreviews();
20
+ init({ config: sveltiaConfig });
21
+ </script>
22
+ </head>
23
+ <body></body>
24
+ </html>
@@ -0,0 +1,58 @@
1
+ ---
2
+ import type { GetStaticPaths } from 'astro';
3
+ import { render } from 'astro:content';
4
+
5
+ import Pagination from '../../components/navigation/Pagination.astro';
6
+ import Container from '../../components/ui/Container.astro';
7
+ import BaseLayout from '../../layouts/BaseLayout.astro';
8
+ import { getArticleNeighbors, getArticles, type Article } from '../../lib/articles';
9
+
10
+ export const getStaticPaths = (async () => {
11
+ const articles = await getArticles({ includeDrafts: import.meta.env.DEV });
12
+
13
+ return articles.map((article) => ({
14
+ params: { slug: article.id },
15
+ props: { article, articles },
16
+ }));
17
+ }) satisfies GetStaticPaths;
18
+
19
+ interface Props {
20
+ article: Article;
21
+ articles: readonly Article[];
22
+ }
23
+
24
+ const { article, articles } = Astro.props;
25
+ const { Content } = await render(article);
26
+ const { previous, next } = getArticleNeighbors(article.id, articles);
27
+ const publishedAt = article.data.publishedAt.toISOString();
28
+ const formattedDate = article.data.publishedAt.toLocaleDateString('en', {
29
+ dateStyle: 'long',
30
+ timeZone: 'UTC',
31
+ });
32
+ ---
33
+
34
+ <BaseLayout
35
+ title={article.data.title}
36
+ description={article.data.description}
37
+ type="article"
38
+ publishedTime={publishedAt}
39
+ >
40
+ <Container size="md" class="space-y-10 pb-16">
41
+ <article class="prose mx-auto">
42
+ {article.data.draft && <div class="badge badge-warning">Draft preview</div>}
43
+ <h1>{article.data.title}</h1>
44
+ <p><time datetime={publishedAt}>{formattedDate}</time></p>
45
+ <p class="lead">{article.data.description}</p>
46
+ <div class="card-actions" aria-label="Article tags">
47
+ {article.data.tags.map((tag) => <span class="badge badge-outline">{tag}</span>)}
48
+ </div>
49
+ <Content />
50
+ </article>
51
+
52
+ <Pagination
53
+ label="Article navigation"
54
+ previous={previous && { href: `/articles/${previous.id}/`, label: previous.data.title }}
55
+ next={next && { href: `/articles/${next.id}/`, label: next.data.title }}
56
+ />
57
+ </Container>
58
+ </BaseLayout>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import Card from '../../components/ui/Card.astro';
3
+ import Container from '../../components/ui/Container.astro';
4
+ import BaseLayout from '../../layouts/BaseLayout.astro';
5
+ import { getArticles } from '../../lib/articles';
6
+
7
+ const articles = await getArticles({ includeDrafts: import.meta.env.DEV });
8
+ ---
9
+
10
+ <BaseLayout title="Articles" description="Generic example articles that demonstrate Forge content routes and metadata.">
11
+ <Container class="space-y-10 pb-16">
12
+ <header class="max-w-2xl space-y-3">
13
+ <div class="badge badge-outline">Example content</div>
14
+ <h1 class="text-4xl font-bold">Articles</h1>
15
+ <p>Replace these generic entries with your own subject matter when you connect the site to its content model.</p>
16
+ </header>
17
+
18
+ <div class="grid gap-6 md:grid-cols-2">
19
+ {
20
+ articles.map((article) => (
21
+ <Card href={`/articles/${article.id}/`} size="lg">
22
+ <time datetime={article.data.publishedAt.toISOString()}>{article.data.publishedAt.toLocaleDateString('en', { dateStyle: 'long', timeZone: 'UTC' })}</time>
23
+ <h2 class="card-title">{article.data.title}</h2>
24
+ <p>{article.data.description}</p>
25
+ <div class="card-actions" aria-label="Article tags">
26
+ {article.data.tags.map((tag) => <span class="badge badge-outline">{tag}</span>)}
27
+ </div>
28
+ {article.data.draft && <div class="badge badge-warning">Draft preview</div>}
29
+ </Card>
30
+ ))
31
+ }
32
+ </div>
33
+ </Container>
34
+ </BaseLayout>
@@ -0,0 +1,64 @@
1
+ ---
2
+ import BaseLayout from '../layouts/BaseLayout.astro';
3
+ import { site } from '../config/site';
4
+ import Card from '../components/ui/Card.astro';
5
+ import Container from '../components/ui/Container.astro';
6
+ ---
7
+
8
+ <BaseLayout>
9
+ <Container class="space-y-16 pb-16">
10
+ <section class="hero rounded-box bg-base-200 py-16 text-center">
11
+ <div class="hero-content flex-col">
12
+ <div class="badge badge-outline">A thoughtful Astro starting point</div>
13
+ <div class="max-w-3xl space-y-6">
14
+ <h1 class="text-5xl font-bold tracking-tight sm:text-6xl">Build something worth publishing.</h1>
15
+ <p class="text-lg">{site.description} Start with accessible components, typed configuration, and a theme system that works without client JavaScript.</p>
16
+ <div class="flex flex-wrap justify-center gap-3">
17
+ <a href="/articles/" class="btn btn-primary">Read the examples</a>
18
+ <a href="/about/" class="btn btn-ghost">About this starter</a>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ </section>
23
+
24
+ <section id="features" class="space-y-8" aria-labelledby="features-title">
25
+ <div class="max-w-2xl space-y-3">
26
+ <div class="badge">Included by default</div>
27
+ <h2 id="features-title" class="text-3xl font-bold">A solid foundation, ready for your content</h2>
28
+ <p>Forge keeps the starting point practical: enough structure to demonstrate the system, without prescribing what your site must become.</p>
29
+ </div>
30
+
31
+ <div class="grid gap-6 md:grid-cols-3">
32
+ <Card size="lg">
33
+ <div class="badge">Content</div>
34
+ <h3 class="card-title">Content-first structure</h3>
35
+ <p>Use reusable layouts and components for articles, notes, documentation, or a personal site.</p>
36
+ </Card>
37
+
38
+ <Card size="lg">
39
+ <div class="badge">Design</div>
40
+ <h3 class="card-title">Adaptable themes</h3>
41
+ <p>Start with four Catppuccin flavors, local fonts, and accessible DaisyUI components.</p>
42
+ </Card>
43
+
44
+ <Card size="lg">
45
+ <div class="badge">Quality</div>
46
+ <h3 class="card-title">Production-minded checks</h3>
47
+ <p>Formatting, linting, type checking, browser tests, and accessibility assertions are already connected.</p>
48
+ </Card>
49
+ </div>
50
+
51
+ </section>
52
+
53
+ <section id="start" class="card bg-base-200" aria-labelledby="start-title">
54
+ <div class="card-body items-start">
55
+ <div class="badge badge-outline">Make it yours</div>
56
+ <h2 id="start-title" class="card-title text-3xl">Replace the examples with your own story.</h2>
57
+ <p>Update the typed site configuration, choose your content model, and grow the template one reusable piece at a time.</p>
58
+ <div class="card-actions">
59
+ <a href="/articles/" class="btn">Browse the articles</a>
60
+ </div>
61
+ </div>
62
+ </section>
63
+ </Container>
64
+ </BaseLayout>
@@ -0,0 +1,8 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ import { site } from '../config/site';
4
+
5
+ export const GET: APIRoute = () =>
6
+ new Response(`User-agent: *\nAllow: /\nSitemap: ${new URL('sitemap-index.xml', site.url)}\n`, {
7
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
8
+ });
@@ -0,0 +1,17 @@
1
+ import rss from '@astrojs/rss';
2
+
3
+ import { site } from '../config/site';
4
+ import { getArticles } from '../lib/articles';
5
+
6
+ export const GET = async () =>
7
+ rss({
8
+ title: site.name,
9
+ description: site.description,
10
+ site: site.url,
11
+ items: (await getArticles()).map((article) => ({
12
+ title: article.data.title,
13
+ description: article.data.description,
14
+ link: `/articles/${article.id}/`,
15
+ pubDate: article.data.publishedAt,
16
+ })),
17
+ });
@@ -0,0 +1,19 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ import { site } from '../config/site';
4
+ import { darkThemeColor } from '../themes/site-theme';
5
+
6
+ export const GET: APIRoute = () =>
7
+ new Response(
8
+ JSON.stringify({
9
+ name: site.name,
10
+ short_name: site.name,
11
+ description: site.description,
12
+ start_url: '/',
13
+ display: 'standalone',
14
+ background_color: darkThemeColor,
15
+ theme_color: darkThemeColor,
16
+ icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }],
17
+ }),
18
+ { headers: { 'Content-Type': 'application/manifest+json' } },
19
+ );
@@ -0,0 +1,73 @@
1
+ @import 'tailwindcss';
2
+ @import '@fontsource/fira-sans/400.css';
3
+ @import '@fontsource/fira-sans/500.css';
4
+ @import '@fontsource/fira-sans/600.css';
5
+ @import '@fontsource/fira-sans/700.css';
6
+ @import '@fontsource/fira-code/400.css';
7
+ @import '@fontsource/fira-code/500.css';
8
+ @import './print.css';
9
+
10
+ @plugin 'daisyui' {
11
+ themes: false;
12
+ }
13
+
14
+ @plugin '@tailwindcss/typography';
15
+ @plugin '../themes/catppuccinTheme.latte.ts';
16
+ @plugin '../themes/catppuccinTheme.frappe.ts';
17
+ @plugin '../themes/catppuccinTheme.macchiato.ts';
18
+ @plugin '../themes/catppuccinTheme.mocha.ts';
19
+
20
+ @theme {
21
+ --font-sans: 'Fira Sans', ui-sans-serif, system-ui, sans-serif;
22
+ --font-mono: 'Fira Code', ui-monospace, monospace;
23
+ }
24
+
25
+ html {
26
+ min-height: 100%;
27
+ background-color: var(--color-base-100);
28
+ color: var(--color-base-content);
29
+ font-family: var(--font-sans);
30
+ }
31
+
32
+ body {
33
+ min-height: 100vh;
34
+ margin: 0;
35
+ }
36
+
37
+ .prose {
38
+ --tw-prose-body: var(--color-base-content);
39
+ --tw-prose-headings: var(--color-base-content);
40
+ --tw-prose-lead: var(--color-base-content);
41
+ --tw-prose-links: var(--color-primary);
42
+ --tw-prose-bold: var(--color-base-content);
43
+ --tw-prose-counters: var(--color-base-content);
44
+ --tw-prose-bullets: var(--color-primary);
45
+ --tw-prose-hr: var(--color-base-300);
46
+ --tw-prose-quotes: var(--color-base-content);
47
+ --tw-prose-quote-borders: var(--color-primary);
48
+ --tw-prose-captions: var(--color-base-content);
49
+ --tw-prose-kbd: var(--color-base-content);
50
+ --tw-prose-kbd-shadows: var(--color-base-300);
51
+ --tw-prose-code: var(--color-base-content);
52
+ --tw-prose-pre-code: var(--color-base-content);
53
+ --tw-prose-pre-bg: var(--color-base-200);
54
+ --tw-prose-th-borders: var(--color-base-300);
55
+ --tw-prose-td-borders: var(--color-base-300);
56
+ }
57
+
58
+ :where(a, button, input, select, summary, textarea):focus-visible {
59
+ border-radius: 0.125rem;
60
+ outline: 0.1875rem solid var(--color-primary);
61
+ outline-offset: 0.1875rem;
62
+ }
63
+
64
+ @media (prefers-reduced-motion: reduce) {
65
+ *,
66
+ *::before,
67
+ *::after {
68
+ scroll-behavior: auto !important;
69
+ animation-duration: 0.01ms !important;
70
+ animation-iteration-count: 1 !important;
71
+ transition-duration: 0.01ms !important;
72
+ }
73
+ }
@@ -0,0 +1,34 @@
1
+ .print-only {
2
+ display: none;
3
+ }
4
+
5
+ @media print {
6
+ @page {
7
+ margin: 0.6in;
8
+ }
9
+
10
+ html,
11
+ body {
12
+ background: #fff !important;
13
+ color: #000 !important;
14
+ }
15
+
16
+ body > header,
17
+ body > footer,
18
+ .no-print {
19
+ display: none !important;
20
+ }
21
+
22
+ .print-only {
23
+ display: block !important;
24
+ }
25
+
26
+ a[href]::after {
27
+ content: ' (' attr(href) ')';
28
+ }
29
+
30
+ a[href^='/']::after,
31
+ a[href^='#']::after {
32
+ content: '';
33
+ }
34
+ }
@@ -0,0 +1,5 @@
1
+ import { createCatppuccinPlugin } from '@catppuccin/daisyui';
2
+
3
+ import { getCatppuccinDaisyOptions } from './site-theme';
4
+
5
+ export default createCatppuccinPlugin('frappe', {}, getCatppuccinDaisyOptions('frappe'));
@@ -0,0 +1,5 @@
1
+ import { createCatppuccinPlugin } from '@catppuccin/daisyui';
2
+
3
+ import { getCatppuccinDaisyOptions } from './site-theme';
4
+
5
+ export default createCatppuccinPlugin('latte', {}, getCatppuccinDaisyOptions('latte'));
@@ -0,0 +1,5 @@
1
+ import { createCatppuccinPlugin } from '@catppuccin/daisyui';
2
+
3
+ import { getCatppuccinDaisyOptions } from './site-theme';
4
+
5
+ export default createCatppuccinPlugin('macchiato', {}, getCatppuccinDaisyOptions('macchiato'));
@@ -0,0 +1,5 @@
1
+ import { createCatppuccinPlugin } from '@catppuccin/daisyui';
2
+
3
+ import { getCatppuccinDaisyOptions } from './site-theme';
4
+
5
+ export default createCatppuccinPlugin('mocha', {}, getCatppuccinDaisyOptions('mocha'));
@@ -0,0 +1,19 @@
1
+ import { flavors } from '@catppuccin/palette';
2
+ import type { FlavorName } from '@catppuccin/palette';
3
+ import { expect, test } from 'vitest';
4
+
5
+ import { darkTheme, darkThemeColor, getCatppuccinDaisyOptions, lightTheme, lightThemeColor } from './site-theme.ts';
6
+
7
+ test('derives browser colors from the configured Catppuccin flavors', () => {
8
+ expect(lightThemeColor).toBe(flavors[lightTheme].colors.base.hex);
9
+ expect(darkThemeColor).toBe(flavors[darkTheme].colors.base.hex);
10
+ });
11
+
12
+ test('marks exactly one light default and one preferred dark theme', () => {
13
+ const flavorNames = Object.keys(flavors) as FlavorName[];
14
+ const defaults = flavorNames.filter((flavor) => getCatppuccinDaisyOptions(flavor).default);
15
+ const preferredDark = flavorNames.filter((flavor) => getCatppuccinDaisyOptions(flavor).prefersdark);
16
+
17
+ expect(defaults).toEqual([lightTheme]);
18
+ expect(preferredDark).toEqual([darkTheme]);
19
+ });
@@ -0,0 +1,13 @@
1
+ import { flavors } from '@catppuccin/palette';
2
+ import type { FlavorName } from '@catppuccin/palette';
3
+
4
+ export const lightTheme: FlavorName = 'latte';
5
+ export const darkTheme: FlavorName = 'mocha';
6
+
7
+ export const lightThemeColor = flavors[lightTheme].colors.base.hex;
8
+ export const darkThemeColor = flavors[darkTheme].colors.base.hex;
9
+
10
+ export const getCatppuccinDaisyOptions = (theme: FlavorName) => ({
11
+ default: theme === lightTheme,
12
+ prefersdark: theme === darkTheme,
13
+ });
@@ -0,0 +1,19 @@
1
+ import type { Config } from 'stylelint';
2
+
3
+ export default {
4
+ extends: ['stylelint-config-standard'],
5
+ rules: {
6
+ 'at-rule-no-unknown': [
7
+ true,
8
+ {
9
+ ignoreAtRules: ['apply', 'plugin', 'theme'],
10
+ },
11
+ ],
12
+ 'import-notation': [
13
+ 'string',
14
+ {
15
+ ignore: ['tailwindcss'],
16
+ },
17
+ ],
18
+ },
19
+ } satisfies Config;