@moonarc/mcp 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 +49 -0
- package/bin/moonarc.js +80 -0
- package/catalog.json +11809 -0
- package/package.json +48 -0
- package/src/audit.js +365 -0
- package/src/catalog.js +186 -0
- package/src/compose.js +171 -0
- package/src/install.js +29 -0
- package/src/license.js +47 -0
- package/src/measure.js +269 -0
- package/src/server.js +196 -0
package/src/compose.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compose_section: an ordered composition of components for a stated goal, with the total measured cost and a code
|
|
3
|
+
* skeleton. The one genuinely N-to-1 workflow tool: an agent states an intent and gets a section.
|
|
4
|
+
*
|
|
5
|
+
* Costs and browser support come from the catalogue at answer time, never from these recipes, so a component that
|
|
6
|
+
* gains or loses a script changes the answer without an edit here. The notes carry composition advice only.
|
|
7
|
+
*/
|
|
8
|
+
const RECIPES = [
|
|
9
|
+
{
|
|
10
|
+
id: 'hero',
|
|
11
|
+
words: ['hero', 'landing', 'above the fold', 'headline', 'intro', 'splash'],
|
|
12
|
+
title: 'Hero',
|
|
13
|
+
parts: ['Aurora', 'SplitText', 'Reveal', 'RotatingText', 'Typewriter'],
|
|
14
|
+
code: `<section class="relative overflow-hidden">
|
|
15
|
+
<Aurora opacity={0.4} />
|
|
16
|
+
<div class="relative">
|
|
17
|
+
<SplitText as="h1" text="Your headline, split on the server." />
|
|
18
|
+
<Reveal delay={400}><p>Supporting copy, revealed once. Ship <RotatingText words={['faster', 'lighter']} /> sites.</p></Reveal>
|
|
19
|
+
<Reveal delay={550}><a href="/setup/">Primary action</a> <code><Typewriter text="npx astro add moonarc" /></code></Reveal>
|
|
20
|
+
</div>
|
|
21
|
+
</section>`,
|
|
22
|
+
notes: ['Aurora is decoration: give it a positioned parent and put the content above it with position: relative.', 'Delays of 400 and 550 ms let the headline finish before the copy starts. Stagger at most three things in a hero.'],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'hero-field',
|
|
26
|
+
words: ['cursor', 'pointer', 'interactive hero', 'grid hero', 'cursor field', 'cursor grid', 'mouse'],
|
|
27
|
+
title: 'Hero with a cursor field',
|
|
28
|
+
parts: ['CursorGrid', 'SplitText', 'Reveal', 'Magnetic'],
|
|
29
|
+
code: `<section class="relative min-h-[70vh]">
|
|
30
|
+
<CursorGrid color="var(--accent)" />
|
|
31
|
+
<div class="relative">
|
|
32
|
+
<SplitText as="h1" text="Move your cursor over the grid." />
|
|
33
|
+
<Reveal delay={400}><Magnetic><a href="/start/">Primary action</a></Magnetic></Reveal>
|
|
34
|
+
</div>
|
|
35
|
+
</section>`,
|
|
36
|
+
notes: ['The field lights its cells with hover states. Keep the content above it with position: relative.', 'Magnetic pulls one element; use it on the primary action only.'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'faq',
|
|
40
|
+
words: ['faq', 'faqs', 'questions', 'accordion', 'collapsible', 'disclosure'],
|
|
41
|
+
title: 'FAQ',
|
|
42
|
+
parts: ['Accordion', 'Reveal'],
|
|
43
|
+
code: `<Reveal>
|
|
44
|
+
<Accordion exclusive open={0} items={[
|
|
45
|
+
{ title: 'Is it really zero JS?', body: '<p>For this one, yes.</p>' },
|
|
46
|
+
{ title: 'Keyboard?', body: '<p>Native.</p>' },
|
|
47
|
+
]} />
|
|
48
|
+
</Reveal>`,
|
|
49
|
+
notes: ['The items are native <details>, so the keyboard works without a script and ::details-content animates the height.', 'exclusive keeps one answer open at a time; leave it off when readers compare answers.'],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'features',
|
|
53
|
+
words: ['feature', 'features', 'benefit', 'benefits', 'grid', 'cards', 'card grid', 'bento', 'why choose', 'why us'],
|
|
54
|
+
title: 'Feature grid',
|
|
55
|
+
parts: ['Reveal'],
|
|
56
|
+
code: `<Reveal as="ul" stagger={60} class="grid gap-4 sm:grid-cols-3">
|
|
57
|
+
<li>Feature one</li>
|
|
58
|
+
<li>Feature two</li>
|
|
59
|
+
<li>Feature three</li>
|
|
60
|
+
</Reveal>`,
|
|
61
|
+
notes: ['One Reveal with stagger and the cards as its direct children. A Reveal per card cannot stagger them.', 'Six cards at 60 ms is 300 ms of stagger; past nine cards, drop to 40 ms.'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'logos',
|
|
65
|
+
words: ['logo', 'logos', 'logo strip', 'logo wall', 'customer logos', 'trusted by', 'partners', 'brands', 'ticker', 'as seen on'],
|
|
66
|
+
title: 'Logo strip',
|
|
67
|
+
parts: ['Marquee'],
|
|
68
|
+
code: `<Marquee duration={28} gap="3rem" label="Customers">
|
|
69
|
+
<img src="/logos/a.svg" alt="A" height="28" />
|
|
70
|
+
<img src="/logos/b.svg" alt="B" height="28" />
|
|
71
|
+
</Marquee>`,
|
|
72
|
+
notes: ['Scale the duration with the content width: about 1 s per 60 px keeps logos readable.', 'label names the strip for screen readers, which hear each logo once.'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'testimonials',
|
|
76
|
+
words: ['testimonial', 'testimonials', 'quote', 'quotes', 'review', 'reviews', 'social proof', 'wall of love', 'customer quotes'],
|
|
77
|
+
title: 'Testimonial wall',
|
|
78
|
+
parts: ['Reveal', 'Marquee'],
|
|
79
|
+
code: `<Reveal as="div" stagger={70} class="grid gap-4 md:grid-cols-2">
|
|
80
|
+
<blockquote>…</blockquote>
|
|
81
|
+
<blockquote>…</blockquote>
|
|
82
|
+
</Reveal>
|
|
83
|
+
<!-- or a moving wall: up and down need a height on the strip -->
|
|
84
|
+
<Marquee direction="up" duration={40} style="height: 32rem">
|
|
85
|
+
<blockquote>…</blockquote>
|
|
86
|
+
</Marquee>`,
|
|
87
|
+
notes: ['A static staggered grid reads better than a moving wall for more than four quotes; use the vertical Marquee for a sidebar.'],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'stats',
|
|
91
|
+
words: ['stat', 'stats', 'statistic', 'statistics', 'number', 'numbers', 'metric', 'metrics', 'kpi', 'kpis', 'counter', 'counters', 'count up'],
|
|
92
|
+
title: 'Stat row',
|
|
93
|
+
parts: ['CountUp', 'Progress'],
|
|
94
|
+
code: `<dl class="grid grid-cols-3 gap-6">
|
|
95
|
+
<div><dt>Customers</dt><dd><CountUp to={1200} /></dd></div>
|
|
96
|
+
<div><dt>Countries</dt><dd><CountUp to={48} /></dd></div>
|
|
97
|
+
<div><dt>Uptime</dt><dd><Progress value={99} /></dd></div>
|
|
98
|
+
</dl>`,
|
|
99
|
+
notes: ['CountUp counts with CSS counters, and the server HTML already holds the final number.', 'Give CountUp and Progress the same duration so the bar and the number land together.'],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'cta',
|
|
103
|
+
words: ['cta', 'call to action', 'signup', 'sign up', 'subscribe', 'get started', 'start now', 'closing'],
|
|
104
|
+
title: 'Call to action',
|
|
105
|
+
parts: ['Reveal', 'Typewriter'],
|
|
106
|
+
code: `<Reveal scale={0.98} class="rounded-2xl border p-10 text-center">
|
|
107
|
+
<h2>Ready?</h2>
|
|
108
|
+
<p><code><Typewriter text="npx astro add moonarc" /></code></p>
|
|
109
|
+
<a href="/setup/">Start</a>
|
|
110
|
+
</Reveal>`,
|
|
111
|
+
notes: ['Scale from 0.98, never from 0. One Reveal for the whole block.'],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** The goals compose_section knows, in the words of its answer when none matches. */
|
|
116
|
+
const GOALS = 'a hero, a hero with a cursor field, an FAQ, a feature grid, a logo strip, a testimonial wall, a stat row, a call to action';
|
|
117
|
+
|
|
118
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
119
|
+
/** How well a recipe answers: each of its words or phrases found as a whole word scores its length in words, so "interactive hero" beats "hero". */
|
|
120
|
+
const scoreOf = (recipe, intent) => recipe.words.reduce((n, w) => n + (new RegExp(`\\b${escape(w).replace(/ /g, '\\s+')}s?\\b`, 'i').test(intent) ? w.split(' ').length : 0), 0);
|
|
121
|
+
|
|
122
|
+
const fmt = (b) => (b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} kB`);
|
|
123
|
+
const list = (names) => (names.length < 3 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`);
|
|
124
|
+
|
|
125
|
+
/** Every recipe's parts, for tests that hold them against the catalogue. */
|
|
126
|
+
export const recipeParts = () => RECIPES.map((r) => ({ id: r.id, parts: r.parts, code: r.code }));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} intent
|
|
130
|
+
* @param {{ components: { name: string, supportLine?: string, support?: { baseline: string, fallback?: string }, cost: { js: { raw: number }, dependsOn: string[] } | null }[], runtime: { raw: number } | null, chunks?: Record<string, { raw: number }> }} catalog
|
|
131
|
+
*/
|
|
132
|
+
export function compose(intent, catalog) {
|
|
133
|
+
const best = RECIPES.map((r) => ({ r, score: scoreOf(r, intent) })).reduce((a, b) => (b.score > a.score ? b : a), { r: null, score: 0 });
|
|
134
|
+
if (!best.r) return `No composition matches "${intent}". compose_section has recipes for ${GOALS}. For anything else, call search_motion with the goal and build the section from its results.`;
|
|
135
|
+
const recipe = best.r;
|
|
136
|
+
const parts = recipe.parts.map((name) => catalog.components.find((c) => c.name === name)).filter(Boolean);
|
|
137
|
+
|
|
138
|
+
// What the page loads for the section, each chunk once: every part's own script, and what the parts share (Reveal,
|
|
139
|
+
// the runtime, a helper). A dependency that is a component weighs its own script; any other takes the catalogue's
|
|
140
|
+
// chunk size, and the runtime its measured figure when the catalogue has no chunk sizes.
|
|
141
|
+
const size = (name) => catalog.components.find((c) => c.name === name)?.cost?.js.raw ?? catalog.chunks?.[name]?.raw ?? (name === 'runtime' ? catalog.runtime?.raw : undefined);
|
|
142
|
+
const chunks = new Map();
|
|
143
|
+
for (const c of parts) {
|
|
144
|
+
if (c.cost?.js.raw) chunks.set(c.name, c.cost.js.raw);
|
|
145
|
+
for (const d of c.cost?.dependsOn ?? []) if (!chunks.has(d)) chunks.set(d, size(d));
|
|
146
|
+
}
|
|
147
|
+
const known = [...chunks].filter(([, b]) => b != null && b > 0);
|
|
148
|
+
const unknown = [...chunks].filter(([, b]) => b == null).map(([n]) => n);
|
|
149
|
+
const total = known.reduce((n, [, b]) => n + b, 0);
|
|
150
|
+
const none = parts.filter((c) => c.cost && c.cost.js.raw === 0).map((c) => c.name);
|
|
151
|
+
const cost = `${fmt(total)} raw of JavaScript for the whole section${known.length ? `: ${known.map(([n, b]) => `${n} ${fmt(b)} raw`).join(', ')}, each loaded once` : ''}${unknown.length ? ` (plus ${list(unknown)}, not measured in this catalogue)` : ''}.${none.length ? ` ${list(none)} ${none.length === 1 ? 'adds' : 'add'} no script of ${none.length === 1 ? 'its' : 'their'} own.` : ''}`;
|
|
152
|
+
const support = parts.filter((c) => c.support && c.support.baseline !== 'widely').map((c) => `- ${c.name} needs ${c.supportLine}${c.support.fallback ? `; elsewhere ${c.support.fallback}` : ''}.`);
|
|
153
|
+
|
|
154
|
+
return `# ${recipe.title}: a composition for "${intent}"
|
|
155
|
+
|
|
156
|
+
Order: ${parts.map((c) => c.name).join(' → ')}
|
|
157
|
+
Cost: ${cost}
|
|
158
|
+
|
|
159
|
+
## Imports
|
|
160
|
+
${parts.map((c) => `import ${c.name} from '@moonarc/core/${c.name}';`).join('\n')}
|
|
161
|
+
|
|
162
|
+
## Skeleton
|
|
163
|
+
\`\`\`astro
|
|
164
|
+
${recipe.code}
|
|
165
|
+
\`\`\`
|
|
166
|
+
|
|
167
|
+
## Notes
|
|
168
|
+
${[...recipe.notes.map((n) => `- ${n}`), ...support].join('\n')}
|
|
169
|
+
- Every part has a reduced-motion branch and keeps working after ClientRouter navigation; get_component shows how for each.
|
|
170
|
+
`;
|
|
171
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// What get_install_command answers. Pure, so tests/registry can hold the copy answer's components.json against the one
|
|
2
|
+
// the site documents on /setup/ and install with it.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The components.json the shadcn CLI needs in an Astro project, which has none. `shadcn init` would write one and add
|
|
6
|
+
* React with it; this file leaves package.json alone. The same file /setup/ prints.
|
|
7
|
+
*/
|
|
8
|
+
export const componentsJson = (site) => ({
|
|
9
|
+
$schema: 'https://ui.shadcn.com/schema.json',
|
|
10
|
+
style: 'new-york',
|
|
11
|
+
rsc: false,
|
|
12
|
+
tsx: true,
|
|
13
|
+
tailwind: { config: '', css: 'src/styles/global.css', baseColor: 'neutral', cssVariables: true },
|
|
14
|
+
aliases: { components: '@/components', utils: '@/lib/utils', lib: '@/lib' },
|
|
15
|
+
registries: { '@moonarc': `${site}/r/{name}.json` },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** `entries`: catalogue entries ({ name, slug }). `method`: 'package' (default) or 'copy'. */
|
|
19
|
+
export function installText(site, entries, method = 'package') {
|
|
20
|
+
if (method !== 'copy') return `npx astro add moonarc\n\n# then, in a .astro file:\n${entries.map((e) => `import ${e.name} from '@moonarc/core/${e.name}';`).join('\n')}`;
|
|
21
|
+
return [
|
|
22
|
+
'1. The shadcn CLI reads components.json, and an Astro project has none. If the project has no components.json, write this one (shadcn init would add React and seven packages these components never import):',
|
|
23
|
+
JSON.stringify(componentsJson(site), null, 2),
|
|
24
|
+
'2. The copied files import one another through the @/* alias. tsconfig.json needs it under compilerOptions: "paths": { "@/*": ["./src/*"] }',
|
|
25
|
+
'3. Run:',
|
|
26
|
+
`npx shadcn@latest add ${entries.map((e) => `${site}/r/${e.slug}.json`).join(' ')}`,
|
|
27
|
+
`Files land in src/components/moonarc/ and src/lib/moonarc/, the stylesheets in src/styles/. Import '@/styles/moonarc.css' once. Entrance effects also need the JS gate: ${site}/docs/installation/#manual-install`,
|
|
28
|
+
].join('\n\n');
|
|
29
|
+
}
|
package/src/license.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pro gating. Plan-level, not per-call: the key is validated once per process against Polar's customer-portal
|
|
3
|
+
* license-key endpoint (no token needed; the organisation id is not a secret) and a final answer is kept for the
|
|
4
|
+
* process. A key Polar does not know is final: the tool result says subscription_required and tells the agent not to
|
|
5
|
+
* retry. A failure that says nothing about the key (a timeout, a 5xx, a 429, a network error, a body it cannot read)
|
|
6
|
+
* is not kept, so the next call asks again.
|
|
7
|
+
*
|
|
8
|
+
* The organisation is Moonarc's production organisation, written here so a subscriber sets only MOONARC_KEY;
|
|
9
|
+
* MOONARC_POLAR_ORG and MOONARC_LICENSE_ENDPOINT point it at the sandbox or at a test's mock. Without an organisation
|
|
10
|
+
* every key is refused: a build that names none must not grant Pro to any string.
|
|
11
|
+
*/
|
|
12
|
+
const ORGANIZATION = '2a6a3f61-ff75-4b83-b809-a3e2a835bfd8'; // Moonarc's production organisation on Polar (slug moonarc)
|
|
13
|
+
const KEY = process.env.MOONARC_KEY;
|
|
14
|
+
const ORG = process.env.MOONARC_POLAR_ORG || ORGANIZATION;
|
|
15
|
+
const ENDPOINT = process.env.MOONARC_LICENSE_ENDPOINT ?? 'https://api.polar.sh/v1/customer-portal/license-keys/validate';
|
|
16
|
+
const REFUSED = 'subscription_required: the license key is invalid, revoked, or the subscription ended. Do not retry; tell the user to check https://moonarc.dev/account/';
|
|
17
|
+
|
|
18
|
+
let cached = null;
|
|
19
|
+
|
|
20
|
+
/** @returns {Promise<{ ok: boolean, plan: 'free'|'pro', reason?: string, validated?: boolean, expiresAt?: string|null }>} */
|
|
21
|
+
export async function licenseStatus() {
|
|
22
|
+
if (cached) return cached;
|
|
23
|
+
if (!KEY) return (cached = { ok: false, plan: 'free', reason: 'no MOONARC_KEY set' });
|
|
24
|
+
if (!ORG) return (cached = { ok: false, plan: 'free', reason: 'Pro is not open yet: this build of @moonarc/mcp names no Polar organisation' });
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: KEY, organization_id: ORG }), signal: AbortSignal.timeout(8000) });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const why = err?.name === 'TimeoutError' ? 'no answer within 8 s' : err?.message;
|
|
30
|
+
return { ok: false, plan: 'free', reason: `license service unreachable (${why}); Pro tools unavailable offline, try again later` };
|
|
31
|
+
}
|
|
32
|
+
if (res.status === 403) return (cached = { ok: false, plan: 'free', reason: REFUSED });
|
|
33
|
+
if (res.status === 404) {
|
|
34
|
+
// Polar's own "not found" names the resource ({"error":"ResourceNotFound"}); a 404 without it is a route or an API
|
|
35
|
+
// version Polar does not serve ({"detail":"Not Found"}), which says nothing about the key
|
|
36
|
+
const body = await res.json().catch(() => null);
|
|
37
|
+
if (body?.error === 'ResourceNotFound') return (cached = { ok: false, plan: 'free', reason: REFUSED });
|
|
38
|
+
return { ok: false, plan: 'free', reason: 'license service answered 404 for its own route; update @moonarc/mcp' };
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) return { ok: false, plan: 'free', reason: `license service returned ${res.status}; try again later` };
|
|
41
|
+
const data = await res.json().catch(() => null);
|
|
42
|
+
if (!data) return { ok: false, plan: 'free', reason: 'license service returned an unreadable answer; try again later' };
|
|
43
|
+
// Pro and Team grant the same license-key benefit, so the answer cannot tell them apart; both run the same tools
|
|
44
|
+
return (cached = { ok: true, plan: 'pro', validated: true, expiresAt: data.expires_at ?? null });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const hasKey = Boolean(KEY);
|
package/src/measure.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* measure_budget and `moonarc measure`: the JavaScript each page of a built Astro site makes the browser load.
|
|
3
|
+
*
|
|
4
|
+
* measurePage() is the method Moonarc's own CI runs: tests/budget/measure.ts re-exports it, so the budget suite, the
|
|
5
|
+
* template ceilings and this tool are one computation and cannot drift apart again. It counts external scripts, inline
|
|
6
|
+
* scripts the browser runs (a JSON, JSON-LD or import-map block is data), modulepreloads, the component and renderer an
|
|
7
|
+
* <astro-island> loads, and every chunk those import, statically or dynamically with a fixed path, each once per page.
|
|
8
|
+
* Astro inlines small scripts and stylesheets into the HTML, so counting dist/_astro alone under-reports.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, posix, relative, sep } from 'node:path';
|
|
12
|
+
import { gzipSync } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ raw: number, gzip: number }} Bytes */
|
|
15
|
+
/** @typedef {Bytes & { kind: 'inline-script'|'script'|'modulepreload'|'inline-style'|'stylesheet', ref: string, shared: boolean, file?: string }} Asset */
|
|
16
|
+
/** @typedef {{ page: string, js: Bytes, jsOwn: Bytes, css: Bytes, assets: Asset[], html: string, external: string[], missing: { ref: string, from: string }[] }} PageCost */
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} MeasureOptions
|
|
19
|
+
* @property {RegExp} [own] which script refs count as the page's own; default: any `*astro_type_script*` chunk
|
|
20
|
+
* @property {string} [base] the site's `base` ("/docs"): stripped from root-relative refs, whose files sit at the top of dist
|
|
21
|
+
* @property {'throw'|'collect'} [missing] a referenced file that is not there: throw (the default), or list it in `missing`
|
|
22
|
+
* @property {Map<string, { text?: string, size: Bytes }>} [files] a cache shared by the pages of one build: the chunks
|
|
23
|
+
* and stylesheets are the same files on every page, and gzipping them again for each page is most of the time
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const bytes = (s) => {
|
|
27
|
+
const b = typeof s === 'string' ? Buffer.from(s) : s;
|
|
28
|
+
return { raw: b.byteLength, gzip: gzipSync(b).byteLength };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Rollup names a page's own script `<file>.astro_astro_type_script_…js`; anything else it imports is a shared chunk. */
|
|
32
|
+
const isShared = (ref) => !/astro_type_script/.test(ref);
|
|
33
|
+
|
|
34
|
+
/** Another origin, a protocol-relative URL or a data: URL: not a file of this build. */
|
|
35
|
+
const isExternal = (spec) => /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(spec);
|
|
36
|
+
|
|
37
|
+
/** One attribute of a tag's attribute string, quoted either way or bare; '' when present without a value. */
|
|
38
|
+
export function attr(attrs, name) {
|
|
39
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${name}(?:\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))|(?=[\\s/>]|$))`, 'i'));
|
|
40
|
+
return m ? (m[1] ?? m[2] ?? m[3] ?? '') : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The HTML standard runs a <script> as JavaScript when its type is absent or empty, "module", or a JavaScript MIME
|
|
44
|
+
// type; any other type (application/json, application/ld+json, importmap, speculationrules, text/template) makes it a
|
|
45
|
+
// data block the browser never executes, so it is not JavaScript the page costs (cb-23: Globe's 710 B JSON was counted).
|
|
46
|
+
const JS_TYPES = /^(?:module|(?:text|application)\/(?:x-)?(?:java|ecma)script|text\/javascript1\.[0-5]|text\/(?:jscript|livescript))$/i;
|
|
47
|
+
const runsAsScript = (attrs) => {
|
|
48
|
+
const type = attr(attrs, 'type')?.trim();
|
|
49
|
+
// a nomodule script is for browsers without modules: every browser that runs the page's modules skips it
|
|
50
|
+
return (type === undefined || type === '' || JS_TYPES.test(type)) && attr(attrs, 'nomodule') === undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sum every byte of JS and CSS a page causes the browser to load.
|
|
55
|
+
* @param {string} distDir @param {string} htmlPath @param {MeasureOptions} [options] @returns {PageCost}
|
|
56
|
+
*/
|
|
57
|
+
export function measurePage(distDir, htmlPath, options = {}) {
|
|
58
|
+
const html = readFileSync(htmlPath, 'utf8');
|
|
59
|
+
// a script in an HTML comment never runs
|
|
60
|
+
const live = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
61
|
+
const page = '/' + relative(distDir, htmlPath).split(sep).join('/');
|
|
62
|
+
const base = options.base && options.base !== '/' ? `/${options.base.replace(/^\/+|\/+$/g, '')}` : '';
|
|
63
|
+
/** @type {Asset[]} */
|
|
64
|
+
const assets = [];
|
|
65
|
+
const external = [];
|
|
66
|
+
const missing = [];
|
|
67
|
+
/** A file of the build: its text when it is a script (to follow its imports), and its size. */
|
|
68
|
+
const read = (file, text) => {
|
|
69
|
+
const hit = options.files?.get(file);
|
|
70
|
+
if (hit && (hit.text !== undefined || !text)) return hit;
|
|
71
|
+
const entry = text ? { text: readFileSync(file, 'utf8') } : {};
|
|
72
|
+
entry.size = bytes(entry.text ?? readFileSync(file));
|
|
73
|
+
options.files?.set(file, entry);
|
|
74
|
+
return entry;
|
|
75
|
+
};
|
|
76
|
+
const locate = (ref) => join(distDir, base && (ref === base || ref.startsWith(`${base}/`)) ? ref.slice(base.length) || '/' : ref);
|
|
77
|
+
const lost = (ref, from, kind) => {
|
|
78
|
+
if (options.missing !== 'collect') throw new Error(`Referenced ${kind} missing: ${ref} (from ${from})`);
|
|
79
|
+
missing.push({ ref, from });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Follow static and dynamic imports so shared chunks (e.g. the runtime, split out once two pages use it) are
|
|
83
|
+
// attributed to every page that loads them.
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const addChunk = (spec, kind, importer = page) => {
|
|
86
|
+
if (isExternal(spec)) {
|
|
87
|
+
if (!external.includes(spec)) external.push(spec);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Chunks import each other with relative specifiers; HTML uses root-relative ones.
|
|
91
|
+
const ref = spec.startsWith('/') ? spec : posix.join(posix.dirname(importer), spec);
|
|
92
|
+
if (seen.has(ref)) return;
|
|
93
|
+
seen.add(ref);
|
|
94
|
+
const file = locate(ref);
|
|
95
|
+
if (!existsSync(file)) return lost(ref, importer, kind);
|
|
96
|
+
const { text, size } = read(file, true);
|
|
97
|
+
assets.push({ kind, ref, shared: options.own ? !options.own.test(ref) : isShared(ref), ...size, file });
|
|
98
|
+
for (const dep of importsOf(text)) addChunk(dep, 'script', ref);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const m of live.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
102
|
+
const attrs = m[1] ?? '';
|
|
103
|
+
if (!runsAsScript(attrs)) continue;
|
|
104
|
+
const src = attr(attrs, 'src');
|
|
105
|
+
if (src) {
|
|
106
|
+
addChunk(src, 'script');
|
|
107
|
+
} else if (m[2]?.trim()) {
|
|
108
|
+
// Classic (non-module) inline scripts come from integration head-inline
|
|
109
|
+
// injections such as the data-ma-js gate: a site-wide constant, not a component's cost.
|
|
110
|
+
const isModule = attr(attrs, 'type')?.trim().toLowerCase() === 'module';
|
|
111
|
+
assets.push({ kind: 'inline-script', ref: `inline#${assets.length}${isModule ? '' : ':classic'}`, shared: !isModule, ...bytes(m[2]) });
|
|
112
|
+
for (const dep of importsOf(m[2])) addChunk(dep, 'script');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Islands load their component and renderer through attributes on <astro-island>, not <script>
|
|
116
|
+
// tags; the browser fetches them all the same, so they count (React, ReactDOM, the framework
|
|
117
|
+
// runtime and the component itself arrive this way).
|
|
118
|
+
for (const m of live.matchAll(/<astro-island\b([^>]*)>/g)) {
|
|
119
|
+
for (const key of ['component-url', 'renderer-url', 'before-hydration-url']) {
|
|
120
|
+
const url = attr(m[1] ?? '', key);
|
|
121
|
+
if (url) addChunk(url, 'script');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const m of live.matchAll(/<link\b([^>]*)>/gi)) {
|
|
125
|
+
const attrs = m[1] ?? '';
|
|
126
|
+
const rel = (attr(attrs, 'rel') ?? '').toLowerCase().split(/\s+/);
|
|
127
|
+
const href = attr(attrs, 'href');
|
|
128
|
+
if (!href) continue;
|
|
129
|
+
if (rel.includes('modulepreload')) addChunk(href, 'modulepreload');
|
|
130
|
+
if (rel.includes('stylesheet')) {
|
|
131
|
+
if (isExternal(href)) {
|
|
132
|
+
if (!external.includes(href)) external.push(href);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const file = locate(href);
|
|
136
|
+
if (!existsSync(file)) {
|
|
137
|
+
lost(href, page, 'stylesheet');
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
assets.push({ kind: 'stylesheet', ref: href, shared: false, ...read(file, false).size, file });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const m of live.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
144
|
+
if (m[1]?.trim()) assets.push({ kind: 'inline-style', ref: `inline#${assets.length}`, shared: false, ...bytes(m[1]) });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sum = (kinds, ownOnly = false) =>
|
|
148
|
+
assets
|
|
149
|
+
.filter((a) => kinds.includes(a.kind) && (!ownOnly || !a.shared))
|
|
150
|
+
.reduce((acc, a) => ({ raw: acc.raw + a.raw, gzip: acc.gzip + a.gzip }), { raw: 0, gzip: 0 });
|
|
151
|
+
|
|
152
|
+
const JS = ['inline-script', 'script', 'modulepreload'];
|
|
153
|
+
return { page: htmlPath, js: sum(JS), jsOwn: sum(JS, true), css: sum(['inline-style', 'stylesheet']), assets, html, external, missing };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Root-relative module specifiers a script body imports, statically or dynamically. */
|
|
157
|
+
export function importsOf(body) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const m of body.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)["']((?:\.{0,2}\/)[^"']+\.m?js)["']/g)) out.push(m[1]);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The name Rollup gives a chunk: `Reveal.astro_astro_type_script_index_0_lang.V9DYN4Ia.js` is Reveal, `runtime.B9PHC9Fq.js` is runtime. */
|
|
164
|
+
export const chunkName = (ref) => ref.split('/').pop().replace(/\.astro_astro_type_script.*$/, '').replace(/\.[\w-]+\.m?js$/, '');
|
|
165
|
+
|
|
166
|
+
// A framework's runtime, by what its bundle contains: Rollup names a renderer chunk `client.<hash>.js` whatever the
|
|
167
|
+
// framework, so the name says nothing.
|
|
168
|
+
const FRAMEWORKS = [
|
|
169
|
+
['React', /react-dom|\breact\.(?:transitional\.)?element\b/],
|
|
170
|
+
['Preact', /\bpreact\b/],
|
|
171
|
+
['Vue', /__vue_app__|__VUE_/],
|
|
172
|
+
['Svelte', /\bsvelte\b/],
|
|
173
|
+
['Solid', /\bsolid-js\b|_\$HY\b/],
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
177
|
+
function* pagesIn(dir) {
|
|
178
|
+
for (const f of readdirSync(dir).sort()) {
|
|
179
|
+
const p = join(dir, f);
|
|
180
|
+
if (statSync(p).isDirectory()) yield* pagesIn(p);
|
|
181
|
+
else if (f.endsWith('.html')) yield p;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The URL a page answers at: index.html is /, about/index.html is /about/, 404.html is /404. */
|
|
186
|
+
const routeOf = (root, file) => {
|
|
187
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
188
|
+
if (rel === 'index.html') return '/';
|
|
189
|
+
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
|
|
190
|
+
return `/${rel.replace(/\.html$/, '')}`;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every prerendered page of a build. A server build (dist/client beside dist/server) is measured in client/, the half
|
|
195
|
+
* the browser downloads; a route rendered on demand has no HTML file there, so it is not in the result.
|
|
196
|
+
* @param {string} dir the folder astro build wrote
|
|
197
|
+
* @param {{ ceiling?: number, base?: string, library?: Set<string> }} [options] `library`: chunk names that are Moonarc's
|
|
198
|
+
*/
|
|
199
|
+
export function measureDist(dir, { ceiling, base, library = new Set() } = {}) {
|
|
200
|
+
if (!isDir(dir)) throw new Error(`No such folder: ${dir}. Build first (astro build), then pass the folder it wrote.`);
|
|
201
|
+
const adapter = isDir(join(dir, 'client')) && isDir(join(dir, 'server'));
|
|
202
|
+
const root = adapter ? join(dir, 'client') : dir;
|
|
203
|
+
const files = [...pagesIn(root)];
|
|
204
|
+
if (files.length === 0) throw new Error(`No HTML pages in ${root}. Pass the folder astro build wrote: dist, which holds client/ and server/ after a server build.`);
|
|
205
|
+
|
|
206
|
+
const cache = new Map();
|
|
207
|
+
const found = new Map();
|
|
208
|
+
const frameworksOf = (file) => {
|
|
209
|
+
if (!found.has(file)) found.set(file, FRAMEWORKS.filter(([, re]) => re.test(cache.get(file)?.text ?? '')).map(([name]) => name));
|
|
210
|
+
return found.get(file);
|
|
211
|
+
};
|
|
212
|
+
const missing = [];
|
|
213
|
+
const routes = files.map((file) => {
|
|
214
|
+
const cost = measurePage(root, file, { base, missing: 'collect', files: cache });
|
|
215
|
+
const route = routeOf(root, file);
|
|
216
|
+
for (const m of cost.missing) missing.push({ ...m, route });
|
|
217
|
+
const js = cost.assets.filter((a) => a.kind !== 'inline-style' && a.kind !== 'stylesheet');
|
|
218
|
+
const share = (test) => js.filter((a) => test(a.ref)).reduce((n, a) => n + a.raw, 0);
|
|
219
|
+
const frameworks = [...new Set(js.filter((a) => a.file).flatMap((a) => frameworksOf(a.file)))];
|
|
220
|
+
return {
|
|
221
|
+
route,
|
|
222
|
+
total: cost.js,
|
|
223
|
+
css: cost.css,
|
|
224
|
+
router: share((ref) => /(^|\/)ClientRouter\.astro_astro_type_script/.test(ref)),
|
|
225
|
+
lib: share((ref) => !ref.startsWith('inline#') && library.has(chunkName(ref))),
|
|
226
|
+
islands: [...cost.html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<astro-island\b/g)].length,
|
|
227
|
+
frameworks,
|
|
228
|
+
external: cost.external,
|
|
229
|
+
assets: cost.assets,
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (missing.length) {
|
|
234
|
+
const refs = [...new Set(missing.map((m) => m.ref))];
|
|
235
|
+
throw new Error(`${refs.length === 1 ? 'A file the pages load is' : `${refs.length} files the pages load are`} not in ${root}: ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ', …' : ''} (first on ${missing[0].route}). ${baseHint(root, refs[0], base) ?? 'Build again, and pass the folder astro build wrote.'}`);
|
|
236
|
+
}
|
|
237
|
+
routes.sort((a, b) => b.total.raw - a.total.raw || a.route.localeCompare(b.route));
|
|
238
|
+
const over = ceiling != null ? routes.filter((r) => r.total.raw > ceiling).map((r) => r.route) : [];
|
|
239
|
+
return { dist: root, layout: adapter ? 'server' : 'static', ceiling: ceiling ?? null, base: base ?? null, routes, over, external: [...new Set(routes.flatMap((r) => r.external))] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A site built with `base: '/docs'` loads /docs/_astro/x.js from dist/_astro/x.js: say so when that is what is missing. */
|
|
243
|
+
function baseHint(root, ref, base) {
|
|
244
|
+
if (base || !ref.startsWith('/')) return null;
|
|
245
|
+
const parts = ref.split('/').filter(Boolean);
|
|
246
|
+
for (let i = 1; i < parts.length; i++) {
|
|
247
|
+
if (existsSync(join(root, ...parts.slice(i)))) {
|
|
248
|
+
const guess = `/${parts.slice(0, i).join('/')}`;
|
|
249
|
+
return `The site looks built with base "${guess}": pass it as base (--base ${guess} on the command line).`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const fmt = (b) => (b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} kB`);
|
|
256
|
+
|
|
257
|
+
export function formatMeasure(r) {
|
|
258
|
+
const lines = r.routes.map((x) => {
|
|
259
|
+
const flags = [
|
|
260
|
+
x.islands ? `⚠ ${x.islands} island${x.islands === 1 ? '' : 's'}${x.frameworks.length ? ` (${x.frameworks.join(', ')})` : ''}` : x.frameworks.length ? `⚠ ${x.frameworks.join(', ')} runtime` : '',
|
|
261
|
+
x.external.length ? `+ ${x.external.length} from another origin, not measured` : '',
|
|
262
|
+
].filter(Boolean);
|
|
263
|
+
return ` ${x.total.raw > (r.ceiling ?? Infinity) ? '✗' : '✓'} ${x.route.padEnd(36)} ${fmt(x.total.raw).padStart(8)} raw ${fmt(x.total.gzip).padStart(8)} gzip router ${fmt(x.router)} raw moonarc ${fmt(x.lib)} raw${flags.length ? ` ${flags.join(' ')}` : ''}`;
|
|
264
|
+
});
|
|
265
|
+
const head = `moonarc measure · ${r.routes.length} routes${r.ceiling != null ? ` · ceiling ${fmt(r.ceiling)} raw` : ''}${r.over.length ? ` · ${r.over.length} over` : ''}`;
|
|
266
|
+
const where = ` ${r.dist}${r.layout === 'server' ? ': the client half of a server build (a route rendered on demand has no HTML here)' : ''}${r.base ? `, base ${r.base}` : ''}`;
|
|
267
|
+
const external = r.external.length ? [` not measured, from another origin: ${r.external.join(', ')}`] : [];
|
|
268
|
+
return [head, where, ...lines, ...external].join('\n');
|
|
269
|
+
}
|