create-krantho 0.0.0-stage → 1.0.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/dist/export.d.ts +2 -0
- package/dist/export.js +113 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +103 -0
- package/dist/prompts.d.ts +14 -0
- package/dist/prompts.js +91 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +21 -0
- package/dist/templates/common.d.ts +2 -0
- package/dist/templates/common.js +98 -0
- package/dist/templates/content.d.ts +2 -0
- package/dist/templates/content.js +99 -0
- package/dist/templates/nextjs.d.ts +2 -0
- package/dist/templates/nextjs.js +539 -0
- package/dist/templates/sveltekit.d.ts +2 -0
- package/dist/templates/sveltekit.js +472 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +5 -0
- package/package.json +32 -3
- package/README.md +0 -3
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { KRANTHO_RANGE } from '../version.js';
|
|
2
|
+
export function generateNextjsFiles(options) {
|
|
3
|
+
const files = new Map();
|
|
4
|
+
files.set('package.json', packageJson(options));
|
|
5
|
+
files.set('tsconfig.json', tsconfig());
|
|
6
|
+
files.set('next.config.ts', nextConfig());
|
|
7
|
+
files.set('krantho.config.ts', kranthoConfig(options));
|
|
8
|
+
files.set('krantho.collections.ts', kranthoCollections(options));
|
|
9
|
+
files.set('lib/krantho.ts', kranthoInit());
|
|
10
|
+
files.set('app/api/[...path]/route.ts', apiRoute());
|
|
11
|
+
files.set('app/studio/[[...slug]]/route.ts', studioRoute());
|
|
12
|
+
files.set('app/layout.tsx', rootLayout(options));
|
|
13
|
+
files.set('app/(site)/layout.tsx', siteLayout(options));
|
|
14
|
+
files.set('app/(site)/[[...slug]]/page.tsx', page(options));
|
|
15
|
+
files.set('app/globals.css', globalsCss(options));
|
|
16
|
+
if (options.ui === 'tailwind') {
|
|
17
|
+
files.set('postcss.config.mjs', postcssConfig());
|
|
18
|
+
}
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
function packageJson(options) {
|
|
22
|
+
// `@krantho/next` is the one-install umbrella — it bundles server-next,
|
|
23
|
+
// ui-react, studio, media-local, and seo so a single dependency provisions the
|
|
24
|
+
// full stack. The packages the generated source imports *by name*
|
|
25
|
+
// (config, server-next, ui-react, schema) are also listed
|
|
26
|
+
// explicitly so the project resolves under a strict, non-hoisted
|
|
27
|
+
// node_modules layout where transitive deps are not directly importable.
|
|
28
|
+
const deps = {
|
|
29
|
+
'@krantho/config': KRANTHO_RANGE,
|
|
30
|
+
'@krantho/next': KRANTHO_RANGE,
|
|
31
|
+
'@krantho/server-next': KRANTHO_RANGE,
|
|
32
|
+
'@krantho/ui-react': KRANTHO_RANGE,
|
|
33
|
+
'@krantho/schema': KRANTHO_RANGE,
|
|
34
|
+
next: '^16.2.1',
|
|
35
|
+
react: '^19.2.4',
|
|
36
|
+
'react-dom': '^19.2.4',
|
|
37
|
+
zod: '^4.4.3',
|
|
38
|
+
};
|
|
39
|
+
if (options.ui === 'tailwind') {
|
|
40
|
+
deps['tailwindcss'] = '^4.2.2';
|
|
41
|
+
deps['@tailwindcss/postcss'] = '^4.2.2';
|
|
42
|
+
}
|
|
43
|
+
if (options.ui === 'mui') {
|
|
44
|
+
deps['@mui/material'] = '^7.3.0';
|
|
45
|
+
deps['@emotion/react'] = '^11.14.0';
|
|
46
|
+
deps['@emotion/styled'] = '^11.14.0';
|
|
47
|
+
}
|
|
48
|
+
const devDeps = {
|
|
49
|
+
'@types/node': '^25.5.0',
|
|
50
|
+
'@types/react': '^19.2.14',
|
|
51
|
+
typescript: '^6.0.2',
|
|
52
|
+
};
|
|
53
|
+
const pkg = {
|
|
54
|
+
name: options.name,
|
|
55
|
+
version: '0.1.0',
|
|
56
|
+
private: true,
|
|
57
|
+
type: 'module',
|
|
58
|
+
scripts: {
|
|
59
|
+
dev: `next dev --port ${options.port}`,
|
|
60
|
+
build: 'next build',
|
|
61
|
+
start: 'next start',
|
|
62
|
+
},
|
|
63
|
+
dependencies: sortObject(deps),
|
|
64
|
+
devDependencies: sortObject(devDeps),
|
|
65
|
+
};
|
|
66
|
+
return JSON.stringify(pkg, null, 2) + '\n';
|
|
67
|
+
}
|
|
68
|
+
function tsconfig() {
|
|
69
|
+
return (JSON.stringify({
|
|
70
|
+
compilerOptions: {
|
|
71
|
+
target: 'ES2022',
|
|
72
|
+
lib: ['dom', 'dom.iterable', 'esnext'],
|
|
73
|
+
allowJs: true,
|
|
74
|
+
skipLibCheck: true,
|
|
75
|
+
strict: true,
|
|
76
|
+
noEmit: true,
|
|
77
|
+
esModuleInterop: true,
|
|
78
|
+
module: 'esnext',
|
|
79
|
+
moduleResolution: 'bundler',
|
|
80
|
+
resolveJsonModule: true,
|
|
81
|
+
isolatedModules: true,
|
|
82
|
+
jsx: 'preserve',
|
|
83
|
+
incremental: true,
|
|
84
|
+
plugins: [{ name: 'next' }],
|
|
85
|
+
paths: { '@/*': ['./*'] },
|
|
86
|
+
},
|
|
87
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
|
|
88
|
+
exclude: ['node_modules'],
|
|
89
|
+
}, null, 2) + '\n');
|
|
90
|
+
}
|
|
91
|
+
function nextConfig() {
|
|
92
|
+
return `import type { NextConfig } from 'next'
|
|
93
|
+
|
|
94
|
+
const nextConfig: NextConfig = {
|
|
95
|
+
serverExternalPackages: [
|
|
96
|
+
'better-sqlite3',
|
|
97
|
+
'@krantho/server',
|
|
98
|
+
'@krantho/server-next',
|
|
99
|
+
],
|
|
100
|
+
// Search engines look for /robots.txt and /sitemap.xml at the site root; the
|
|
101
|
+
// krantho API builds both from the published content and site.baseUrl.
|
|
102
|
+
async rewrites() {
|
|
103
|
+
return [
|
|
104
|
+
{ source: '/robots.txt', destination: '/api/seo/robots.txt' },
|
|
105
|
+
{ source: '/sitemap.xml', destination: '/api/seo/sitemap' },
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default nextConfig
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
function kranthoInit() {
|
|
114
|
+
return `import { notFound } from 'next/navigation'
|
|
115
|
+
import { createContentLoader } from '@krantho/server-next'
|
|
116
|
+
import { getCMSContent, initServerLoader, NetworkError } from '@krantho/ui-react/server'
|
|
117
|
+
import type { CMSContent } from '@krantho/ui-react/server'
|
|
118
|
+
import config from '../krantho.config'
|
|
119
|
+
import { collections } from '../krantho.collections'
|
|
120
|
+
|
|
121
|
+
// Server-rendered pages read the database the API route opens: both resolve this
|
|
122
|
+
// app's configuration and open persistence through the same factory.
|
|
123
|
+
initServerLoader(createContentLoader({ config, collections }))
|
|
124
|
+
|
|
125
|
+
/** A page's published content; a document with no published version answers 404 */
|
|
126
|
+
export async function requirePublished(collection: string, slug: string): Promise<CMSContent> {
|
|
127
|
+
try {
|
|
128
|
+
return await getCMSContent(collection, slug)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof NetworkError && error.status === 404) notFound()
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A global's published fields; none when it is not published */
|
|
136
|
+
export async function globalFields(slug: string): Promise<Record<string, unknown>> {
|
|
137
|
+
try {
|
|
138
|
+
return (await getCMSContent('globals', slug)).fields
|
|
139
|
+
} catch {
|
|
140
|
+
return {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
/** With SEO chosen, a page's own structured data is a group of its metadata, edited in Data mode */
|
|
146
|
+
function kranthoCollections(options) {
|
|
147
|
+
const seoSlot = options.seo ? ', seo: { archetype: structuredDataMetadata }' : '';
|
|
148
|
+
const imports = options.seo
|
|
149
|
+
? `import {
|
|
150
|
+
defineCollection,
|
|
151
|
+
defineDocumentArchetype,
|
|
152
|
+
sharingMetadata,
|
|
153
|
+
structuredDataMetadata,
|
|
154
|
+
} from '@krantho/schema'`
|
|
155
|
+
: "import { defineCollection, defineDocumentArchetype, sharingMetadata } from '@krantho/schema'";
|
|
156
|
+
return `import { join } from 'path'
|
|
157
|
+
${imports}
|
|
158
|
+
import { z } from 'zod'
|
|
159
|
+
|
|
160
|
+
// The collections this site serves — shared by the API route and the page loader,
|
|
161
|
+
// so whichever a fresh deployment serves first imports the same content.
|
|
162
|
+
|
|
163
|
+
const pages = defineCollection({
|
|
164
|
+
name: 'pages',
|
|
165
|
+
source: 'filesystem',
|
|
166
|
+
directory: join(process.cwd(), 'content', 'pages'),
|
|
167
|
+
archetype: defineDocumentArchetype({
|
|
168
|
+
id: 'site/pages', label: 'Page',
|
|
169
|
+
fields: z.object({
|
|
170
|
+
title: z.string(),
|
|
171
|
+
description: z.string(),
|
|
172
|
+
|
|
173
|
+
}),
|
|
174
|
+
metadata: { sharing: { archetype: sharingMetadata }${seoSlot} },
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const globals = defineCollection({
|
|
179
|
+
name: 'globals',
|
|
180
|
+
source: 'filesystem',
|
|
181
|
+
directory: join(process.cwd(), 'content', 'globals'),
|
|
182
|
+
archetype: defineDocumentArchetype({
|
|
183
|
+
id: 'site/globals', label: 'Shared content',
|
|
184
|
+
fields: z.object({
|
|
185
|
+
'logo-name': z.string().optional(),
|
|
186
|
+
'logo-subtitle': z.string().optional(),
|
|
187
|
+
'nav-home': z.string().optional(),
|
|
188
|
+
'nav-about': z.string().optional(),
|
|
189
|
+
'footer-title': z.string().optional(),
|
|
190
|
+
'footer-description': z.string().optional(),
|
|
191
|
+
'footer-copyright-text': z.string().optional(),
|
|
192
|
+
}),
|
|
193
|
+
metadata: { sharing: { archetype: sharingMetadata } },
|
|
194
|
+
}),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
export const collections = { pages, globals }
|
|
198
|
+
`;
|
|
199
|
+
}
|
|
200
|
+
function apiRoute() {
|
|
201
|
+
return `import { createApiRouteHandlers } from '@krantho/server-next'
|
|
202
|
+
import config from '../../../krantho.config'
|
|
203
|
+
import { collections } from '../../../krantho.collections'
|
|
204
|
+
|
|
205
|
+
export const { GET, POST, PUT, DELETE } = createApiRouteHandlers({
|
|
206
|
+
config,
|
|
207
|
+
collections,
|
|
208
|
+
})
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
/** Studio, which the project tells its owner to open at /studio */
|
|
212
|
+
function studioRoute() {
|
|
213
|
+
return `import { createStudioRouteHandler } from '@krantho/server-next'
|
|
214
|
+
import config from '../../../krantho.config'
|
|
215
|
+
|
|
216
|
+
export const { GET } = createStudioRouteHandler({ config })
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
219
|
+
function kranthoConfig(options) {
|
|
220
|
+
const authLines = options.auth
|
|
221
|
+
? `
|
|
222
|
+
// Token mode — the secret comes from KRANTHO_API_TOKEN in the environment.
|
|
223
|
+
auth: { mode: 'token' },`
|
|
224
|
+
: '';
|
|
225
|
+
return `import { defineKranthoConfig } from '@krantho/config'
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Site configuration — data only. Every value here can be overridden from the
|
|
229
|
+
* environment; \`KRANTHO_SITE_BASE_URL\` overrides \`site.baseUrl\`, and so on.
|
|
230
|
+
* Collections live in krantho.collections.ts; plugins are wiring in the route
|
|
231
|
+
* file. The media file store is chosen by \`media.provider\`.
|
|
232
|
+
*/
|
|
233
|
+
export default defineKranthoConfig({
|
|
234
|
+
site: {
|
|
235
|
+
domain: 'localhost',
|
|
236
|
+
name: '${options.name}',
|
|
237
|
+
language: 'en',
|
|
238
|
+
baseUrl: 'http://localhost:${options.port}',
|
|
239
|
+
supportedLocales: ['en'],
|
|
240
|
+
defaultLocale: 'en',
|
|
241
|
+
},
|
|
242
|
+
// Pages stay cached until a content change invalidates them
|
|
243
|
+
cache: { pages: 'next-isr' },
|
|
244
|
+
// Uploads go to public/uploads, which Next.js serves at /uploads
|
|
245
|
+
media: { provider: 'local' },${authLines}
|
|
246
|
+
})
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
function rootLayout(options) {
|
|
250
|
+
return `import './globals.css'
|
|
251
|
+
|
|
252
|
+
export const metadata = {
|
|
253
|
+
title: '${options.name}',
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The root layout reads no content: the build prerenders the 404 page through
|
|
257
|
+
// it, and the build cannot reach the database.
|
|
258
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
259
|
+
return (
|
|
260
|
+
<html lang="en">
|
|
261
|
+
<body>{children}</body>
|
|
262
|
+
</html>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
function siteLayout(options) {
|
|
268
|
+
return `import { showPreviewBanner } from '@krantho/server-next'
|
|
269
|
+
import { globalFields } from '@/lib/krantho'
|
|
270
|
+
|
|
271
|
+
export default async function SiteLayout({ children }: { children: React.ReactNode }) {
|
|
272
|
+
const navFm = await globalFields('navigation')
|
|
273
|
+
const footerFm = await globalFields('footer')
|
|
274
|
+
// An editor previewing drafts ("Preview on site" in Studio)
|
|
275
|
+
const previewing = await showPreviewBanner()
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
{previewing && (
|
|
280
|
+
<p className="preview-banner">
|
|
281
|
+
Previewing drafts — <a href="/api/preview/exit">Exit preview</a>
|
|
282
|
+
</p>
|
|
283
|
+
)}
|
|
284
|
+
<header>
|
|
285
|
+
<nav>
|
|
286
|
+
<a href="/">
|
|
287
|
+
<strong>{String(navFm['logo-name'] ?? '${options.name}')}</strong>
|
|
288
|
+
{Boolean(navFm['logo-subtitle']) && <span> — {String(navFm['logo-subtitle'])}</span>}
|
|
289
|
+
</a>
|
|
290
|
+
<div>
|
|
291
|
+
<a href="/">{String(navFm['nav-home'] ?? 'Home')}</a>
|
|
292
|
+
<a href="/about">{String(navFm['nav-about'] ?? 'About')}</a>
|
|
293
|
+
</div>
|
|
294
|
+
</nav>
|
|
295
|
+
</header>
|
|
296
|
+
<main>{children}</main>
|
|
297
|
+
<footer>
|
|
298
|
+
<p>{String(footerFm['footer-title'] ?? '${options.name}')}</p>
|
|
299
|
+
{Boolean(footerFm['footer-description']) && <p>{String(footerFm['footer-description'])}</p>}
|
|
300
|
+
<p>{String(footerFm['footer-copyright-text'] ?? '')}</p>
|
|
301
|
+
</footer>
|
|
302
|
+
</>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
function page(options) {
|
|
308
|
+
const seoImport = options.seo ? ', SeoJsonLd' : '';
|
|
309
|
+
const seoComponent = options.seo ? `\n <SeoJsonLd collection="pages" slug={slug} />` : '';
|
|
310
|
+
return `import { notFound } from 'next/navigation'
|
|
311
|
+
import { CMSMarkdownServer${seoImport} } from '@krantho/ui-react/server'
|
|
312
|
+
import { requirePublished } from '@/lib/krantho'
|
|
313
|
+
|
|
314
|
+
// Every page of the \`pages\` collection. None is rendered at build — the build
|
|
315
|
+
// never reads the database. Each renders on its first request and stays cached
|
|
316
|
+
// until Studio changes what it shows (\`cache.pages: 'next-isr'\`).
|
|
317
|
+
export const revalidate = false
|
|
318
|
+
|
|
319
|
+
export function generateStaticParams(): { slug: string[] }[] {
|
|
320
|
+
return []
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface PageProps {
|
|
324
|
+
params: Promise<{ slug?: string[] | undefined }>
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** \`/\` is the home page; any other page is one path segment */
|
|
328
|
+
function pageSlug(path: string[] | undefined): string {
|
|
329
|
+
if (!path) return 'home'
|
|
330
|
+
const [name] = path
|
|
331
|
+
if (path.length !== 1 || !name || name === 'home') notFound()
|
|
332
|
+
return name
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default async function Page({ params }: PageProps) {
|
|
336
|
+
const slug = pageSlug((await params).slug)
|
|
337
|
+
const page = await requirePublished('pages', slug)
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<article>${seoComponent}
|
|
341
|
+
<h1>{String(page.fields['title'] ?? slug)}</h1>
|
|
342
|
+
<CMSMarkdownServer collection="pages" slug={slug} />
|
|
343
|
+
</article>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
`;
|
|
347
|
+
}
|
|
348
|
+
function globalsCss(options) {
|
|
349
|
+
if (options.ui === 'tailwind') {
|
|
350
|
+
return `@import 'tailwindcss';
|
|
351
|
+
|
|
352
|
+
:root {
|
|
353
|
+
--font-sans: system-ui, -apple-system, sans-serif;
|
|
354
|
+
--color-primary: #2563eb;
|
|
355
|
+
--color-text: #1a1a2e;
|
|
356
|
+
--color-muted: #6b7280;
|
|
357
|
+
--color-bg: #ffffff;
|
|
358
|
+
--color-surface: #f9fafb;
|
|
359
|
+
--color-border: #e5e7eb;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
body {
|
|
363
|
+
font-family: var(--font-sans);
|
|
364
|
+
color: var(--color-text);
|
|
365
|
+
background: var(--color-bg);
|
|
366
|
+
margin: 0;
|
|
367
|
+
line-height: 1.6;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
header {
|
|
371
|
+
@apply border-b border-gray-200 px-6 py-4;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
header nav {
|
|
375
|
+
@apply max-w-5xl mx-auto flex items-center justify-between;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
header nav a {
|
|
379
|
+
@apply text-inherit no-underline;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
header nav div {
|
|
383
|
+
@apply flex gap-4;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
header nav div a:hover {
|
|
387
|
+
@apply text-blue-600;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
main {
|
|
391
|
+
@apply max-w-3xl mx-auto px-6 py-12;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
article h1 {
|
|
395
|
+
@apply text-3xl font-bold mb-4;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
article h2 {
|
|
399
|
+
@apply text-xl font-semibold mt-8 mb-3;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
article .intro {
|
|
403
|
+
@apply text-lg text-gray-600 mb-8;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
article ul {
|
|
407
|
+
@apply list-disc pl-6 mb-4;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
article li {
|
|
411
|
+
@apply mb-1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
article a {
|
|
415
|
+
@apply text-blue-600 underline;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
footer {
|
|
419
|
+
@apply border-t border-gray-200 px-6 py-8 text-center text-sm text-gray-500;
|
|
420
|
+
}
|
|
421
|
+
`;
|
|
422
|
+
}
|
|
423
|
+
// Plain CSS or MUI — minimal base styles
|
|
424
|
+
return `:root {
|
|
425
|
+
--font-sans: system-ui, -apple-system, sans-serif;
|
|
426
|
+
--color-primary: #2563eb;
|
|
427
|
+
--color-text: #1a1a2e;
|
|
428
|
+
--color-muted: #6b7280;
|
|
429
|
+
--color-bg: #ffffff;
|
|
430
|
+
--color-surface: #f9fafb;
|
|
431
|
+
--color-border: #e5e7eb;
|
|
432
|
+
--max-width: 48rem;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
436
|
+
|
|
437
|
+
body {
|
|
438
|
+
font-family: var(--font-sans);
|
|
439
|
+
color: var(--color-text);
|
|
440
|
+
background: var(--color-bg);
|
|
441
|
+
line-height: 1.6;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
header {
|
|
445
|
+
border-bottom: 1px solid var(--color-border);
|
|
446
|
+
padding: 1rem 1.5rem;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
header nav {
|
|
450
|
+
max-width: var(--max-width);
|
|
451
|
+
margin: 0 auto;
|
|
452
|
+
display: flex;
|
|
453
|
+
align-items: center;
|
|
454
|
+
justify-content: space-between;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
header nav a {
|
|
458
|
+
color: inherit;
|
|
459
|
+
text-decoration: none;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
header nav div {
|
|
463
|
+
display: flex;
|
|
464
|
+
gap: 1rem;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
header nav div a:hover {
|
|
468
|
+
color: var(--color-primary);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
main {
|
|
472
|
+
max-width: var(--max-width);
|
|
473
|
+
margin: 0 auto;
|
|
474
|
+
padding: 3rem 1.5rem;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
article h1 {
|
|
478
|
+
font-size: 1.875rem;
|
|
479
|
+
font-weight: 700;
|
|
480
|
+
margin-bottom: 1rem;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
article h2 {
|
|
484
|
+
font-size: 1.25rem;
|
|
485
|
+
font-weight: 600;
|
|
486
|
+
margin-top: 2rem;
|
|
487
|
+
margin-bottom: 0.75rem;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
article .intro {
|
|
491
|
+
font-size: 1.125rem;
|
|
492
|
+
color: var(--color-muted);
|
|
493
|
+
margin-bottom: 2rem;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
article ul {
|
|
497
|
+
list-style: disc;
|
|
498
|
+
padding-left: 1.5rem;
|
|
499
|
+
margin-bottom: 1rem;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
article li { margin-bottom: 0.25rem; }
|
|
503
|
+
|
|
504
|
+
article a {
|
|
505
|
+
color: var(--color-primary);
|
|
506
|
+
text-decoration: underline;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
article strong { font-weight: 600; }
|
|
510
|
+
|
|
511
|
+
footer {
|
|
512
|
+
border-top: 1px solid var(--color-border);
|
|
513
|
+
padding: 2rem 1.5rem;
|
|
514
|
+
text-align: center;
|
|
515
|
+
font-size: 0.875rem;
|
|
516
|
+
color: var(--color-muted);
|
|
517
|
+
}
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
function postcssConfig() {
|
|
521
|
+
return `export default {
|
|
522
|
+
plugins: {
|
|
523
|
+
'@tailwindcss/postcss': {},
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
`;
|
|
527
|
+
}
|
|
528
|
+
function sortObject(obj) {
|
|
529
|
+
const sorted = {};
|
|
530
|
+
for (const key of Object.keys(obj).sort()) {
|
|
531
|
+
const value = obj[key];
|
|
532
|
+
// `key` came from `Object.keys(obj)` so it is guaranteed to be present —
|
|
533
|
+
// but noUncheckedIndexedAccess treats the lookup as `string | undefined`.
|
|
534
|
+
// Explicit guard instead of a non-null assertion keeps the check visible.
|
|
535
|
+
if (value !== undefined)
|
|
536
|
+
sorted[key] = value;
|
|
537
|
+
}
|
|
538
|
+
return sorted;
|
|
539
|
+
}
|