create-clientkit 0.1.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 +228 -0
- package/THIRD-PARTY.md +61 -0
- package/bin/cli.js +52 -0
- package/dist/cli.js +2556 -0
- package/package.json +84 -0
- package/templates/astro-tailwind/base/README.md +131 -0
- package/templates/astro-tailwind/base/_gitattributes +5 -0
- package/templates/astro-tailwind/base/_gitignore +23 -0
- package/templates/astro-tailwind/base/_package.json +28 -0
- package/templates/astro-tailwind/base/astro.config.mjs +32 -0
- package/templates/astro-tailwind/base/public/favicon.svg +5 -0
- package/templates/astro-tailwind/base/src/components/Brand.astro +89 -0
- package/templates/astro-tailwind/base/src/components/Footer.astro +88 -0
- package/templates/astro-tailwind/base/src/components/Header.astro +104 -0
- package/templates/astro-tailwind/base/src/components/LaunchNotice.astro +156 -0
- package/templates/astro-tailwind/base/src/components/Seo.astro +73 -0
- package/templates/astro-tailwind/base/src/components/SocialLinks.astro +75 -0
- package/templates/astro-tailwind/base/src/components/StructuredData.astro +39 -0
- package/templates/astro-tailwind/base/src/config/site.config.ts +141 -0
- package/templates/astro-tailwind/base/src/layouts/BaseLayout.astro +121 -0
- package/templates/astro-tailwind/base/src/lib/seo.ts +87 -0
- package/templates/astro-tailwind/base/src/pages/404.astro +147 -0
- package/templates/astro-tailwind/base/src/pages/index.astro +31 -0
- package/templates/astro-tailwind/base/src/pages/robots.txt.ts +32 -0
- package/templates/astro-tailwind/base/src/styles/global.css +330 -0
- package/templates/astro-tailwind/base/tsconfig.json +11 -0
- package/templates/astro-tailwind/modes/coming-soon/_package.json +3 -0
- package/templates/astro-tailwind/modes/coming-soon/src/pages/index.astro +149 -0
- package/templates/astro-tailwind/modes/full/_package.json +3 -0
- package/templates/astro-tailwind/modes/full/src/pages/index.astro +237 -0
- package/templates/astro-tailwind/template.json +23 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { LAUNCH, SITE } from '../config/site.config.ts';
|
|
3
|
+
|
|
4
|
+
// Render nothing at all unless a launch is configured *and* the date parses.
|
|
5
|
+
// A broken date must never produce an empty box or fail the build.
|
|
6
|
+
const parsed = LAUNCH.enabled && LAUNCH.date !== '' ? new Date(LAUNCH.date) : null;
|
|
7
|
+
const launchDate = parsed !== null && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
8
|
+
|
|
9
|
+
const formatted =
|
|
10
|
+
launchDate === null
|
|
11
|
+
? ''
|
|
12
|
+
: new Intl.DateTimeFormat(SITE.locale || 'en', {
|
|
13
|
+
day: 'numeric',
|
|
14
|
+
month: 'long',
|
|
15
|
+
year: 'numeric',
|
|
16
|
+
}).format(launchDate);
|
|
17
|
+
|
|
18
|
+
const iso = launchDate === null ? '' : launchDate.toISOString();
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
launchDate !== null && (
|
|
23
|
+
<div class="launch">
|
|
24
|
+
<p class="line">
|
|
25
|
+
Launching <time datetime={iso}>{formatted}</time>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
{/*
|
|
29
|
+
The date above is the real content and is always present. The countdown
|
|
30
|
+
is a progressive enhancement: it stays hidden without JavaScript, and it
|
|
31
|
+
is hidden from assistive technology because it repeats what the date
|
|
32
|
+
already says.
|
|
33
|
+
*/}
|
|
34
|
+
<div class="countdown" data-launch={iso} hidden aria-hidden="true">
|
|
35
|
+
<span class="unit">
|
|
36
|
+
<>
|
|
37
|
+
<b data-unit="days">0</b>
|
|
38
|
+
<small>days</small>
|
|
39
|
+
</>
|
|
40
|
+
</span>
|
|
41
|
+
<span class="unit">
|
|
42
|
+
<>
|
|
43
|
+
<b data-unit="hours">0</b>
|
|
44
|
+
<small>hrs</small>
|
|
45
|
+
</>
|
|
46
|
+
</span>
|
|
47
|
+
<span class="unit">
|
|
48
|
+
<>
|
|
49
|
+
<b data-unit="minutes">0</b>
|
|
50
|
+
<small>min</small>
|
|
51
|
+
</>
|
|
52
|
+
</span>
|
|
53
|
+
<span class="unit">
|
|
54
|
+
<>
|
|
55
|
+
<b data-unit="seconds">0</b>
|
|
56
|
+
<small>sec</small>
|
|
57
|
+
</>
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{LAUNCH.note !== '' && <p class="note">{LAUNCH.note}</p>}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
<script>
|
|
67
|
+
// ~20 lines, no framework, no hydration. Runs once on the page that uses it.
|
|
68
|
+
const root = document.querySelector<HTMLElement>('.countdown[data-launch]');
|
|
69
|
+
const target = root ? Date.parse(root.dataset.launch ?? '') : Number.NaN;
|
|
70
|
+
|
|
71
|
+
if (root && !Number.isNaN(target)) {
|
|
72
|
+
const cells = new Map<string, HTMLElement>();
|
|
73
|
+
for (const cell of root.querySelectorAll<HTMLElement>('[data-unit]')) {
|
|
74
|
+
cells.set(cell.dataset.unit ?? '', cell);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tick = (): void => {
|
|
78
|
+
const remaining = target - Date.now();
|
|
79
|
+
if (remaining <= 0) {
|
|
80
|
+
root.hidden = true;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const seconds = Math.floor(remaining / 1000);
|
|
84
|
+
const parts: Record<string, number> = {
|
|
85
|
+
days: Math.floor(seconds / 86400),
|
|
86
|
+
hours: Math.floor(seconds / 3600) % 24,
|
|
87
|
+
minutes: Math.floor(seconds / 60) % 60,
|
|
88
|
+
seconds: seconds % 60,
|
|
89
|
+
};
|
|
90
|
+
for (const [unit, cell] of cells) {
|
|
91
|
+
cell.textContent = String(parts[unit] ?? 0).padStart(2, '0');
|
|
92
|
+
}
|
|
93
|
+
root.hidden = false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
tick();
|
|
97
|
+
window.setInterval(tick, 1000);
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<style>
|
|
102
|
+
.launch {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 1rem;
|
|
106
|
+
align-items: flex-start;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.line {
|
|
110
|
+
margin: 0;
|
|
111
|
+
color: var(--color-foreground);
|
|
112
|
+
font-size: var(--text-body);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
time {
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.countdown {
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-wrap: wrap;
|
|
122
|
+
gap: 0.5rem;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.unit {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 0.125rem;
|
|
130
|
+
min-width: 3.75rem;
|
|
131
|
+
padding: 0.625rem 0.75rem;
|
|
132
|
+
border: 1px solid var(--color-border);
|
|
133
|
+
border-radius: var(--radius-md);
|
|
134
|
+
background-color: var(--color-surface);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.unit b {
|
|
138
|
+
font-size: 1.125rem;
|
|
139
|
+
font-weight: 600;
|
|
140
|
+
font-variant-numeric: tabular-nums;
|
|
141
|
+
letter-spacing: -0.01em;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.unit small {
|
|
145
|
+
color: var(--color-muted);
|
|
146
|
+
font-size: 0.6875rem;
|
|
147
|
+
letter-spacing: 0.08em;
|
|
148
|
+
text-transform: uppercase;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.note {
|
|
152
|
+
margin: 0;
|
|
153
|
+
color: var(--color-muted);
|
|
154
|
+
font-size: var(--text-small);
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEO, SITE } from '../config/site.config.ts';
|
|
3
|
+
import { absoluteUrl, assetUrl, siteOrigin } from '../lib/seo.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every <head> tag that describes the page lives here, so there is exactly one
|
|
7
|
+
* place that decides titles, canonical URLs and social metadata - and exactly
|
|
8
|
+
* one of each tag in the output.
|
|
9
|
+
*
|
|
10
|
+
* Everything defaults from src/config/site.config.ts. A page only passes what
|
|
11
|
+
* it genuinely needs to override.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Page name. Omit on the home page so the title is just the site name. */
|
|
15
|
+
title?: string;
|
|
16
|
+
/** Overrides SITE.description for this page. */
|
|
17
|
+
description?: string;
|
|
18
|
+
/** Force this page out of the index regardless of the site setting. */
|
|
19
|
+
noindex?: boolean;
|
|
20
|
+
/** Open Graph type. 'website' suits every page this template ships. */
|
|
21
|
+
type?: 'website' | 'article';
|
|
22
|
+
/** Overrides SEO.image for this page. */
|
|
23
|
+
image?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { title, description, noindex = false, type = 'website', image } = Astro.props;
|
|
27
|
+
|
|
28
|
+
// '' means no production URL is configured. Every absolute tag below is then
|
|
29
|
+
// skipped rather than pointed at a domain nobody has bought yet.
|
|
30
|
+
const origin = siteOrigin(SITE.url);
|
|
31
|
+
|
|
32
|
+
const pageTitle = title ? `${title} - ${SITE.name}` : SITE.name;
|
|
33
|
+
|
|
34
|
+
// page description -> site description -> no tag at all. Nothing invented.
|
|
35
|
+
const metaDescription = (description ?? SITE.description).trim();
|
|
36
|
+
|
|
37
|
+
// A page may opt out on its own (the 404 does); the site setting covers the rest.
|
|
38
|
+
const blocked = noindex || SEO.noindex;
|
|
39
|
+
const robots = blocked ? 'noindex, nofollow' : 'index, follow';
|
|
40
|
+
|
|
41
|
+
// A canonical tag says "this is the definitive URL for this content" while
|
|
42
|
+
// noindex says "do not index it". Emitting both is contradictory, so a blocked
|
|
43
|
+
// page gets no canonical at all.
|
|
44
|
+
const canonical = origin === '' || blocked ? '' : absoluteUrl(origin, Astro.url.pathname);
|
|
45
|
+
|
|
46
|
+
const socialImage = assetUrl(origin, image ?? SEO.image);
|
|
47
|
+
|
|
48
|
+
// og:locale wants language_TERRITORY. A bare 'en' is not valid, so it is
|
|
49
|
+
// omitted rather than emitted in a form crawlers will discard.
|
|
50
|
+
const ogLocale = /^[a-z]{2,3}-[A-Za-z0-9]{2,8}$/.test(SITE.locale)
|
|
51
|
+
? SITE.locale.replace('-', '_')
|
|
52
|
+
: '';
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<title>{pageTitle}</title>
|
|
56
|
+
{metaDescription !== '' && <meta name="description" content={metaDescription} />}
|
|
57
|
+
<meta name="robots" content={robots} />
|
|
58
|
+
{canonical !== '' && <link rel="canonical" href={canonical} />}
|
|
59
|
+
|
|
60
|
+
{/* Open Graph. Only tags whose value actually exists are emitted. */}
|
|
61
|
+
<meta property="og:type" content={type} />
|
|
62
|
+
<meta property="og:title" content={pageTitle} />
|
|
63
|
+
<meta property="og:site_name" content={SITE.name} />
|
|
64
|
+
{metaDescription !== '' && <meta property="og:description" content={metaDescription} />}
|
|
65
|
+
{canonical !== '' && <meta property="og:url" content={canonical} />}
|
|
66
|
+
{ogLocale !== '' && <meta property="og:locale" content={ogLocale} />}
|
|
67
|
+
{socialImage !== '' && <meta property="og:image" content={socialImage} />}
|
|
68
|
+
|
|
69
|
+
{/* Twitter/X. No handle is required and none is invented. */}
|
|
70
|
+
<meta name="twitter:card" content={socialImage !== '' ? SEO.twitterCard : 'summary'} />
|
|
71
|
+
<meta name="twitter:title" content={pageTitle} />
|
|
72
|
+
{metaDescription !== '' && <meta name="twitter:description" content={metaDescription} />}
|
|
73
|
+
{socialImage !== '' && <meta name="twitter:image" content={socialImage} />}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SOCIAL } from '../config/site.config.ts';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Accessible name for the list, since a page may show it more than once. */
|
|
6
|
+
label?: string;
|
|
7
|
+
align?: 'start' | 'center';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { label = 'Social links', align = 'start' } = Astro.props;
|
|
11
|
+
|
|
12
|
+
// Nothing configured means nothing rendered - never an empty container.
|
|
13
|
+
const links = SOCIAL.filter((link) => link.label !== '' && link.href !== '');
|
|
14
|
+
const isExternal = (href: string) => /^https?:\/\//i.test(href);
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
links.length > 0 && (
|
|
19
|
+
<ul class:list={['social', align]} aria-label={label}>
|
|
20
|
+
{links.map((link) => (
|
|
21
|
+
<li>
|
|
22
|
+
<a
|
|
23
|
+
href={link.href}
|
|
24
|
+
rel={isExternal(link.href) ? 'me noopener noreferrer' : undefined}
|
|
25
|
+
target={isExternal(link.href) ? '_blank' : undefined}
|
|
26
|
+
>
|
|
27
|
+
{link.label}
|
|
28
|
+
{isExternal(link.href) && <span class="sr-only"> (opens in a new tab)</span>}
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
))}
|
|
32
|
+
</ul>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
.social {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-wrap: wrap;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: 0.25rem 1.25rem;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
list-style: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.center {
|
|
48
|
+
justify-content: center;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
a {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
min-height: 2.25rem;
|
|
55
|
+
color: var(--color-muted);
|
|
56
|
+
font-size: var(--text-small);
|
|
57
|
+
text-decoration: none;
|
|
58
|
+
transition: color 160ms ease;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
a:hover {
|
|
62
|
+
color: var(--color-foreground);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.sr-only {
|
|
66
|
+
position: absolute;
|
|
67
|
+
width: 1px;
|
|
68
|
+
height: 1px;
|
|
69
|
+
padding: 0;
|
|
70
|
+
margin: -1px;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
clip-path: inset(50%);
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { CONTACT, SITE, SOCIAL } from '../config/site.config.ts';
|
|
3
|
+
import { jsonLdScript, siteOrigin } from '../lib/seo.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* schema.org Organization data, built only from values the developer has
|
|
7
|
+
* actually configured.
|
|
8
|
+
*
|
|
9
|
+
* Search engines consume structured data automatically, so a fabricated field
|
|
10
|
+
* here is worse than a missing one: every property below is omitted unless it
|
|
11
|
+
* has a real value. No logo, address, rating, review or founding date is
|
|
12
|
+
* invented, and none is emitted at all until the config carries one.
|
|
13
|
+
*/
|
|
14
|
+
const origin = siteOrigin(SITE.url);
|
|
15
|
+
|
|
16
|
+
const sameAs = SOCIAL.filter((link) => link.href.trim() !== '').map((link) => link.href.trim());
|
|
17
|
+
|
|
18
|
+
const organization: Record<string, unknown> = {
|
|
19
|
+
'@context': 'https://schema.org',
|
|
20
|
+
'@type': 'Organization',
|
|
21
|
+
name: SITE.name,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (origin !== '') organization['url'] = origin;
|
|
25
|
+
if (SITE.description.trim() !== '') organization['description'] = SITE.description.trim();
|
|
26
|
+
if (CONTACT.email.trim() !== '') organization['email'] = CONTACT.email.trim();
|
|
27
|
+
if (CONTACT.phone.trim() !== '') organization['telephone'] = CONTACT.phone.trim();
|
|
28
|
+
if (sameAs.length > 0) organization['sameAs'] = sameAs;
|
|
29
|
+
|
|
30
|
+
// `location` is free text, so it maps to a plain Place name rather than a
|
|
31
|
+
// PostalAddress - claiming a structured address we do not have would be wrong.
|
|
32
|
+
if (CONTACT.location.trim() !== '') {
|
|
33
|
+
organization['location'] = { '@type': 'Place', name: CONTACT.location.trim() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const payload = jsonLdScript(organization);
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<script type="application/ld+json" is:inline set:html={payload} />
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for this site.
|
|
3
|
+
*
|
|
4
|
+
* Every component reads from here, so updating a client's details is a
|
|
5
|
+
* one-file change. Nothing below is required to be filled in: an empty string
|
|
6
|
+
* or an empty array means "not set", and the UI omits that piece entirely
|
|
7
|
+
* rather than rendering a placeholder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Appearance = 'light' | 'dark' | 'system';
|
|
11
|
+
export type SiteMode = 'coming-soon' | 'full';
|
|
12
|
+
|
|
13
|
+
export interface NavItem {
|
|
14
|
+
label: string;
|
|
15
|
+
/** Internal path ('/about') or in-page anchor ('#services'). */
|
|
16
|
+
href: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SocialLink {
|
|
20
|
+
/** Shown as the link text, so name the platform: 'Instagram', 'LinkedIn'. */
|
|
21
|
+
label: string;
|
|
22
|
+
href: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SiteIdentity {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
/**
|
|
29
|
+
* Production URL, as an origin: 'https://example.com'.
|
|
30
|
+
*
|
|
31
|
+
* Empty until the domain is decided - nothing is invented, and every
|
|
32
|
+
* absolute tag (canonical, og:url, sitemap) is simply omitted until it is
|
|
33
|
+
* set. Only the origin is used; to deploy under a subpath such as
|
|
34
|
+
* example.com/client, also set `base` in astro.config.mjs.
|
|
35
|
+
*/
|
|
36
|
+
url: string;
|
|
37
|
+
/** BCP-47 language tag, used for <html lang> and date formatting. */
|
|
38
|
+
locale: string;
|
|
39
|
+
author: string;
|
|
40
|
+
/** How this project was scaffolded. Informational; edit the pages, not this. */
|
|
41
|
+
mode: SiteMode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ContactDetails {
|
|
45
|
+
email: string;
|
|
46
|
+
phone: string;
|
|
47
|
+
/** Free text, e.g. 'Manchester, UK'. */
|
|
48
|
+
location: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LaunchSettings {
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
/** ISO date or date-time, e.g. '2026-11-01' or '2026-11-01T09:00:00Z'. */
|
|
54
|
+
date: string;
|
|
55
|
+
/** Optional line shown beneath the date. */
|
|
56
|
+
note: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ThemeSettings {
|
|
60
|
+
/** 'system' follows the visitor's OS preference. No JavaScript either way. */
|
|
61
|
+
appearance: Appearance;
|
|
62
|
+
/** Brand colour as a hex value, e.g. '#1d4ed8'. Empty keeps the neutral ink. */
|
|
63
|
+
accent: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type TwitterCard = 'summary' | 'summary_large_image';
|
|
67
|
+
|
|
68
|
+
export interface SeoSettings {
|
|
69
|
+
/**
|
|
70
|
+
* Social preview image, 1200x630 works everywhere. Either a path in
|
|
71
|
+
* `public/` ('/og.png') or an absolute URL.
|
|
72
|
+
*
|
|
73
|
+
* Empty by default and empty is safe: no `og:image` tag is emitted at all,
|
|
74
|
+
* which is better than pointing social platforms at a file that isn't there.
|
|
75
|
+
* Drop a real image in `public/` and name it here when you have one.
|
|
76
|
+
*/
|
|
77
|
+
image: string;
|
|
78
|
+
|
|
79
|
+
/** 'summary_large_image' shows a wide preview; 'summary' shows a thumbnail. */
|
|
80
|
+
twitterCard: TwitterCard;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ask search engines to stay away. Also suppresses the sitemap and its
|
|
84
|
+
* reference in robots.txt, so the site never advertises pages it has asked
|
|
85
|
+
* not to have indexed.
|
|
86
|
+
*
|
|
87
|
+
* Left `false` on purpose, including for coming-soon sites: a placeholder
|
|
88
|
+
* page that gets indexed is replaced at the next crawl, whereas a `noindex`
|
|
89
|
+
* left switched on after launch keeps the real site invisible for weeks.
|
|
90
|
+
* If you would rather the holding page stayed out of search, set this to
|
|
91
|
+
* true now and remember to turn it off when the real site goes live.
|
|
92
|
+
*/
|
|
93
|
+
noindex: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Identity. These values are filled in from your answers at scaffold time. */
|
|
97
|
+
export const SITE: SiteIdentity = {
|
|
98
|
+
name: '{{siteName}}',
|
|
99
|
+
description: '{{description}}',
|
|
100
|
+
url: '{{siteUrl}}',
|
|
101
|
+
locale: '{{locale}}',
|
|
102
|
+
author: '{{author}}',
|
|
103
|
+
mode: '{{mode}}',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Site navigation. Empty by default: the header renders no menu at all rather
|
|
108
|
+
* than inventing links. Add items once real pages or sections exist.
|
|
109
|
+
*/
|
|
110
|
+
export const NAV: NavItem[] = [];
|
|
111
|
+
|
|
112
|
+
/** Social profiles. Add only accounts that actually exist. */
|
|
113
|
+
export const SOCIAL: SocialLink[] = [];
|
|
114
|
+
|
|
115
|
+
/** Contact details. Each field is optional and hidden when empty. */
|
|
116
|
+
export const CONTACT: ContactDetails = {
|
|
117
|
+
email: '',
|
|
118
|
+
phone: '',
|
|
119
|
+
location: '',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Launch date. While `enabled` is false, or `date` is empty or unparseable,
|
|
124
|
+
* no launch information and no countdown is rendered.
|
|
125
|
+
*/
|
|
126
|
+
export const LAUNCH: LaunchSettings = {
|
|
127
|
+
enabled: false,
|
|
128
|
+
date: '',
|
|
129
|
+
note: '',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const THEME: ThemeSettings = {
|
|
133
|
+
appearance: 'system',
|
|
134
|
+
accent: '',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const SEO: SeoSettings = {
|
|
138
|
+
image: '',
|
|
139
|
+
twitterCard: 'summary_large_image',
|
|
140
|
+
noindex: false,
|
|
141
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Footer from '../components/Footer.astro';
|
|
3
|
+
import Header from '../components/Header.astro';
|
|
4
|
+
import Seo from '../components/Seo.astro';
|
|
5
|
+
import StructuredData from '../components/StructuredData.astro';
|
|
6
|
+
import { NAV, SITE, THEME, type NavItem } from '../config/site.config.ts';
|
|
7
|
+
import '../styles/global.css';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Page title. The site name is appended automatically. */
|
|
11
|
+
title?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Keep this page out of search results regardless of the site setting. */
|
|
14
|
+
noindex?: boolean;
|
|
15
|
+
/** Emit Organization structured data. On for content pages, off for the 404. */
|
|
16
|
+
structuredData?: boolean;
|
|
17
|
+
/** Overrides the navigation from site.config.ts for this page. */
|
|
18
|
+
nav?: NavItem[];
|
|
19
|
+
/** Hide the header entirely - used by pages that carry their own branding. */
|
|
20
|
+
showHeader?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Column width shared by the header, content and footer.
|
|
23
|
+
* 'narrow' suits a single focused page; 'wide' suits content pages.
|
|
24
|
+
*/
|
|
25
|
+
measure?: 'narrow' | 'wide';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
noindex = false,
|
|
32
|
+
structuredData = true,
|
|
33
|
+
nav = NAV,
|
|
34
|
+
showHeader = true,
|
|
35
|
+
measure = 'wide',
|
|
36
|
+
} = Astro.props;
|
|
37
|
+
|
|
38
|
+
// 'system' means no attribute at all, so the CSS media query decides.
|
|
39
|
+
const appearance = THEME.appearance === 'system' ? undefined : THEME.appearance;
|
|
40
|
+
|
|
41
|
+
// theme.accent is developer-authored, but it is interpolated into a stylesheet,
|
|
42
|
+
// so only a well-formed hex value is ever emitted.
|
|
43
|
+
const accent = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(THEME.accent) ? THEME.accent : '';
|
|
44
|
+
|
|
45
|
+
// Pick readable text for the accent using its perceived luminance, so a brand
|
|
46
|
+
// colour can be dropped in without also hand-picking a foreground.
|
|
47
|
+
function accentForeground(hex: string): string {
|
|
48
|
+
const full = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
|
|
49
|
+
const value = Number.parseInt(full.slice(1), 16);
|
|
50
|
+
const r = (value >> 16) & 255;
|
|
51
|
+
const g = (value >> 8) & 255;
|
|
52
|
+
const b = value & 255;
|
|
53
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
54
|
+
return luminance > 0.6 ? '#0f1216' : '#ffffff';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const accentCss = accent
|
|
58
|
+
? `:root{--ck-accent:${accent};--ck-accent-foreground:${accentForeground(accent)}}`
|
|
59
|
+
: '';
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<!doctype html>
|
|
63
|
+
<html lang={SITE.locale} data-appearance={appearance}>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
67
|
+
<meta name="generator" content={Astro.generator} />
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
/* Every descriptive tag lives in one component, so there is exactly one
|
|
71
|
+
title, description, canonical and social tag per page. */
|
|
72
|
+
}
|
|
73
|
+
<Seo title={title} description={description} noindex={noindex} />
|
|
74
|
+
|
|
75
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
76
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
|
|
77
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b0d10" />
|
|
78
|
+
|
|
79
|
+
{structuredData && <StructuredData />}
|
|
80
|
+
{accentCss && <style is:global set:html={accentCss} />}
|
|
81
|
+
</head>
|
|
82
|
+
|
|
83
|
+
<body>
|
|
84
|
+
<a href="#main" class="skip-link">Skip to content</a>
|
|
85
|
+
|
|
86
|
+
<div class="page-shell" data-measure={measure}>
|
|
87
|
+
{showHeader && <Header items={nav} />}
|
|
88
|
+
|
|
89
|
+
<main id="main" class="flex-1">
|
|
90
|
+
<slot />
|
|
91
|
+
</main>
|
|
92
|
+
|
|
93
|
+
<Footer />
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
.page-shell[data-measure='narrow'] {
|
|
98
|
+
--ck-measure: 48rem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.skip-link {
|
|
102
|
+
position: absolute;
|
|
103
|
+
left: -9999px;
|
|
104
|
+
top: 0;
|
|
105
|
+
z-index: 50;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.skip-link:focus-visible {
|
|
109
|
+
left: 1rem;
|
|
110
|
+
top: 1rem;
|
|
111
|
+
padding: 0.625rem 1rem;
|
|
112
|
+
border-radius: var(--radius-md);
|
|
113
|
+
background-color: var(--color-accent);
|
|
114
|
+
color: var(--color-accent-foreground);
|
|
115
|
+
font-size: var(--text-small);
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
text-decoration: none;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
120
|
+
</body>
|
|
121
|
+
</html>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small URL helpers for the site's metadata.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a general-purpose URL library: it solves exactly what
|
|
5
|
+
* canonical tags, Open Graph and the sitemap need, and nothing more.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the site's origin with any trailing slash removed, or '' when no
|
|
10
|
+
* usable production URL is configured.
|
|
11
|
+
*
|
|
12
|
+
* An empty result is the signal used throughout the site to omit absolute
|
|
13
|
+
* metadata entirely rather than invent a domain.
|
|
14
|
+
*/
|
|
15
|
+
export function siteOrigin(url: string): string {
|
|
16
|
+
const trimmed = url.trim();
|
|
17
|
+
if (trimmed === '') return '';
|
|
18
|
+
|
|
19
|
+
let parsed: URL;
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(trimmed);
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
|
26
|
+
|
|
27
|
+
// Origin only, so there is never a trailing slash to double up on. Astro
|
|
28
|
+
// resolves absolute routes against the origin, so keeping a path here would
|
|
29
|
+
// make the canonical tag and the sitemap disagree. Sites deployed under a
|
|
30
|
+
// subpath need Astro's `base` option too - see the note on SITE.url in
|
|
31
|
+
// src/config/site.config.ts.
|
|
32
|
+
return parsed.origin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Joins a site-relative path onto the configured origin.
|
|
37
|
+
* Returns '' when there is no origin, so callers can omit the tag entirely.
|
|
38
|
+
*/
|
|
39
|
+
export function absoluteUrl(origin: string, path: string): string {
|
|
40
|
+
if (origin === '') return '';
|
|
41
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
42
|
+
// Collapse any accidental double slash in the path portion.
|
|
43
|
+
return `${origin}${suffix}`.replace(/([^:]\/)\/+/g, '$1');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves a configured asset reference for use in metadata.
|
|
48
|
+
* Absolute URLs pass through; a site-relative path needs an origin, because
|
|
49
|
+
* social crawlers reject relative image URLs.
|
|
50
|
+
*/
|
|
51
|
+
export function assetUrl(origin: string, value: string): string {
|
|
52
|
+
const trimmed = value.trim();
|
|
53
|
+
if (trimmed === '') return '';
|
|
54
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
55
|
+
return absoluteUrl(origin, trimmed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The three characters that can end a script element early or open a new tag,
|
|
60
|
+
* mapped to their JSON unicode escapes.
|
|
61
|
+
*
|
|
62
|
+
* Each value must be a six-character sequence starting with a real backslash,
|
|
63
|
+
* not the character itself. Writing the character instead turns the
|
|
64
|
+
* replacement below into a silent no-op, which is why `jsonLdScript` is
|
|
65
|
+
* covered by a test asserting the output contains no bare angle bracket.
|
|
66
|
+
*/
|
|
67
|
+
const JSON_LD_ESCAPES: Readonly<Record<string, string>> = {
|
|
68
|
+
'<': '\\u003c',
|
|
69
|
+
'>': '\\u003e',
|
|
70
|
+
'&': '\\u0026',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Serialises data for a <script type="application/ld+json"> block.
|
|
75
|
+
*
|
|
76
|
+
* JSON.stringify alone is not enough: the HTML parser ends a script element at
|
|
77
|
+
* the first '</script', whatever the JSON quoting says. Escaping '<', '>' and
|
|
78
|
+
* '&' keeps the payload valid JSON - the escapes decode back to the original
|
|
79
|
+
* characters when parsed - while making it impossible to close the element or
|
|
80
|
+
* open a new tag.
|
|
81
|
+
*
|
|
82
|
+
* HTML entity escaping must NOT be used here: script content is raw text, so
|
|
83
|
+
* '&' would survive into the parsed JSON as a literal five-character run.
|
|
84
|
+
*/
|
|
85
|
+
export function jsonLdScript(data: unknown): string {
|
|
86
|
+
return JSON.stringify(data).replace(/[<>&]/g, (char) => JSON_LD_ESCAPES[char] ?? char);
|
|
87
|
+
}
|