@cparkerwebm/webmonterey 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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- package/template/workflows/ci.yml +52 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* INLINE TEXT FORMATTING for prose blocks.
|
|
3
|
+
*
|
|
4
|
+
* Page JSON is plain strings, but real copy needs bold lead-ins and links — a privacy policy
|
|
5
|
+
* is almost entirely "**Something.** explanation, see [the policy](/privacy)". Supporting that
|
|
6
|
+
* without shipping a markdown parser is the whole job of this module.
|
|
7
|
+
*
|
|
8
|
+
* The supported subset is deliberately tiny and will not grow without a reason:
|
|
9
|
+
*
|
|
10
|
+
* **bold** -> <strong>bold</strong>
|
|
11
|
+
* _italic_ -> <em>italic</em>
|
|
12
|
+
* [text](/url) -> <a href="/url">text</a>
|
|
13
|
+
*
|
|
14
|
+
* ORDER IS LOAD-BEARING. Everything is HTML-escaped FIRST, then the three patterns are
|
|
15
|
+
* applied to the escaped string. Do not reverse this to "parse then escape": the markers are
|
|
16
|
+
* all ASCII punctuation that survives escaping intact, so escaping first costs nothing and
|
|
17
|
+
* means a `<script>` in page JSON is inert no matter what. Page JSON is trusted content —
|
|
18
|
+
* it arrives through a reviewed commit, not a form — but a content model that is safe only
|
|
19
|
+
* while everyone remembers to be careful is not safe.
|
|
20
|
+
*
|
|
21
|
+
* The output is passed to `set:html`, which does NOT sanitise. This module is the sanitiser.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Escape the five characters that can break out of text or an attribute value. */
|
|
25
|
+
export function escapeHtml(value: string): string {
|
|
26
|
+
return value
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, ''');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Schemes allowed in a link. `javascript:` and `data:` are the two that turn a link into
|
|
36
|
+
* script execution, so this is an allowlist rather than a blocklist — a blocklist misses
|
|
37
|
+
* `vbscript:`, control characters inside the scheme, and whatever comes next.
|
|
38
|
+
*
|
|
39
|
+
* A relative URL (`/privacy`, `#section`, `contact`) has no scheme and is always allowed.
|
|
40
|
+
*/
|
|
41
|
+
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
|
|
42
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
43
|
+
|
|
44
|
+
function safeHref(href: string): string | null {
|
|
45
|
+
const trimmed = href.trim();
|
|
46
|
+
if (!trimmed) return null;
|
|
47
|
+
if (!HAS_SCHEME.test(trimmed)) return trimmed; // relative
|
|
48
|
+
return SAFE_SCHEME.test(trimmed) ? trimmed : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render one string of prose to HTML. Escapes everything, then applies the subset above.
|
|
53
|
+
*
|
|
54
|
+
* A link with an unsafe scheme degrades to its own text — the words survive, the link does
|
|
55
|
+
* not. Silently dropping the whole phrase would lose content the author wrote.
|
|
56
|
+
*/
|
|
57
|
+
export function renderInline(text: string): string {
|
|
58
|
+
return (
|
|
59
|
+
escapeHtml(text)
|
|
60
|
+
// Links first: their label may itself contain bold, and the URL must not be scanned
|
|
61
|
+
// for emphasis markers (an underscore in a query string is not italic).
|
|
62
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => {
|
|
63
|
+
const safe = safeHref(href);
|
|
64
|
+
return safe === null ? label : `<a href="${safe}">${label}</a>`;
|
|
65
|
+
})
|
|
66
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
67
|
+
// Underscores only count as emphasis at a word boundary, so snake_case_identifiers and
|
|
68
|
+
// the underscores inside a URL that already became an href are left alone.
|
|
69
|
+
.replace(/(^|\s)_([^_]+)_(?=$|[\s.,;:!?)])/g, '$1<em>$2</em>')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { copy } from '../copy.ts';
|
|
3
|
+
/*
|
|
4
|
+
* Back-to-top control — a circular progress ring that fills 0→100% with scroll depth,
|
|
5
|
+
* wrapping an up arrow. Fixed to the bottom-right.
|
|
6
|
+
*
|
|
7
|
+
* The ring is a conic-gradient driven by ONE custom property, --webm-scroll-progress, which
|
|
8
|
+
* the script updates. No SVG, no stroke-dasharray maths, no per-frame layout.
|
|
9
|
+
*
|
|
10
|
+
* Progress is tracked with a scroll listener throttled to requestAnimationFrame — unlike the
|
|
11
|
+
* show/hide case, a ring genuinely needs a continuous value, so IntersectionObserver cannot
|
|
12
|
+
* do the job. The handler is passive and does one style write per frame at most.
|
|
13
|
+
*
|
|
14
|
+
* Where scroll-driven animations are supported the ring is driven natively by
|
|
15
|
+
* `animation-timeline: scroll()` instead, with zero JavaScript; the script is the fallback
|
|
16
|
+
* and stands down when CSS is handling it.
|
|
17
|
+
*
|
|
18
|
+
* USAGE — near the end of <body> in the layout:
|
|
19
|
+
*
|
|
20
|
+
* <ScrollTop />
|
|
21
|
+
*/
|
|
22
|
+
interface Props {
|
|
23
|
+
/** Accessible name. */
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Hide the control until the visitor has scrolled this fraction of the page (0–1). */
|
|
26
|
+
revealAfter?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { label = copy.scrollTop.label, revealAfter = 0.15 } = Astro.props;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<!--
|
|
33
|
+
href="#top" is a browser-defined special case that scrolls to the top of the DOCUMENT and
|
|
34
|
+
needs no matching element. Do NOT point this at #webm-main: once a site has a header above
|
|
35
|
+
<main>, "Back to top" would land the visitor below it, hijack the skip link's target, and
|
|
36
|
+
push a hash into history.
|
|
37
|
+
-->
|
|
38
|
+
<a class="webm-scroll-top" href="#top" data-reveal-after={revealAfter} aria-label={label} hidden>
|
|
39
|
+
<span class="webm-scroll-top__ring" aria-hidden="true"></span>
|
|
40
|
+
<svg
|
|
41
|
+
class="webm-scroll-top__icon"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
width="18"
|
|
44
|
+
height="18"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
focusable="false"
|
|
47
|
+
>
|
|
48
|
+
<path
|
|
49
|
+
d="M12 19V5M5 12l7-7 7 7"
|
|
50
|
+
fill="none"
|
|
51
|
+
stroke="currentColor"
|
|
52
|
+
stroke-width="2.5"
|
|
53
|
+
stroke-linecap="round"
|
|
54
|
+
stroke-linejoin="round"></path>
|
|
55
|
+
</svg>
|
|
56
|
+
</a>
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
@layer webm.components.core {
|
|
60
|
+
.webm-scroll-top {
|
|
61
|
+
/* 0 → 1. Read by the conic-gradient below. */
|
|
62
|
+
--webm-scroll-progress: 0;
|
|
63
|
+
--webm-scroll-size: 3rem;
|
|
64
|
+
--webm-scroll-ring-width: 3px;
|
|
65
|
+
|
|
66
|
+
position: fixed;
|
|
67
|
+
inset-block-end: var(--webm-space-md);
|
|
68
|
+
inset-inline-end: var(--webm-space-md);
|
|
69
|
+
z-index: var(--webm-z-sticky);
|
|
70
|
+
|
|
71
|
+
display: grid;
|
|
72
|
+
place-items: center;
|
|
73
|
+
inline-size: var(--webm-scroll-size);
|
|
74
|
+
block-size: var(--webm-scroll-size);
|
|
75
|
+
|
|
76
|
+
color: var(--webm-action);
|
|
77
|
+
background-color: var(--webm-surface-raised);
|
|
78
|
+
border-radius: var(--webm-radius-circle);
|
|
79
|
+
box-shadow: var(--webm-shadow-md);
|
|
80
|
+
|
|
81
|
+
transition:
|
|
82
|
+
opacity var(--webm-duration-normal) var(--webm-ease-out),
|
|
83
|
+
translate var(--webm-duration-normal) var(--webm-ease-out);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Entry state the script sets for one frame, so the reveal has something to animate to. */
|
|
87
|
+
.webm-scroll-top:where([data-entering]) {
|
|
88
|
+
opacity: 0;
|
|
89
|
+
translate: 0 var(--webm-space-sm);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* The ring. A conic-gradient hard-stops at the progress angle: filled in --webm-action,
|
|
94
|
+
* unfilled in a muted track. `mask` punches out the middle so only a ring remains —
|
|
95
|
+
* cheaper and sharper than an SVG circle, and it animates on the compositor.
|
|
96
|
+
*/
|
|
97
|
+
.webm-scroll-top__ring {
|
|
98
|
+
position: absolute;
|
|
99
|
+
inset: 0;
|
|
100
|
+
border-radius: inherit;
|
|
101
|
+
background: conic-gradient(
|
|
102
|
+
from 0deg,
|
|
103
|
+
var(--webm-action) calc(var(--webm-scroll-progress) * 360deg),
|
|
104
|
+
var(--webm-border-subtle) 0
|
|
105
|
+
);
|
|
106
|
+
mask: radial-gradient(
|
|
107
|
+
closest-side,
|
|
108
|
+
transparent calc(100% - var(--webm-scroll-ring-width)),
|
|
109
|
+
black calc(100% - var(--webm-scroll-ring-width))
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.webm-scroll-top__icon {
|
|
114
|
+
/* Above the ring, and nudged optically rather than mathematically centred. */
|
|
115
|
+
position: relative;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.webm-scroll-top:hover {
|
|
119
|
+
color: var(--webm-action-dark);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* Preferred path: drive the ring from the scroll timeline itself — no JavaScript at all.
|
|
124
|
+
* The script detects this support and stops updating the property.
|
|
125
|
+
*/
|
|
126
|
+
@supports (animation-timeline: scroll()) {
|
|
127
|
+
.webm-scroll-top__ring {
|
|
128
|
+
animation: webm-scroll-ring linear both;
|
|
129
|
+
animation-timeline: scroll(root block);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@keyframes webm-scroll-ring {
|
|
135
|
+
from {
|
|
136
|
+
--webm-scroll-progress: 0;
|
|
137
|
+
}
|
|
138
|
+
to {
|
|
139
|
+
--webm-scroll-progress: 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
143
|
+
|
|
144
|
+
<!--
|
|
145
|
+
Registering the custom property is what makes --webm-scroll-progress ANIMATABLE. Without
|
|
146
|
+
@property it is an untyped token that jumps 0 → 1 with no interpolation, so the CSS-driven
|
|
147
|
+
ring would snap rather than sweep. It has to live outside the scoped <style> block because
|
|
148
|
+
@property is always global.
|
|
149
|
+
-->
|
|
150
|
+
<style is:global>
|
|
151
|
+
@property --webm-scroll-progress {
|
|
152
|
+
syntax: '<number>';
|
|
153
|
+
inherits: false;
|
|
154
|
+
initial-value: 0;
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
157
|
+
|
|
158
|
+
<script>
|
|
159
|
+
const button = document.querySelector<HTMLAnchorElement>('.webm-scroll-top');
|
|
160
|
+
|
|
161
|
+
if (button) {
|
|
162
|
+
const revealAfter = Number(button.dataset.revealAfter ?? '0.15');
|
|
163
|
+
|
|
164
|
+
// If CSS can drive the ring from the scroll timeline, JS only handles show/hide.
|
|
165
|
+
const cssDrivesRing = CSS.supports('animation-timeline', 'scroll()');
|
|
166
|
+
|
|
167
|
+
let ticking = false;
|
|
168
|
+
|
|
169
|
+
const update = () => {
|
|
170
|
+
ticking = false;
|
|
171
|
+
|
|
172
|
+
const doc = document.documentElement;
|
|
173
|
+
const scrollable = doc.scrollHeight - doc.clientHeight;
|
|
174
|
+
|
|
175
|
+
// A page shorter than the viewport has nothing to scroll: never show the control.
|
|
176
|
+
if (scrollable <= 0) {
|
|
177
|
+
if (!button.hidden) button.hidden = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const progress = Math.min(Math.max(doc.scrollTop / scrollable, 0), 1);
|
|
182
|
+
|
|
183
|
+
if (!cssDrivesRing) {
|
|
184
|
+
button.style.setProperty('--webm-scroll-progress', String(progress));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const shouldShow = progress > revealAfter;
|
|
188
|
+
|
|
189
|
+
if (shouldShow && button.hidden) {
|
|
190
|
+
button.hidden = false;
|
|
191
|
+
button.setAttribute('data-entering', '');
|
|
192
|
+
// Next frame, drop the attribute so the transition has a target to run to.
|
|
193
|
+
requestAnimationFrame(() => button.removeAttribute('data-entering'));
|
|
194
|
+
} else if (!shouldShow && !button.hidden) {
|
|
195
|
+
button.hidden = true;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const onScroll = () => {
|
|
200
|
+
if (ticking) return;
|
|
201
|
+
ticking = true;
|
|
202
|
+
requestAnimationFrame(update);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
addEventListener('scroll', onScroll, { passive: true });
|
|
206
|
+
addEventListener('resize', onScroll, { passive: true });
|
|
207
|
+
update();
|
|
208
|
+
}
|
|
209
|
+
</script>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Typed access to the client's webmonterey.json.
|
|
3
|
+
*
|
|
4
|
+
* THIN BY DESIGN. Everything here is a binding of a pure helper in config.ts to the real config,
|
|
5
|
+
* which arrives through the `virtual:webm/site` module the integration provides. The logic is
|
|
6
|
+
* tested without a build; this file is what wires it up.
|
|
7
|
+
*
|
|
8
|
+
* The question everything asks of this file is whether a field has actually been filled in yet.
|
|
9
|
+
* `client` and `domain` ship as the literal CHANGEME, and five surfaces depend on knowing that -
|
|
10
|
+
* each wanting a DIFFERENT answer to "what should I do about it":
|
|
11
|
+
*
|
|
12
|
+
* Credit.astro throw, because a credit link with a placeholder in utm_content is worse
|
|
13
|
+
* than no build
|
|
14
|
+
* robots.txt omit the Sitemap: line, because pointing crawlers at a sitemap that does
|
|
15
|
+
* not exist is worse than omitting it
|
|
16
|
+
* structured data emit nothing at all, because @id values need a real origin
|
|
17
|
+
* email fall back to the domain, because failing a live enquiry over a cosmetic
|
|
18
|
+
* subject-line prefix trades a real loss for a trivial one
|
|
19
|
+
*
|
|
20
|
+
* So this module answers the question and deliberately does NOT decide the consequence.
|
|
21
|
+
*/
|
|
22
|
+
import site from 'virtual:webm/site';
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_LOCALE,
|
|
25
|
+
DEFAULT_TIME_ZONE,
|
|
26
|
+
isStagingDeployment,
|
|
27
|
+
zonedHour,
|
|
28
|
+
isConfigured,
|
|
29
|
+
PLACEHOLDER,
|
|
30
|
+
resolveDisplayName,
|
|
31
|
+
type Organization,
|
|
32
|
+
type SiteConfig,
|
|
33
|
+
type SiteFeatures,
|
|
34
|
+
} from './config.ts';
|
|
35
|
+
|
|
36
|
+
export { PLACEHOLDER, isConfigured, isStagingDeployment };
|
|
37
|
+
export type { SiteConfig, SiteFeatures, Organization };
|
|
38
|
+
|
|
39
|
+
export const config: SiteConfig = site;
|
|
40
|
+
|
|
41
|
+
export const client = site.client;
|
|
42
|
+
export const domain = site.domain;
|
|
43
|
+
export const features: SiteFeatures = site.features ?? {};
|
|
44
|
+
export const organization: Organization = site.organization ?? {};
|
|
45
|
+
|
|
46
|
+
/** The short name for titles, falling back to the display name. See SiteConfig.shortName. */
|
|
47
|
+
export const shortName = isConfigured(site.shortName) ? site.shortName! : displayName();
|
|
48
|
+
|
|
49
|
+
/** Whether to append the site name to a page title. See SiteConfig.brandTitles. */
|
|
50
|
+
export const brandTitles = site.brandTitles !== false;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The Google Tag Manager container id, or '' when the site has not set one.
|
|
54
|
+
*
|
|
55
|
+
* `isConfigured` rather than a plain read, so the literal CHANGEME never reaches a script tag:
|
|
56
|
+
* GTM would request a container by that name, 404, and leave a broken third-party request on
|
|
57
|
+
* every page of the site.
|
|
58
|
+
*/
|
|
59
|
+
export const gtmId = isConfigured(site.gtmId) ? site.gtmId! : '';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What this deployment is FOR. See SiteConfig.environment.
|
|
63
|
+
*
|
|
64
|
+
* Unset means production — see the field's own note for why that default is the safe one.
|
|
65
|
+
*/
|
|
66
|
+
export const environment = site.environment ?? 'production';
|
|
67
|
+
|
|
68
|
+
/** True when this deployment must not touch a client's real contacts. */
|
|
69
|
+
export const isStaging = environment === 'staging';
|
|
70
|
+
export const isProduction = !isStaging;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where staging email goes instead of its real recipients, or '' when the site has not said.
|
|
74
|
+
* There is no default - see SiteConfig.stagingEmail. sendEmail refuses on '' rather than guessing.
|
|
75
|
+
*/
|
|
76
|
+
export const stagingEmail = isConfigured(site.stagingEmail) ? site.stagingEmail! : '';
|
|
77
|
+
|
|
78
|
+
export const hasClient = isConfigured(client);
|
|
79
|
+
export const hasDomain = isConfigured(domain);
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* THE PHONE NUMBER, from organization.telephone.
|
|
83
|
+
*
|
|
84
|
+
* Stored in E.164 - a leading + and country code, no punctuation - because that is what Google
|
|
85
|
+
* expects in structured data. That is also exactly what a `tel:` href wants, so the same value
|
|
86
|
+
* serves the JSON-LD and the call link on a mobile menu, and there is one place to change it.
|
|
87
|
+
*
|
|
88
|
+
* The human-readable form is a display decision and belongs in the component that shows it.
|
|
89
|
+
*
|
|
90
|
+
* Added because webmonterey.com had already patched this into its own copy of this file. That
|
|
91
|
+
* is the copy-forward failure the package exists to end: a fix made in one site's src/includes/
|
|
92
|
+
* reaches no other site, ever, and nothing reports that it is missing.
|
|
93
|
+
*/
|
|
94
|
+
export const telephone = organization.telephone ?? '';
|
|
95
|
+
export const hasTelephone = isConfigured(telephone);
|
|
96
|
+
|
|
97
|
+
/** Never returns 'CHANGEME'. See resolveDisplayName. */
|
|
98
|
+
export function displayName(): string {
|
|
99
|
+
return resolveDisplayName(client, domain);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* IANA zone for every client-facing date. Everything stored is UTC, so nothing reaches a person
|
|
104
|
+
* without passing through this - a prerendered page especially, which bakes in build-time values
|
|
105
|
+
* and must be handed the zone explicitly rather than trusting the build machine's clock.
|
|
106
|
+
*/
|
|
107
|
+
export const timeZone = site.timeZone ?? DEFAULT_TIME_ZONE;
|
|
108
|
+
export const locale = site.locale ?? DEFAULT_LOCALE;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The hour, 0-23, in THIS site's zone. See zonedHour in config.ts for why a scheduled job needs
|
|
112
|
+
* it and why it cannot use a fixed UTC hour.
|
|
113
|
+
*
|
|
114
|
+
* export default defineWorker({
|
|
115
|
+
* scheduled: (c, env, ctx) => {
|
|
116
|
+
* if (hourNow(new Date(c.scheduledTime)) !== 21) return; // 9pm local, all year
|
|
117
|
+
* ctx.waitUntil(sendSummaries(env));
|
|
118
|
+
* },
|
|
119
|
+
* });
|
|
120
|
+
*/
|
|
121
|
+
export function hourNow(at: Date = new Date()): number {
|
|
122
|
+
return zonedHour(at, timeZone);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Format a date in the client's zone and locale.
|
|
127
|
+
*
|
|
128
|
+
* Never call toLocaleString without a zone on a prerendered route: the build machine's clock is
|
|
129
|
+
* the developer's and the Worker's is UTC, so a date rendered at build is wrong in one of them.
|
|
130
|
+
*/
|
|
131
|
+
export function formatDate(
|
|
132
|
+
value: Date | string | number,
|
|
133
|
+
options: Intl.DateTimeFormatOptions = {},
|
|
134
|
+
): string {
|
|
135
|
+
return new Intl.DateTimeFormat(locale, { timeZone, ...options }).format(new Date(value));
|
|
136
|
+
}
|