@rm-industries/create-forge 0.3.0-alpha.0 → 0.4.0-beta.1
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/dist/index.mjs +31 -3
- package/dist/index.mjs.map +1 -1
- package/dist/template/.github/actions/setup-project/action.yml +24 -0
- package/dist/template/.github/dependabot.yml +45 -0
- package/dist/template/.github/workflows/automation.yml +59 -0
- package/dist/template/.github/workflows/project.yml +235 -0
- package/dist/template/.github/workflows/security.yml +70 -0
- package/dist/template/.gitignore.template +261 -0
- package/dist/template/.lighthouserc.json +28 -0
- package/dist/template/README.md +121 -22
- package/dist/template/audit-ci.jsonc +20 -0
- package/dist/template/cspell.config.ts +19 -2
- package/dist/template/docs/accessibility-checklist.md +50 -0
- package/dist/template/docs/performance-budget.md +68 -0
- package/dist/template/package-lock.json +5663 -390
- package/dist/template/package.json +19 -6
- package/dist/template/playwright.config.ts +9 -3
- package/dist/template/scripts/validate-build.test.ts +58 -0
- package/dist/template/scripts/validate-build.ts +64 -0
- package/dist/template/src/config/content-models/registry.test.ts +14 -0
- package/dist/template/src/config/site.test.ts +13 -0
- package/dist/template/src/content.config.test.ts +8 -0
- package/dist/template/src/integrations/sveltia/previews.test.ts +23 -0
- package/dist/template/src/lib/articles.test.ts +43 -0
- package/dist/template/src/lib/articles.ts +1 -1
- package/dist/template/src/lib/navigation.test.ts +2 -0
- package/dist/template/src/styles/global.css +7 -1
- package/dist/template/stylelint.config.ts +2 -0
- package/dist/template/tests/accessibility.spec.ts +56 -10
- package/dist/template/tests/navigation.spec.ts +43 -0
- package/dist/template/tests/routes.spec.ts +19 -12
- package/dist/template/vitest.config.ts +18 -1
- package/package.json +1 -1
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
"dev": "astro dev",
|
|
8
8
|
"build": "astro build",
|
|
9
9
|
"preview": "astro preview",
|
|
10
|
+
"validate:build": "node scripts/validate-build.ts",
|
|
10
11
|
"astro": "astro",
|
|
11
12
|
"astro:check": "astro check",
|
|
12
|
-
"audit": "
|
|
13
|
+
"audit": "audit-ci --config audit-ci.jsonc",
|
|
14
|
+
"audit:unused": "knip",
|
|
13
15
|
"format": "oxfmt --check",
|
|
14
16
|
"format:fix": "oxfmt",
|
|
15
17
|
"lint": "npm run lint:code && npm run lint:css && npm run lint:markdown && npm run spellcheck",
|
|
@@ -19,19 +21,26 @@
|
|
|
19
21
|
"lint:css:fix": "stylelint --fix \"**/*.css\" --ignore-path .gitignore",
|
|
20
22
|
"lint:markdown": "markdownlint \"**/*.md\" --ignore-path .gitignore",
|
|
21
23
|
"lint:markdown:fix": "markdownlint --fix \"**/*.md\" --ignore-path .gitignore",
|
|
24
|
+
"lighthouse:ci": "npm run build && lhci autorun",
|
|
22
25
|
"spellcheck": "cspell \"**\"",
|
|
23
26
|
"typecheck": "astro sync && tsc --noEmit",
|
|
24
|
-
"test": "
|
|
27
|
+
"test": "npm run test:unit",
|
|
28
|
+
"test:a11y": "playwright test tests/accessibility.spec.ts",
|
|
29
|
+
"test:coverage": "vitest run --coverage",
|
|
25
30
|
"test:e2e": "playwright test",
|
|
26
|
-
"
|
|
31
|
+
"test:unit": "vitest run",
|
|
32
|
+
"quality": "npm run quality:core && npm run test:e2e && npm run lighthouse:ci",
|
|
33
|
+
"quality:core": "npm run quality:static && npm run test:coverage && npm run build && npm run validate:build",
|
|
34
|
+
"quality:static": "npm run format && npm run lint && npm run audit:unused && npm run typecheck && npm run astro:check && npm run audit",
|
|
35
|
+
"quality:static:ci": "npm run lint:code && npm run lint:css && npm run lint:markdown && npm run audit:unused && npm run typecheck && npm run astro:check && npm run audit"
|
|
27
36
|
},
|
|
28
37
|
"dependencies": {
|
|
29
38
|
"@astrojs/rss": "^4.0.19",
|
|
30
39
|
"@astrojs/sitemap": "^3.7.3",
|
|
31
40
|
"@fontsource/fira-code": "^5.3.0",
|
|
32
41
|
"@fontsource/fira-sans": "^5.3.0",
|
|
33
|
-
"@rm-industries/content-model": "0.
|
|
34
|
-
"@sveltia/cms": "0.
|
|
42
|
+
"@rm-industries/content-model": "0.2.0-alpha.0",
|
|
43
|
+
"@sveltia/cms": "0.203.2",
|
|
35
44
|
"astro": "^7.2.3"
|
|
36
45
|
},
|
|
37
46
|
"devDependencies": {
|
|
@@ -39,11 +48,15 @@
|
|
|
39
48
|
"@axe-core/playwright": "^4.13.0",
|
|
40
49
|
"@catppuccin/daisyui": "^2.1.1",
|
|
41
50
|
"@catppuccin/palette": "^1.8.0",
|
|
51
|
+
"@lhci/cli": "0.15.1",
|
|
42
52
|
"@playwright/test": "^1.62.1",
|
|
43
53
|
"@tailwindcss/typography": "^0.5.20",
|
|
44
54
|
"@tailwindcss/vite": "^4.3.3",
|
|
55
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
56
|
+
"audit-ci": "7.1.0",
|
|
45
57
|
"cspell": "^10.0.1",
|
|
46
58
|
"daisyui": "^5.7.18",
|
|
59
|
+
"knip": "^6.33.0",
|
|
47
60
|
"markdownlint-cli": "^0.49.0",
|
|
48
61
|
"oxfmt": "^0.63.0",
|
|
49
62
|
"oxlint": "^1.72.0",
|
|
@@ -57,5 +70,5 @@
|
|
|
57
70
|
"node": "^22.22.2 || ^24.0.0 || ^26.0.0",
|
|
58
71
|
"npm": "^10.9.0 || ^11.0.0"
|
|
59
72
|
},
|
|
60
|
-
"packageManager": "npm@11.
|
|
73
|
+
"packageManager": "npm@11.19.0"
|
|
61
74
|
}
|
|
@@ -2,13 +2,15 @@ import { defineConfig, devices } from '@playwright/test';
|
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
testDir: './tests',
|
|
5
|
+
outputDir: 'test-results',
|
|
5
6
|
fullyParallel: true,
|
|
6
7
|
forbidOnly: Boolean(process.env.CI),
|
|
7
8
|
retries: process.env.CI ? 2 : 0,
|
|
8
|
-
reporter: process.env.CI ? 'github' : 'list',
|
|
9
|
+
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
|
|
9
10
|
use: {
|
|
10
11
|
baseURL: 'http://127.0.0.1:4321',
|
|
11
|
-
|
|
12
|
+
screenshot: 'only-on-failure',
|
|
13
|
+
trace: 'retain-on-failure',
|
|
12
14
|
},
|
|
13
15
|
projects: [
|
|
14
16
|
{
|
|
@@ -17,7 +19,11 @@ export default defineConfig({
|
|
|
17
19
|
},
|
|
18
20
|
],
|
|
19
21
|
webServer: {
|
|
20
|
-
command: 'npm run
|
|
22
|
+
command: 'npm run build && npm run preview -- --host 127.0.0.1',
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
ASTRO_PREVIEW_BACKGROUND: '0',
|
|
26
|
+
},
|
|
21
27
|
url: 'http://127.0.0.1:4321',
|
|
22
28
|
reuseExistingServer: !process.env.CI,
|
|
23
29
|
},
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { validateBuild } from './validate-build';
|
|
8
|
+
|
|
9
|
+
const requiredPaths = [
|
|
10
|
+
'404.html',
|
|
11
|
+
'about/index.html',
|
|
12
|
+
'admin/index.html',
|
|
13
|
+
'articles/accessible-by-default/index.html',
|
|
14
|
+
'articles/content-that-travels-well/index.html',
|
|
15
|
+
'articles/designing-a-calm-starting-point/index.html',
|
|
16
|
+
'articles/index.html',
|
|
17
|
+
'index.html',
|
|
18
|
+
'robots.txt',
|
|
19
|
+
'rss.xml',
|
|
20
|
+
'site.webmanifest',
|
|
21
|
+
'sitemap-index.xml',
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const fixtures: string[] = [];
|
|
25
|
+
|
|
26
|
+
const createFixture = async () => {
|
|
27
|
+
const fixture = await mkdtemp(join(tmpdir(), 'forge-build-output-'));
|
|
28
|
+
fixtures.push(fixture);
|
|
29
|
+
for (const path of requiredPaths) {
|
|
30
|
+
const output = join(fixture, path);
|
|
31
|
+
await mkdir(dirname(output), { recursive: true });
|
|
32
|
+
await writeFile(output, path.endsWith('.html') ? '<!doctype html><main>Forge</main>' : 'Forge\n');
|
|
33
|
+
}
|
|
34
|
+
return fixture;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await Promise.all(fixtures.splice(0).map((fixture) => rm(fixture, { recursive: true, force: true })));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('generated build validation', () => {
|
|
42
|
+
test('accepts the complete neutral output fixture', async () => {
|
|
43
|
+
await expect(validateBuild(await createFixture())).resolves.toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('reports a missing required output path', async () => {
|
|
47
|
+
const fixture = await createFixture();
|
|
48
|
+
await rm(join(fixture, 'rss.xml'));
|
|
49
|
+
await expect(validateBuild(fixture)).rejects.toThrow('rss.xml');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejects unresolved generator tokens', async () => {
|
|
53
|
+
const fixture = await createFixture();
|
|
54
|
+
const unresolvedToken = '__FORGE_' + 'SITE_NAME__';
|
|
55
|
+
await writeFile(join(fixture, 'index.html'), `<p>${unresolvedToken}</p>`);
|
|
56
|
+
await expect(validateBuild(fixture)).rejects.toThrow(unresolvedToken);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const requiredOutputPaths = [
|
|
6
|
+
'404.html',
|
|
7
|
+
'about/index.html',
|
|
8
|
+
'admin/index.html',
|
|
9
|
+
'articles/accessible-by-default/index.html',
|
|
10
|
+
'articles/content-that-travels-well/index.html',
|
|
11
|
+
'articles/designing-a-calm-starting-point/index.html',
|
|
12
|
+
'articles/index.html',
|
|
13
|
+
'index.html',
|
|
14
|
+
'robots.txt',
|
|
15
|
+
'rss.xml',
|
|
16
|
+
'site.webmanifest',
|
|
17
|
+
'sitemap-index.xml',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const forbiddenOutputPatterns = [/__FORGE_[A-Z0-9_]+__/u, /FORGE_[A-Z0-9_]+_PLACEHOLDER/u] as const;
|
|
21
|
+
|
|
22
|
+
const listFiles = async (directory: string, root = directory): Promise<string[]> => {
|
|
23
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
24
|
+
const files: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const path = join(directory, entry.name);
|
|
28
|
+
if (entry.isDirectory()) files.push(...(await listFiles(path, root)));
|
|
29
|
+
if (entry.isFile()) files.push(relative(root, path));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return files;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const validateBuild = async (outputDirectory: string) => {
|
|
36
|
+
const output = resolve(outputDirectory);
|
|
37
|
+
if (!(await stat(output).catch(() => undefined))?.isDirectory()) {
|
|
38
|
+
throw new Error(`Build output directory does not exist: ${output}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = await listFiles(output);
|
|
42
|
+
const missingPaths = requiredOutputPaths.filter((path) => !files.includes(path));
|
|
43
|
+
if (missingPaths.length > 0) {
|
|
44
|
+
throw new Error(`Build output is missing required paths:\n${missingPaths.join('\n')}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const path of files) {
|
|
48
|
+
const content = await readFile(join(output, path), 'utf8').catch(() => undefined);
|
|
49
|
+
if (content === undefined || content.includes('\0')) continue;
|
|
50
|
+
|
|
51
|
+
for (const pattern of forbiddenOutputPatterns) {
|
|
52
|
+
const match = content.match(pattern);
|
|
53
|
+
if (match) throw new Error(`Build output contains forbidden text in ${path}: ${match[0]}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isEntryPoint = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
59
|
+
|
|
60
|
+
if (isEntryPoint) {
|
|
61
|
+
const projectDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
62
|
+
await validateBuild(join(projectDirectory, 'dist'));
|
|
63
|
+
console.log(`Validated ${requiredOutputPaths.length} required build outputs and scanned generated text.`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineModels } from '@rm-industries/content-model';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { articleContentModel } from './articles.ts';
|
|
5
|
+
import { contentModels } from './registry.ts';
|
|
6
|
+
|
|
7
|
+
test('registers the shared article model', () => {
|
|
8
|
+
expect(contentModels).toEqual([articleContentModel]);
|
|
9
|
+
expect(contentModels[0]?.fields.tags).toMatchObject({ kind: 'list', default: [] });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('rejects duplicate model names', () => {
|
|
13
|
+
expect(() => defineModels([articleContentModel, articleContentModel])).toThrow(/duplicate collection name/i);
|
|
14
|
+
});
|
|
@@ -32,6 +32,19 @@ test('rejects a non-HTTP canonical URL', () => {
|
|
|
32
32
|
);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test('rejects malformed canonical URLs and URL metadata', () => {
|
|
36
|
+
expect(() => defineSiteConfig({ ...validConfig, url: 'relative' })).toThrow(/absolute URL/);
|
|
37
|
+
expect(() => defineSiteConfig({ ...validConfig, url: 'https://user@example.test' })).toThrow(/credentials/);
|
|
38
|
+
expect(() => defineSiteConfig({ ...validConfig, url: 'https://example.test?query=true' })).toThrow(/query/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('rejects control characters in optional text and invalid links', () => {
|
|
42
|
+
expect(() => defineSiteConfig({ ...validConfig, author: 'Example\u0000Author' })).toThrow(/control characters/);
|
|
43
|
+
expect(() => defineSiteConfig({ ...validConfig, navigation: [{ label: ' ', href: '/' }] })).toThrow(
|
|
44
|
+
/navigation\[0\]\.label must not be empty/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
test('derives CMS branding from the site name', () => {
|
|
36
49
|
expect(cmsBranding.appTitle).toBe(`${site.name} Content Manager`);
|
|
37
50
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { contentModels } from './config/content-models/registry.ts';
|
|
4
|
+
import { collections } from './content.config.ts';
|
|
5
|
+
|
|
6
|
+
test('registers every shared model with the Astro adapter', () => {
|
|
7
|
+
expect(Object.keys(collections)).toEqual(contentModels.map((model) => model.name));
|
|
8
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@sveltia/cms', () => ({
|
|
4
|
+
registerPreviewTemplate: vi.fn<(name: string, render: unknown) => void>(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { registerSveltiaPreviews } from './previews.ts';
|
|
8
|
+
|
|
9
|
+
test('registers and renders the article preview', () => {
|
|
10
|
+
const register = vi.fn<NonNullable<Parameters<typeof registerSveltiaPreviews>[0]>>();
|
|
11
|
+
registerSveltiaPreviews(register);
|
|
12
|
+
|
|
13
|
+
expect(register).toHaveBeenCalledOnce();
|
|
14
|
+
const [name, render] = register.mock.calls[0] ?? [];
|
|
15
|
+
const add = vi.fn<(first: string, second: string) => void>();
|
|
16
|
+
const widgetFor = vi.fn<(name: string) => string>(() => 'rendered body');
|
|
17
|
+
const result = render({ document: { body: { classList: { add } } }, widgetFor } as never);
|
|
18
|
+
|
|
19
|
+
expect(name).toBe('articles');
|
|
20
|
+
expect(add).toHaveBeenCalledWith('prose', 'mx-auto');
|
|
21
|
+
expect(widgetFor).toHaveBeenCalledWith('body');
|
|
22
|
+
expect(result).toBe('rendered body');
|
|
23
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { getCollection } = vi.hoisted(() => ({
|
|
4
|
+
getCollection: vi.fn<(name: string, filter: (entry: Article) => boolean) => Promise<Article[]>>(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('astro:content', () => ({ getCollection }));
|
|
8
|
+
|
|
9
|
+
import { getArticleNeighbors, getArticles, type Article } from './articles.ts';
|
|
10
|
+
|
|
11
|
+
const article = (id: string, publishedAt: string, draft = false) =>
|
|
12
|
+
({ id, data: { publishedAt: new Date(publishedAt), draft } }) as Article;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
getCollection.mockReset();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('filters drafts and sorts published articles newest first', async () => {
|
|
19
|
+
const entries = [
|
|
20
|
+
article('older', '2025-01-01'),
|
|
21
|
+
article('draft', '2026-01-01', true),
|
|
22
|
+
article('newer', '2025-06-01'),
|
|
23
|
+
];
|
|
24
|
+
getCollection.mockImplementation(async (_name, filter: (entry: Article) => boolean) => entries.filter(filter));
|
|
25
|
+
|
|
26
|
+
await expect(getArticles()).resolves.toEqual([entries[2], entries[0]]);
|
|
27
|
+
expect(getCollection).toHaveBeenCalledWith('articles', expect.any(Function));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('can include drafts', async () => {
|
|
31
|
+
const entries = [article('published', '2025-01-01'), article('draft', '2026-01-01', true)];
|
|
32
|
+
getCollection.mockImplementation(async (_name, filter: (entry: Article) => boolean) => entries.filter(filter));
|
|
33
|
+
|
|
34
|
+
await expect(getArticles({ includeDrafts: true })).resolves.toEqual([entries[1], entries[0]]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('returns previous and next neighbors without wrapping', () => {
|
|
38
|
+
const entries = [article('newest', '2026-01-01'), article('middle', '2025-01-01'), article('oldest', '2024-01-01')];
|
|
39
|
+
|
|
40
|
+
expect(getArticleNeighbors('middle', entries)).toEqual({ previous: entries[2], next: entries[0] });
|
|
41
|
+
expect(getArticleNeighbors('newest', entries)).toEqual({ previous: entries[1], next: undefined });
|
|
42
|
+
expect(getArticleNeighbors('missing', entries)).toEqual({});
|
|
43
|
+
});
|
|
@@ -2,7 +2,7 @@ import { getCollection, type CollectionEntry } from 'astro:content';
|
|
|
2
2
|
|
|
3
3
|
export type Article = CollectionEntry<'articles'>;
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const sortArticles = (entries: readonly Article[]): Article[] =>
|
|
6
6
|
[...entries].sort((left, right) => right.data.publishedAt.getTime() - left.data.publishedAt.getTime());
|
|
7
7
|
|
|
8
8
|
export const getArticles = async ({ includeDrafts = false }: { includeDrafts?: boolean } = {}): Promise<Article[]> =>
|
|
@@ -4,12 +4,14 @@ import { isCurrentPath, isExternalHref } from './navigation.ts';
|
|
|
4
4
|
|
|
5
5
|
test('matches exact and nested navigation paths at segment boundaries', () => {
|
|
6
6
|
expect(isCurrentPath('/articles/', '/articles')).toBe(true);
|
|
7
|
+
expect(isCurrentPath('articles/?page=2', '/articles/#content')).toBe(true);
|
|
7
8
|
expect(isCurrentPath('/articles/example/', '/articles/')).toBe(true);
|
|
8
9
|
expect(isCurrentPath('/articles-summary/', '/articles')).toBe(false);
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
test('matches the home route only at the site root', () => {
|
|
12
13
|
expect(isCurrentPath('/', '/')).toBe(true);
|
|
14
|
+
expect(isCurrentPath('', '/')).toBe(true);
|
|
13
15
|
expect(isCurrentPath('/articles/', '/')).toBe(false);
|
|
14
16
|
});
|
|
15
17
|
|
|
@@ -38,7 +38,7 @@ body {
|
|
|
38
38
|
--tw-prose-body: var(--color-base-content);
|
|
39
39
|
--tw-prose-headings: var(--color-base-content);
|
|
40
40
|
--tw-prose-lead: var(--color-base-content);
|
|
41
|
-
--tw-prose-links: var(--color-
|
|
41
|
+
--tw-prose-links: var(--color-base-content);
|
|
42
42
|
--tw-prose-bold: var(--color-base-content);
|
|
43
43
|
--tw-prose-counters: var(--color-base-content);
|
|
44
44
|
--tw-prose-bullets: var(--color-primary);
|
|
@@ -55,6 +55,12 @@ body {
|
|
|
55
55
|
--tw-prose-td-borders: var(--color-base-300);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
.prose a {
|
|
59
|
+
text-decoration: underline;
|
|
60
|
+
text-decoration-thickness: 0.125rem;
|
|
61
|
+
text-underline-offset: 0.1875rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
:where(a, button, input, select, summary, textarea):focus-visible {
|
|
59
65
|
border-radius: 0.125rem;
|
|
60
66
|
outline: 0.1875rem solid var(--color-primary);
|
|
@@ -6,12 +6,14 @@ export default {
|
|
|
6
6
|
'at-rule-no-unknown': [
|
|
7
7
|
true,
|
|
8
8
|
{
|
|
9
|
+
// These directives are parsed by Tailwind and DaisyUI during the build.
|
|
9
10
|
ignoreAtRules: ['apply', 'plugin', 'theme'],
|
|
10
11
|
},
|
|
11
12
|
],
|
|
12
13
|
'import-notation': [
|
|
13
14
|
'string',
|
|
14
15
|
{
|
|
16
|
+
// Tailwind's bare package import is valid input for its Vite plugin.
|
|
15
17
|
ignore: ['tailwindcss'],
|
|
16
18
|
},
|
|
17
19
|
],
|
|
@@ -1,23 +1,50 @@
|
|
|
1
1
|
import AxeBuilder from '@axe-core/playwright';
|
|
2
2
|
import { expect, test } from '@playwright/test';
|
|
3
|
+
import type { Page } from '@playwright/test';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const publicRoutes = [
|
|
6
|
+
{ name: 'home', path: '/' },
|
|
7
|
+
{ name: 'about', path: '/about/' },
|
|
8
|
+
{ name: 'article listing', path: '/articles/' },
|
|
9
|
+
{ name: 'article detail', path: '/articles/designing-a-calm-starting-point/' },
|
|
10
|
+
{ name: 'not found', path: '/does-not-exist/' },
|
|
11
|
+
] as const;
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
const
|
|
13
|
+
const analyzePage = async (page: Page) => {
|
|
14
|
+
const results = await new AxeBuilder({ page })
|
|
15
|
+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
|
|
16
|
+
.analyze();
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
}
|
|
18
|
+
return results.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
for (const colorScheme of ['light', 'dark'] as const) {
|
|
22
|
+
for (const route of publicRoutes) {
|
|
23
|
+
test(`${route.name} has no serious or critical violations in ${colorScheme} mode`, async ({ page }) => {
|
|
24
|
+
await page.emulateMedia({ colorScheme });
|
|
25
|
+
await page.goto(route.path);
|
|
26
|
+
|
|
27
|
+
expect(await analyzePage(page)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const route of publicRoutes) {
|
|
33
|
+
test(`${route.name} exposes one main landmark and one level-one heading`, async ({ page }) => {
|
|
34
|
+
await page.goto(route.path);
|
|
35
|
+
|
|
36
|
+
await expect(page.getByRole('banner')).toHaveCount(1);
|
|
37
|
+
await expect(page.getByRole('main')).toHaveCount(1);
|
|
38
|
+
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
|
39
|
+
await expect(page.getByRole('contentinfo')).toHaveCount(1);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
12
42
|
|
|
13
|
-
test('
|
|
43
|
+
test('identifies the current primary navigation item', async ({ page }) => {
|
|
14
44
|
await page.goto('/');
|
|
15
45
|
|
|
16
|
-
await expect(page.getByRole('banner')).toBeVisible();
|
|
17
46
|
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible();
|
|
18
47
|
await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('aria-current', 'page');
|
|
19
|
-
await expect(page.getByRole('main')).toBeVisible();
|
|
20
|
-
await expect(page.getByRole('contentinfo')).toBeVisible();
|
|
21
48
|
});
|
|
22
49
|
|
|
23
50
|
test('moves keyboard users directly to the main content', async ({ page }) => {
|
|
@@ -45,3 +72,22 @@ test('keeps navigation available without horizontal overflow on small screens',
|
|
|
45
72
|
|
|
46
73
|
expect(hasHorizontalOverflow).toBe(false);
|
|
47
74
|
});
|
|
75
|
+
|
|
76
|
+
test('honors reduced-motion preferences', async ({ page }) => {
|
|
77
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
78
|
+
await page.goto('/');
|
|
79
|
+
|
|
80
|
+
const motionStyles = await page.getByRole('link', { name: 'Browse the articles' }).evaluate((element) => {
|
|
81
|
+
const styles = getComputedStyle(element);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
animationDuration: styles.animationDuration,
|
|
85
|
+
scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
|
|
86
|
+
transitionDuration: styles.transitionDuration,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(motionStyles.scrollBehavior).toBe('auto');
|
|
91
|
+
expect(Number.parseFloat(motionStyles.animationDuration)).toBeLessThanOrEqual(0.001);
|
|
92
|
+
expect(Number.parseFloat(motionStyles.transitionDuration)).toBeLessThanOrEqual(0.001);
|
|
93
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
test('uses primary navigation to move between pages and identify the current page', async ({ page }) => {
|
|
4
|
+
await page.goto('/');
|
|
5
|
+
|
|
6
|
+
const navigation = page.getByRole('navigation', { name: 'Primary navigation' });
|
|
7
|
+
await expect(navigation.getByRole('link', { name: 'Home' })).toHaveAttribute('aria-current', 'page');
|
|
8
|
+
|
|
9
|
+
await navigation.getByRole('link', { name: 'Articles' }).click();
|
|
10
|
+
await expect(page).toHaveURL('/articles/');
|
|
11
|
+
await expect(page.getByRole('heading', { level: 1, name: 'Articles' })).toBeVisible();
|
|
12
|
+
await expect(
|
|
13
|
+
page.getByRole('navigation', { name: 'Primary navigation' }).getByRole('link', { name: 'Articles' }),
|
|
14
|
+
).toHaveAttribute('aria-current', 'page');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('opens mobile navigation and follows a configured link', async ({ page }) => {
|
|
18
|
+
await page.setViewportSize({ width: 320, height: 640 });
|
|
19
|
+
await page.goto('/');
|
|
20
|
+
|
|
21
|
+
await page.locator('summary').filter({ hasText: 'Menu' }).click();
|
|
22
|
+
const navigation = page.getByRole('navigation', { name: 'Mobile navigation' });
|
|
23
|
+
await expect(navigation).toBeVisible();
|
|
24
|
+
await navigation.getByRole('link', { name: 'About' }).click();
|
|
25
|
+
|
|
26
|
+
await expect(page).toHaveURL('/about/');
|
|
27
|
+
await expect(
|
|
28
|
+
page.getByRole('heading', { level: 1, name: 'A useful foundation, not a finished identity.' }),
|
|
29
|
+
).toBeVisible();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('moves from the article listing into an article and through article pagination', async ({ page }) => {
|
|
33
|
+
await page.goto('/articles/');
|
|
34
|
+
|
|
35
|
+
await page.getByRole('link', { name: /Designing a calm starting point/u }).click();
|
|
36
|
+
await expect(page).toHaveURL('/articles/designing-a-calm-starting-point/');
|
|
37
|
+
await expect(page.getByRole('heading', { level: 1, name: 'Designing a calm starting point' })).toBeVisible();
|
|
38
|
+
|
|
39
|
+
const articleNavigation = page.getByRole('navigation', { name: 'Article navigation' });
|
|
40
|
+
await articleNavigation.getByRole('link', { name: /Content that travels well/u }).click();
|
|
41
|
+
await expect(page).toHaveURL('/articles/content-that-travels-well/');
|
|
42
|
+
await expect(page.getByRole('heading', { level: 1, name: 'Content that travels well' })).toBeVisible();
|
|
43
|
+
});
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test';
|
|
2
2
|
|
|
3
|
+
import { site } from '../src/config/site';
|
|
4
|
+
|
|
3
5
|
const routes = ['/', '/about/', '/articles/', '/articles/designing-a-calm-starting-point/', '/admin/', '/404/'];
|
|
4
6
|
|
|
5
7
|
test('serves every baseline page with configured canonical and social metadata', async ({ page }) => {
|
|
6
8
|
for (const route of routes) {
|
|
7
9
|
await page.goto(route);
|
|
8
10
|
|
|
9
|
-
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
);
|
|
13
|
-
await expect(page.locator('meta[property="og:site_name"]')).toHaveAttribute('content', 'Forge');
|
|
14
|
-
await expect(page.locator('meta[property="og:url"]')).toHaveAttribute(
|
|
15
|
-
'content',
|
|
16
|
-
new URL(route, 'https://example.com').href,
|
|
17
|
-
);
|
|
11
|
+
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', new URL(route, site.url).href);
|
|
12
|
+
await expect(page.locator('meta[property="og:site_name"]')).toHaveAttribute('content', site.name);
|
|
13
|
+
await expect(page.locator('meta[property="og:url"]')).toHaveAttribute('content', new URL(route, site.url).href);
|
|
18
14
|
await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute('content', 'summary_large_image');
|
|
19
15
|
}
|
|
20
16
|
});
|
|
@@ -24,6 +20,7 @@ test('serves the content manager without allowing search indexing', async ({ pag
|
|
|
24
20
|
|
|
25
21
|
await expect(page.locator('meta[name="robots"][content="noindex, nofollow"]')).toHaveCount(1);
|
|
26
22
|
await expect(page).toHaveTitle(/Content Manager/u);
|
|
23
|
+
await expect(page.locator('body')).not.toBeEmpty();
|
|
27
24
|
});
|
|
28
25
|
|
|
29
26
|
test('renders generic article tags on listings and detail pages', async ({ page }) => {
|
|
@@ -69,19 +66,29 @@ test('resolves every internal page link', async ({ page, request }) => {
|
|
|
69
66
|
test('serves feed, crawler, manifest, and not-found metadata', async ({ request }) => {
|
|
70
67
|
const feed = await request.get('/rss.xml');
|
|
71
68
|
expect(feed.ok()).toBe(true);
|
|
72
|
-
expect(await feed.text()).toContain('
|
|
69
|
+
expect(await feed.text()).toContain(new URL('articles/designing-a-calm-starting-point/', site.url).href);
|
|
73
70
|
expect(await feed.text()).not.toContain('future-draft');
|
|
74
71
|
|
|
75
72
|
const robots = await request.get('/robots.txt');
|
|
76
73
|
expect(robots.ok()).toBe(true);
|
|
77
|
-
expect(await robots.text()).toContain(
|
|
74
|
+
expect(await robots.text()).toContain(`Sitemap: ${new URL('sitemap-index.xml', site.url).href}`);
|
|
78
75
|
|
|
79
76
|
const manifest = await request.get('/site.webmanifest');
|
|
80
77
|
expect(manifest.ok()).toBe(true);
|
|
81
78
|
expect(manifest.headers()['content-type']).toMatch(/application\/manifest\+json/u);
|
|
82
|
-
expect(await manifest.json()).toMatchObject({ name:
|
|
79
|
+
expect(await manifest.json()).toMatchObject({ name: site.name, start_url: '/' });
|
|
83
80
|
|
|
84
81
|
const missing = await request.get('/does-not-exist/');
|
|
85
82
|
expect(missing.status()).toBe(404);
|
|
86
83
|
expect(await missing.text()).toContain('That page is not here.');
|
|
87
84
|
});
|
|
85
|
+
|
|
86
|
+
test('helps visitors recover from a missing page', async ({ page }) => {
|
|
87
|
+
const response = await page.goto('/does-not-exist/');
|
|
88
|
+
|
|
89
|
+
expect(response?.status()).toBe(404);
|
|
90
|
+
await expect(page.getByRole('heading', { level: 1, name: 'That page is not here.' })).toBeVisible();
|
|
91
|
+
await page.getByRole('link', { name: 'Return home' }).click();
|
|
92
|
+
await expect(page).toHaveURL('/');
|
|
93
|
+
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
94
|
+
});
|
|
@@ -3,6 +3,23 @@ import { getViteConfig } from 'astro/config';
|
|
|
3
3
|
|
|
4
4
|
export default getViteConfig({
|
|
5
5
|
test: {
|
|
6
|
-
include: ['src/**/*.test.ts'],
|
|
6
|
+
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
|
7
|
+
coverage: {
|
|
8
|
+
include: [
|
|
9
|
+
'src/config/**/*.ts',
|
|
10
|
+
'src/content.config.ts',
|
|
11
|
+
'src/integrations/**/*.ts',
|
|
12
|
+
'src/lib/**/*.ts',
|
|
13
|
+
'src/themes/site-theme.ts',
|
|
14
|
+
],
|
|
15
|
+
provider: 'v8',
|
|
16
|
+
reporter: ['text', 'html'],
|
|
17
|
+
thresholds: {
|
|
18
|
+
branches: 100,
|
|
19
|
+
functions: 100,
|
|
20
|
+
lines: 100,
|
|
21
|
+
statements: 100,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
7
24
|
},
|
|
8
25
|
});
|