@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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/index.mjs +511 -0
- package/dist/index.mjs.map +1 -0
- package/dist/template/.editorconfig +10 -0
- package/dist/template/.markdownlint.json +10 -0
- package/dist/template/README.md +169 -0
- package/dist/template/astro.config.ts +15 -0
- package/dist/template/cspell.config.ts +6 -0
- package/dist/template/oxfmt.config.ts +9 -0
- package/dist/template/package-lock.json +10896 -0
- package/dist/template/package.json +61 -0
- package/dist/template/playwright.config.ts +24 -0
- package/dist/template/public/favicon.svg +4 -0
- package/dist/template/public/social-card.svg +8 -0
- package/dist/template/src/components/navigation/Footer.astro +33 -0
- package/dist/template/src/components/navigation/Header.astro +11 -0
- package/dist/template/src/components/navigation/Navbar.astro +60 -0
- package/dist/template/src/components/navigation/Pagination.astro +37 -0
- package/dist/template/src/components/seo/SeoHead.astro +48 -0
- package/dist/template/src/components/theme/ThemeHead.astro +6 -0
- package/dist/template/src/components/ui/Card.astro +26 -0
- package/dist/template/src/components/ui/Container.astro +17 -0
- package/dist/template/src/config/content-models/articles.ts +39 -0
- package/dist/template/src/config/content-models/registry.ts +5 -0
- package/dist/template/src/config/site.test.ts +37 -0
- package/dist/template/src/config/site.ts +95 -0
- package/dist/template/src/content/articles/accessible-by-default.md +22 -0
- package/dist/template/src/content/articles/content-that-travels-well.md +17 -0
- package/dist/template/src/content/articles/designing-a-calm-starting-point.md +22 -0
- package/dist/template/src/content/articles/future-draft.md +11 -0
- package/dist/template/src/content.config.ts +5 -0
- package/dist/template/src/integrations/sveltia/config.test.ts +35 -0
- package/dist/template/src/integrations/sveltia/config.ts +28 -0
- package/dist/template/src/integrations/sveltia/preview-collections.test.ts +8 -0
- package/dist/template/src/integrations/sveltia/preview-collections.ts +3 -0
- package/dist/template/src/integrations/sveltia/previews.ts +13 -0
- package/dist/template/src/layouts/BaseLayout.astro +41 -0
- package/dist/template/src/lib/articles.ts +20 -0
- package/dist/template/src/lib/navigation.test.ts +20 -0
- package/dist/template/src/lib/navigation.ts +22 -0
- package/dist/template/src/pages/404.astro +17 -0
- package/dist/template/src/pages/about.astro +22 -0
- package/dist/template/src/pages/admin/index.astro +24 -0
- package/dist/template/src/pages/articles/[slug].astro +58 -0
- package/dist/template/src/pages/articles/index.astro +34 -0
- package/dist/template/src/pages/index.astro +64 -0
- package/dist/template/src/pages/robots.txt.ts +8 -0
- package/dist/template/src/pages/rss.xml.ts +17 -0
- package/dist/template/src/pages/site.webmanifest.ts +19 -0
- package/dist/template/src/styles/global.css +73 -0
- package/dist/template/src/styles/print.css +34 -0
- package/dist/template/src/themes/catppuccinTheme.frappe.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.latte.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.macchiato.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.mocha.ts +5 -0
- package/dist/template/src/themes/site-theme.test.ts +19 -0
- package/dist/template/src/themes/site-theme.ts +13 -0
- package/dist/template/stylelint.config.ts +19 -0
- package/dist/template/tests/accessibility.spec.ts +47 -0
- package/dist/template/tests/routes.spec.ts +87 -0
- package/dist/template/tests/theme.spec.ts +47 -0
- package/dist/template/tsconfig.json +5 -0
- package/dist/template/vitest.config.ts +8 -0
- package/package.json +52 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__RM_INDUSTRIES_FORGE_TEMPLATE_V1_PACKAGE_NAME__",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "astro dev",
|
|
8
|
+
"build": "astro build",
|
|
9
|
+
"preview": "astro preview",
|
|
10
|
+
"astro": "astro",
|
|
11
|
+
"astro:check": "astro check",
|
|
12
|
+
"audit": "npm audit --audit-level=high",
|
|
13
|
+
"format": "oxfmt --check",
|
|
14
|
+
"format:fix": "oxfmt",
|
|
15
|
+
"lint": "npm run lint:code && npm run lint:css && npm run lint:markdown && npm run spellcheck",
|
|
16
|
+
"lint:code": "oxlint",
|
|
17
|
+
"lint:code:fix": "oxlint --fix",
|
|
18
|
+
"lint:css": "stylelint \"**/*.css\" --ignore-path .gitignore",
|
|
19
|
+
"lint:css:fix": "stylelint --fix \"**/*.css\" --ignore-path .gitignore",
|
|
20
|
+
"lint:markdown": "markdownlint \"**/*.md\" --ignore-path .gitignore",
|
|
21
|
+
"lint:markdown:fix": "markdownlint --fix \"**/*.md\" --ignore-path .gitignore",
|
|
22
|
+
"spellcheck": "cspell \"**\"",
|
|
23
|
+
"typecheck": "astro sync && tsc --noEmit",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:e2e": "playwright test",
|
|
26
|
+
"quality": "npm run format && npm run lint && npm run typecheck && npm run astro:check && npm test && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@astrojs/rss": "^4.0.19",
|
|
30
|
+
"@astrojs/sitemap": "^3.7.3",
|
|
31
|
+
"@fontsource/fira-code": "^5.3.0",
|
|
32
|
+
"@fontsource/fira-sans": "^5.3.0",
|
|
33
|
+
"@rm-industries/content-model": "0.1.0-alpha.0",
|
|
34
|
+
"@sveltia/cms": "0.193.2",
|
|
35
|
+
"astro": "^7.2.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@astrojs/check": "^0.9.10",
|
|
39
|
+
"@axe-core/playwright": "^4.13.0",
|
|
40
|
+
"@catppuccin/daisyui": "^2.1.1",
|
|
41
|
+
"@catppuccin/palette": "^1.8.0",
|
|
42
|
+
"@playwright/test": "^1.62.1",
|
|
43
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
44
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
45
|
+
"cspell": "^10.0.1",
|
|
46
|
+
"daisyui": "^5.7.18",
|
|
47
|
+
"markdownlint-cli": "^0.49.0",
|
|
48
|
+
"oxfmt": "^0.63.0",
|
|
49
|
+
"oxlint": "^1.72.0",
|
|
50
|
+
"stylelint": "^17.14.1",
|
|
51
|
+
"stylelint-config-standard": "^40.0.0",
|
|
52
|
+
"tailwindcss": "^4.3.3",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": "^22.22.2 || ^24.0.0 || ^26.0.0",
|
|
58
|
+
"npm": "^10.9.0 || ^11.0.0"
|
|
59
|
+
},
|
|
60
|
+
"packageManager": "npm@11.18.0"
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: './tests',
|
|
5
|
+
fullyParallel: true,
|
|
6
|
+
forbidOnly: Boolean(process.env.CI),
|
|
7
|
+
retries: process.env.CI ? 2 : 0,
|
|
8
|
+
reporter: process.env.CI ? 'github' : 'list',
|
|
9
|
+
use: {
|
|
10
|
+
baseURL: 'http://127.0.0.1:4321',
|
|
11
|
+
trace: 'on-first-retry',
|
|
12
|
+
},
|
|
13
|
+
projects: [
|
|
14
|
+
{
|
|
15
|
+
name: 'chromium',
|
|
16
|
+
use: { ...devices['Desktop Chrome'] },
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
webServer: {
|
|
20
|
+
command: 'npm run dev -- --host 127.0.0.1',
|
|
21
|
+
url: 'http://127.0.0.1:4321',
|
|
22
|
+
reuseExistingServer: !process.env.CI,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
|
|
2
|
+
<title>Forge</title>
|
|
3
|
+
<desc>A generic social sharing card for a Forge website</desc>
|
|
4
|
+
<rect width="1200" height="630" fill="#1e1e2e" />
|
|
5
|
+
<rect x="72" y="72" width="1056" height="486" rx="40" fill="#313244" />
|
|
6
|
+
<text x="600" y="290" fill="#cdd6f4" font-family="system-ui, sans-serif" font-size="112" font-weight="700" text-anchor="middle">Forge</text>
|
|
7
|
+
<text x="600" y="380" fill="#bac2de" font-family="system-ui, sans-serif" font-size="38" text-anchor="middle">A thoughtful starting point for your content</text>
|
|
8
|
+
</svg>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '../../config/site';
|
|
3
|
+
import { isExternalHref } from '../../lib/navigation';
|
|
4
|
+
import Container from '../ui/Container.astro';
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<footer class="bg-base-200">
|
|
8
|
+
<Container class="footer footer-horizontal py-10">
|
|
9
|
+
<aside>
|
|
10
|
+
<h2>{site.name}</h2>
|
|
11
|
+
<p>{site.description}</p>
|
|
12
|
+
<p>© {new Date().getFullYear()} {site.name}</p>
|
|
13
|
+
</aside>
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
site.socialLinks.length > 0 && (
|
|
17
|
+
<nav aria-label="Social links">
|
|
18
|
+
<h2>Elsewhere</h2>
|
|
19
|
+
{site.socialLinks.map((item) => (
|
|
20
|
+
<a
|
|
21
|
+
href={item.href}
|
|
22
|
+
class="link link-hover"
|
|
23
|
+
target={isExternalHref(item.href) ? '_blank' : undefined}
|
|
24
|
+
rel={isExternalHref(item.href) ? 'noopener noreferrer' : undefined}
|
|
25
|
+
>
|
|
26
|
+
{item.label}
|
|
27
|
+
</a>
|
|
28
|
+
))}
|
|
29
|
+
</nav>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
</Container>
|
|
33
|
+
</footer>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '../../config/site';
|
|
3
|
+
import Container from '../ui/Container.astro';
|
|
4
|
+
import Navbar from './Navbar.astro';
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<header>
|
|
8
|
+
<Container class="py-4">
|
|
9
|
+
<Navbar brand={site.name} links={site.navigation} currentPath={Astro.url.pathname} />
|
|
10
|
+
</Container>
|
|
11
|
+
</header>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SiteLink } from '../../config/site';
|
|
3
|
+
import { isCurrentPath } from '../../lib/navigation';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
brand: string;
|
|
7
|
+
brandHref?: string;
|
|
8
|
+
links: readonly SiteLink[];
|
|
9
|
+
currentPath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { brand, brandHref = '/', links, currentPath } = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<div class="navbar rounded-box bg-base-200 shadow-sm">
|
|
16
|
+
<div class="navbar-start">
|
|
17
|
+
<a href={brandHref} class="btn btn-ghost text-xl">{brand}</a>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<nav class="navbar-end hidden sm:flex" aria-label="Primary navigation">
|
|
21
|
+
<ul class="menu menu-horizontal" role="list">
|
|
22
|
+
{
|
|
23
|
+
links.map((item) => {
|
|
24
|
+
const isCurrent = isCurrentPath(currentPath, item.href);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<li>
|
|
28
|
+
<a href={item.href} aria-current={isCurrent ? 'page' : undefined} class:list={[isCurrent && 'menu-active']}>
|
|
29
|
+
{item.label}
|
|
30
|
+
</a>
|
|
31
|
+
</li>
|
|
32
|
+
);
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
</ul>
|
|
36
|
+
</nav>
|
|
37
|
+
|
|
38
|
+
<div class="navbar-end sm:hidden">
|
|
39
|
+
<details class="dropdown dropdown-end">
|
|
40
|
+
<summary class="btn btn-ghost">Menu</summary>
|
|
41
|
+
<nav class="dropdown-content z-10 mt-3 w-52 rounded-box bg-base-200 p-2 shadow" aria-label="Mobile navigation">
|
|
42
|
+
<ul class="menu" role="list">
|
|
43
|
+
{
|
|
44
|
+
links.map((item) => {
|
|
45
|
+
const isCurrent = isCurrentPath(currentPath, item.href);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<li>
|
|
49
|
+
<a href={item.href} aria-current={isCurrent ? 'page' : undefined} class:list={[isCurrent && 'menu-active']}>
|
|
50
|
+
{item.label}
|
|
51
|
+
</a>
|
|
52
|
+
</li>
|
|
53
|
+
);
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
</ul>
|
|
57
|
+
</nav>
|
|
58
|
+
</details>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface PaginationLink {
|
|
3
|
+
href: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
previous?: PaginationLink;
|
|
9
|
+
next?: PaginationLink;
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { previous, next, label = 'Pagination' } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<nav aria-label={label} class="join grid grid-cols-2">
|
|
17
|
+
{
|
|
18
|
+
previous ? (
|
|
19
|
+
<a href={previous.href} rel="prev" class="btn btn-outline join-item">
|
|
20
|
+
<span aria-hidden="true">←</span>
|
|
21
|
+
<span>{previous.label}</span>
|
|
22
|
+
</a>
|
|
23
|
+
) : (
|
|
24
|
+
<span class="btn btn-disabled btn-outline join-item" aria-disabled="true">No previous page</span>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
{
|
|
28
|
+
next ? (
|
|
29
|
+
<a href={next.href} rel="next" class="btn btn-outline join-item">
|
|
30
|
+
<span>{next.label}</span>
|
|
31
|
+
<span aria-hidden="true">→</span>
|
|
32
|
+
</a>
|
|
33
|
+
) : (
|
|
34
|
+
<span class="btn btn-disabled btn-outline join-item" aria-disabled="true">No next page</span>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
</nav>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '../../config/site';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
canonicalPath?: string;
|
|
8
|
+
type?: 'article' | 'website';
|
|
9
|
+
publishedTime?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
title,
|
|
14
|
+
description = site.description,
|
|
15
|
+
canonicalPath = Astro.url.pathname,
|
|
16
|
+
type = 'website',
|
|
17
|
+
publishedTime,
|
|
18
|
+
} = Astro.props;
|
|
19
|
+
|
|
20
|
+
const normalizedPath = (() => {
|
|
21
|
+
const path = `/${canonicalPath}`.replace(/\/{2,}/g, '/');
|
|
22
|
+
return path === '/' ? path : `${path.replace(/\/$/, '')}/`;
|
|
23
|
+
})();
|
|
24
|
+
const canonicalUrl = new URL(normalizedPath, site.url);
|
|
25
|
+
const pageTitle = title ? `${title} | ${site.name}` : site.name;
|
|
26
|
+
const socialImageUrl = new URL(site.socialImage, site.url);
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<title>{pageTitle}</title>
|
|
30
|
+
<meta name="description" content={description} />
|
|
31
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
32
|
+
<link rel="sitemap" href="/sitemap-index.xml" />
|
|
33
|
+
<link rel="alternate" type="application/rss+xml" title={`${site.name} RSS feed`} href="/rss.xml" />
|
|
34
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
35
|
+
<link rel="manifest" href="/site.webmanifest" />
|
|
36
|
+
|
|
37
|
+
<meta property="og:site_name" content={site.name} />
|
|
38
|
+
<meta property="og:title" content={pageTitle} />
|
|
39
|
+
<meta property="og:description" content={description} />
|
|
40
|
+
<meta property="og:url" content={canonicalUrl} />
|
|
41
|
+
<meta property="og:type" content={type} />
|
|
42
|
+
<meta property="og:image" content={socialImageUrl} />
|
|
43
|
+
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
|
44
|
+
|
|
45
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
46
|
+
<meta name="twitter:title" content={pageTitle} />
|
|
47
|
+
<meta name="twitter:description" content={description} />
|
|
48
|
+
<meta name="twitter:image" content={socialImageUrl} />
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { darkThemeColor, lightThemeColor } from '../../themes/site-theme';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<meta name="theme-color" content={lightThemeColor} media="(prefers-color-scheme: light)" />
|
|
6
|
+
<meta name="theme-color" content={darkThemeColor} media="(prefers-color-scheme: dark)" />
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
class?: string;
|
|
4
|
+
href?: string;
|
|
5
|
+
size?: 'sm' | 'md' | 'lg';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { class: className, href, size = 'md' } = Astro.props;
|
|
9
|
+
const classes = ['card bg-base-200', `card-${size}`, className];
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
href ? (
|
|
14
|
+
<a href={href} class:list={classes}>
|
|
15
|
+
<div class="card-body">
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|
|
18
|
+
</a>
|
|
19
|
+
) : (
|
|
20
|
+
<div class:list={classes}>
|
|
21
|
+
<div class="card-body">
|
|
22
|
+
<slot />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
class?: string;
|
|
4
|
+
size?: 'sm' | 'md' | 'lg';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { class: className, size = 'lg' } = Astro.props;
|
|
8
|
+
const widths = {
|
|
9
|
+
sm: 'max-w-2xl',
|
|
10
|
+
md: 'max-w-4xl',
|
|
11
|
+
lg: 'max-w-5xl',
|
|
12
|
+
};
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<div class:list={['container mx-auto px-4', widths[size], className]}>
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineModel } from '@rm-industries/content-model';
|
|
2
|
+
|
|
3
|
+
export const articleContentModel = defineModel({
|
|
4
|
+
name: 'articles',
|
|
5
|
+
label: 'Articles',
|
|
6
|
+
labelSingular: 'Article',
|
|
7
|
+
folder: 'src/content/articles',
|
|
8
|
+
extensions: ['md', 'mdx'],
|
|
9
|
+
slug: '{{slug}}',
|
|
10
|
+
sort: {
|
|
11
|
+
fields: ['publishedAt', 'title'],
|
|
12
|
+
default: { field: 'publishedAt', direction: 'descending' },
|
|
13
|
+
},
|
|
14
|
+
fields: {
|
|
15
|
+
title: { kind: 'string', required: true, label: 'Title' },
|
|
16
|
+
description: {
|
|
17
|
+
kind: 'string',
|
|
18
|
+
required: true,
|
|
19
|
+
multiline: true,
|
|
20
|
+
label: 'Description',
|
|
21
|
+
help: 'A short summary used on listing pages and in social metadata.',
|
|
22
|
+
},
|
|
23
|
+
publishedAt: { kind: 'date', mode: 'date', required: true, label: 'Published at' },
|
|
24
|
+
tags: {
|
|
25
|
+
kind: 'list',
|
|
26
|
+
default: [],
|
|
27
|
+
items: { kind: 'string', required: true, label: 'Tag' },
|
|
28
|
+
label: 'Tags',
|
|
29
|
+
help: 'Use a few concise topics that help readers understand the article.',
|
|
30
|
+
},
|
|
31
|
+
draft: {
|
|
32
|
+
kind: 'boolean',
|
|
33
|
+
default: false,
|
|
34
|
+
label: 'Draft',
|
|
35
|
+
help: 'Draft articles appear locally but are excluded from production builds and feeds.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
body: { name: 'body', label: 'Body', required: true },
|
|
39
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { cmsBranding, defineSiteConfig, site, type SiteConfig } from './site.ts';
|
|
4
|
+
|
|
5
|
+
const validConfig = {
|
|
6
|
+
name: 'Example site',
|
|
7
|
+
description: 'An example site description.',
|
|
8
|
+
author: 'Example Author',
|
|
9
|
+
url: 'https://example.test',
|
|
10
|
+
repository: 'example/site',
|
|
11
|
+
language: 'en',
|
|
12
|
+
socialImage: '/social-card.svg',
|
|
13
|
+
navigation: [{ label: 'Home', href: '/' }],
|
|
14
|
+
socialLinks: [],
|
|
15
|
+
} satisfies SiteConfig;
|
|
16
|
+
|
|
17
|
+
test('normalizes and freezes valid configuration', () => {
|
|
18
|
+
const config = defineSiteConfig(validConfig);
|
|
19
|
+
|
|
20
|
+
expect(config.url).toBe('https://example.test/');
|
|
21
|
+
expect(Object.isFrozen(config)).toBe(true);
|
|
22
|
+
expect(Object.isFrozen(config.navigation)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('rejects an empty site name', () => {
|
|
26
|
+
expect(() => defineSiteConfig({ ...validConfig, name: ' ' })).toThrow(/name must not be empty/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('rejects a non-HTTP canonical URL', () => {
|
|
30
|
+
expect(() => defineSiteConfig({ ...validConfig, url: 'file:///example' })).toThrow(
|
|
31
|
+
/url must use the HTTP or HTTPS protocol/,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('derives CMS branding from the site name', () => {
|
|
36
|
+
expect(cmsBranding.appTitle).toBe(`${site.name} Content Manager`);
|
|
37
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface SiteLink {
|
|
2
|
+
label: string;
|
|
3
|
+
href: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SiteConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
author: string;
|
|
10
|
+
url: string;
|
|
11
|
+
repository: string;
|
|
12
|
+
language: string;
|
|
13
|
+
socialImage: string;
|
|
14
|
+
navigation: readonly SiteLink[];
|
|
15
|
+
socialLinks: readonly SiteLink[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const requireText = (value: string, field: string): string => {
|
|
19
|
+
const normalizedValue = value.trim();
|
|
20
|
+
|
|
21
|
+
if (!normalizedValue) {
|
|
22
|
+
throw new TypeError(`${field} must not be empty.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return normalizedValue;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const validateOptionalText = (value: string, field: string): string => {
|
|
29
|
+
const normalizedValue = value.trim();
|
|
30
|
+
if (/\p{Cc}/u.test(normalizedValue)) throw new TypeError(`${field} must not contain control characters.`);
|
|
31
|
+
return normalizedValue;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const requireSiteUrl = (value: string): string => {
|
|
35
|
+
let url: URL;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(value);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new TypeError('url must be an absolute URL.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
44
|
+
throw new TypeError('url must use the HTTP or HTTPS protocol.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
48
|
+
throw new TypeError('url must not include credentials, a query, or a fragment.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
|
|
52
|
+
|
|
53
|
+
return url.href;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const requireLinks = (links: readonly SiteLink[], field: string): readonly SiteLink[] =>
|
|
57
|
+
Object.freeze(
|
|
58
|
+
links.map((link, index) => ({
|
|
59
|
+
label: requireText(link.label, `${field}[${index}].label`),
|
|
60
|
+
href: requireText(link.href, `${field}[${index}].href`),
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const defineSiteConfig = (config: SiteConfig): Readonly<SiteConfig> =>
|
|
65
|
+
Object.freeze({
|
|
66
|
+
name: requireText(config.name, 'name'),
|
|
67
|
+
description: requireText(config.description, 'description'),
|
|
68
|
+
author: validateOptionalText(config.author, 'author'),
|
|
69
|
+
url: requireSiteUrl(config.url),
|
|
70
|
+
repository: validateOptionalText(config.repository, 'repository'),
|
|
71
|
+
language: requireText(config.language, 'language'),
|
|
72
|
+
socialImage: requireText(config.socialImage, 'socialImage'),
|
|
73
|
+
navigation: requireLinks(config.navigation, 'navigation'),
|
|
74
|
+
socialLinks: requireLinks(config.socialLinks, 'socialLinks'),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const site = defineSiteConfig({
|
|
78
|
+
name: '__RM_INDUSTRIES_FORGE_TEMPLATE_V1_SITE_NAME__',
|
|
79
|
+
description: '__RM_INDUSTRIES_FORGE_TEMPLATE_V1_DESCRIPTION__',
|
|
80
|
+
author: '__RM_INDUSTRIES_FORGE_TEMPLATE_V1_AUTHOR__',
|
|
81
|
+
url: '__RM_INDUSTRIES_FORGE_TEMPLATE_V1_URL__',
|
|
82
|
+
repository: '__RM_INDUSTRIES_FORGE_TEMPLATE_V1_REPOSITORY__',
|
|
83
|
+
language: 'en',
|
|
84
|
+
socialImage: '/social-card.svg',
|
|
85
|
+
navigation: [
|
|
86
|
+
{ label: 'Home', href: '/' },
|
|
87
|
+
{ label: 'Articles', href: '/articles/' },
|
|
88
|
+
{ label: 'About', href: '/about/' },
|
|
89
|
+
],
|
|
90
|
+
socialLinks: [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const cmsBranding = Object.freeze({
|
|
94
|
+
appTitle: `${site.name} Content Manager`,
|
|
95
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Accessible by default
|
|
3
|
+
description: Why landmarks, focus behavior, and readable metadata belong in the foundation rather than a final pass.
|
|
4
|
+
publishedAt: 2026-01-30
|
|
5
|
+
tags:
|
|
6
|
+
- Accessibility
|
|
7
|
+
- Foundations
|
|
8
|
+
draft: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Accessibility is most dependable when it is part of the page structure.
|
|
12
|
+
Semantic landmarks and visible focus states then benefit every route
|
|
13
|
+
automatically.
|
|
14
|
+
|
|
15
|
+
Automated checks do not replace thoughtful review, but they protect the
|
|
16
|
+
baseline while the site grows and changes hands.
|
|
17
|
+
|
|
18
|
+
## A dependable baseline
|
|
19
|
+
|
|
20
|
+
- Use semantic landmarks before adding ARIA.
|
|
21
|
+
- Keep keyboard focus visible.
|
|
22
|
+
- Test content at narrow and wide viewport sizes.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Content that travels well
|
|
3
|
+
description: A practical case for keeping example content portable, structured, and easy to replace.
|
|
4
|
+
publishedAt: 2026-02-21
|
|
5
|
+
tags:
|
|
6
|
+
- Content
|
|
7
|
+
- Portability
|
|
8
|
+
draft: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Portable content starts with a small contract: a stable slug, a useful summary,
|
|
12
|
+
a publication date, and the text readers came to find.
|
|
13
|
+
|
|
14
|
+
Keeping the example data separate from route rendering makes the eventual move
|
|
15
|
+
to a content system explicit instead of accidental.
|
|
16
|
+
|
|
17
|
+
> Portable content should remain useful when the presentation layer changes.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Designing a calm starting point
|
|
3
|
+
description: How a small set of deliberate defaults can leave room for a site to develop its own identity.
|
|
4
|
+
publishedAt: 2026-03-14
|
|
5
|
+
tags:
|
|
6
|
+
- Design
|
|
7
|
+
- Defaults
|
|
8
|
+
draft: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
A useful starter should make the first page feel considered without deciding
|
|
12
|
+
what every future page must become.
|
|
13
|
+
|
|
14
|
+
Begin with a clear hierarchy, dependable spacing, and components that can be
|
|
15
|
+
replaced one at a time. The result is easier to understand and safer to
|
|
16
|
+
customize.
|
|
17
|
+
|
|
18
|
+
## Start with the essentials
|
|
19
|
+
|
|
20
|
+
1. Establish a readable hierarchy.
|
|
21
|
+
2. Keep the first set of components small.
|
|
22
|
+
3. Replace examples as the site's purpose becomes clearer.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: A future draft
|
|
3
|
+
description: An unpublished entry used to prove production filtering.
|
|
4
|
+
publishedAt: 2027-01-01
|
|
5
|
+
tags:
|
|
6
|
+
- Drafts
|
|
7
|
+
draft: true
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
This draft is available to local development but must never be emitted by a
|
|
11
|
+
production build or feed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { articleContentModel } from '../../config/content-models/articles.ts';
|
|
4
|
+
import { sveltiaConfig } from './config.ts';
|
|
5
|
+
|
|
6
|
+
test('derives the article collection from the shared model', () => {
|
|
7
|
+
expect(sveltiaConfig.collections.map(({ name }) => name)).toEqual([articleContentModel.name]);
|
|
8
|
+
expect(sveltiaConfig.collections[0]?.fields.map(({ name }) => name)).toEqual([
|
|
9
|
+
...Object.keys(articleContentModel.fields),
|
|
10
|
+
'body',
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('keeps authentication credentials outside the configuration', () => {
|
|
15
|
+
expect(sveltiaConfig.backend.name).toBe('github');
|
|
16
|
+
expect('token' in sveltiaConfig.backend).toBe(false);
|
|
17
|
+
expect(JSON.stringify(sveltiaConfig)).not.toContain('your-token');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('exposes tags as a default-empty list derived from the article model', () => {
|
|
21
|
+
const tags = sveltiaConfig.collections[0]?.fields.find(({ name }) => name === 'tags');
|
|
22
|
+
|
|
23
|
+
expect(tags).toEqual({
|
|
24
|
+
name: 'tags',
|
|
25
|
+
label: 'Tags',
|
|
26
|
+
after_input: 'Use a few concise topics that help readers understand the article.',
|
|
27
|
+
required: false,
|
|
28
|
+
widget: 'list',
|
|
29
|
+
default: [],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('serializes the generated configuration', () => {
|
|
34
|
+
expect(() => JSON.stringify(sveltiaConfig)).not.toThrow();
|
|
35
|
+
});
|