@purview-dev/astroc4 1.0.0-prerelease.2 → 1.0.0-prerelease.4

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.
@@ -1,410 +0,0 @@
1
- // source.mjs
2
- // path: src/grammar/source.mjs
3
- // Resolves the LikeC4 grammar the site asked for: the Langium grammar from
4
- // likec4/likec4 at a branch, tag or commit, cached on disk, reduced to a
5
- // TextMate grammar by ./textmate.mjs.
6
- //
7
- // This runs from the site's `astro:config:setup`, so `astro dev`, `astro
8
- // build`, `astro check` and `astro sync` all go through it — a bad ref or an
9
- // unreadable grammar fails the command instead of quietly shipping
10
- // unhighlighted code.
11
- //
12
- // Network policy: strict by default (`onError: 'fail'`). A *bad ref* is user
13
- // error and always fails. A *network failure* may fall back to the cache from
14
- // the last successful fetch, and then to the bundled snapshot, but only when
15
- // `onError` says so.
16
-
17
- import { createHash } from 'node:crypto';
18
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
19
- import { join } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
-
22
- import { parseLangium } from './langium.mjs';
23
- import { buildTmLanguage } from './textmate.mjs';
24
-
25
- /** Upstream repository and the paths the grammar is read from. */
26
- export const DEFAULT_REPO = 'likec4/likec4';
27
- export const DEFAULT_REF = 'main';
28
- export const LANGIUM_PATH = 'packages/language-server/src/like-c4.langium';
29
- export const DEFAULT_BASE_URL = 'https://raw.githubusercontent.com';
30
-
31
- /** Shape of a cache file. Bump when the fields change. */
32
- const CACHE_VERSION = 1;
33
-
34
- const BUNDLED_GRAMMAR = new URL('./likec4.tmLanguage.json', import.meta.url);
35
- const BUNDLED_LANGIUM = new URL('./like-c4.langium', import.meta.url);
36
-
37
- /** The refs a repository actually has: branches, tags, `a1b2c3d`. */
38
- const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
39
-
40
- /** @typedef {'fail' | 'warn' | 'silent'} OnError */
41
-
42
- /**
43
- * @typedef {object} GrammarSourceOptions
44
- * @property {string} [repo] Repository to read from. Defaults to likec4/likec4.
45
- * @property {string} [ref] Branch, tag or commit SHA. Defaults to `main`.
46
- * @property {boolean} [offline] Use the bundled snapshot, never the network.
47
- * @property {OnError} [onError] What to do when the grammar cannot be fetched.
48
- * @property {number} [cacheTtl] Seconds a cached fetch stays fresh.
49
- * @property {boolean} [refresh] Ignore the cache and refetch.
50
- * @property {string} [baseUrl] Raw file host, for mirrors and proxies.
51
- * @property {number} [timeout] Request timeout in milliseconds.
52
- * @property {string} [root] Site root, for the default cache directory.
53
- * @property {string} [cacheDir] Where to cache, overriding the default.
54
- * @property {NodeJS.ProcessEnv} [env] Environment, for `ASTROC4_*` overrides.
55
- * @property {{ info?: Function, warn?: Function }} [logger] Astro's logger.
56
- * @property {typeof fetch} [fetchImpl] Injected in tests.
57
- * @property {string} [bundledLangiumPath] Bundled Langium text, for tests.
58
- */
59
-
60
- /**
61
- * @typedef {object} GrammarMeta
62
- * @property {string} repo
63
- * @property {string} ref
64
- * @property {'network' | 'cache' | 'bundled'} source Where it came from.
65
- * @property {number} fetchedAt
66
- * @property {string} [etag]
67
- * @property {string} sha Content hash of the Langium source.
68
- * @property {string} [reason] Why the bundled snapshot was used.
69
- */
70
-
71
- /**
72
- * @typedef {object} GrammarSource
73
- * @property {Record<string, unknown>} grammar The TextMate grammar for Shiki.
74
- * @property {string} langium The Langium source it was derived from.
75
- * @property {GrammarMeta} meta Where it came from, for logs and tests.
76
- */
77
-
78
- /** Raised for a ref this repository cannot have — always a user error. */
79
- export class GrammarRefError extends Error {
80
- name = 'GrammarRefError';
81
- }
82
-
83
- /** Raised when the grammar could not be fetched, or came back unusable. */
84
- export class GrammarFetchError extends Error {
85
- name = 'GrammarFetchError';
86
- }
87
-
88
- /**
89
- * @param {GrammarSourceOptions} [options]
90
- * @returns {Promise<GrammarSource>}
91
- */
92
- export async function resolveLikeC4Grammar(options = {}) {
93
- const env = options.env ?? process.env;
94
- const repo = options.repo ?? DEFAULT_REPO;
95
- const ref = env.ASTROC4_REF || options.ref || DEFAULT_REF;
96
- const offline = options.offline ?? isTruthy(env.ASTROC4_OFFLINE);
97
- const onError = options.onError ?? 'fail';
98
- const logger = options.logger;
99
-
100
- validateRef(ref);
101
-
102
- if (offline) {
103
- return bundled('offline: true', options);
104
- }
105
-
106
- const cache = await readCache(options, { repo, ref });
107
-
108
- if (cache?.fresh && !options.refresh) {
109
- return derive(cache.langium, { repo, ref, source: 'cache', fetchedAt: cache.fetchedAt, etag: cache.etag });
110
- }
111
-
112
- let fetched;
113
-
114
- try {
115
- fetched = await fetchLangiumSource({ ...options, repo, ref, etag: cache?.etag });
116
- } catch (error) {
117
- if (!(error instanceof Error)) throw error;
118
-
119
- // A bad ref will fail the same way forever: never paper over it.
120
- if (error instanceof GrammarRefError) throw error;
121
-
122
- if (onError === 'fail') throw error;
123
-
124
- if (onError === 'warn') logger?.warn?.(`[astroc4] ${error.message}`);
125
-
126
- if (cache) {
127
- if (onError === 'warn') {
128
- logger?.warn?.(`[astroc4] using the grammar cached at ${new Date(cache.fetchedAt).toISOString()}`);
129
- }
130
-
131
- return derive(cache.langium, {
132
- repo,
133
- ref,
134
- source: 'cache',
135
- fetchedAt: cache.fetchedAt,
136
- etag: cache.etag,
137
- });
138
- }
139
-
140
- return bundled(error.message, options);
141
- }
142
-
143
- if ('notModified' in fetched) {
144
- if (!cache) {
145
- throw new GrammarFetchError(
146
- 'the grammar host answered 304 for a grammar that is not cached — retry with `refresh: true`',
147
- );
148
- }
149
-
150
- await writeCache(options, { repo, ref }, { ...cache, fresh: true });
151
-
152
- return derive(cache.langium, { repo, ref, source: 'cache', fetchedAt: cache.fetchedAt, etag: cache.etag });
153
- }
154
-
155
- const result = derive(fetched.text, { repo, ref, source: 'network', etag: fetched.etag });
156
- await writeCache(options, { repo, ref }, { ...result, langium: fetched.text, etag: fetched.etag });
157
-
158
- return result;
159
- }
160
-
161
- /**
162
- * @param {string} langium
163
- * @param {{ repo: string, ref: string, source: 'network' | 'cache' | 'bundled', fetchedAt?: number, etag?: string }} info
164
- * @returns {GrammarSource}
165
- */
166
- function derive(langium, info) {
167
- let vocabulary;
168
-
169
- try {
170
- vocabulary = parseLangium(langium);
171
- } catch (cause) {
172
- throw new GrammarFetchError(
173
- `${info.repo}@${info.ref}: the grammar could not be read — ${cause instanceof Error ? cause.message : String(cause)}`,
174
- { cause },
175
- );
176
- }
177
-
178
- const sha = grammarSha(langium);
179
-
180
- return {
181
- grammar: buildTmLanguage(vocabulary, { repo: info.repo, ref: info.ref, sha }),
182
- langium,
183
- meta: {
184
- repo: info.repo,
185
- ref: info.ref,
186
- source: info.source,
187
- fetchedAt: info.fetchedAt ?? Date.now(),
188
- etag: info.etag,
189
- sha,
190
- },
191
- };
192
- }
193
-
194
- /**
195
- * The snapshot shipped with this package, for `offline: true` and for falling
196
- * back when the network is down and `onError` allows it. It is derived from
197
- * the committed `like-c4.langium`, so it matches `likec4.tmLanguage.json` (the
198
- * snapshot test asserts exactly that). Its provenance is the snapshot's own,
199
- * not the ref that was asked for.
200
- * @param {string} reason Why it is being used, surfaced in logs.
201
- * @param {GrammarSourceOptions} options
202
- * @returns {Promise<GrammarSource>}
203
- */
204
- async function bundled(reason, options) {
205
- const path = options.bundledLangiumPath ?? fileURLToPath(BUNDLED_LANGIUM);
206
- const snapshot = await bundledProvenance();
207
-
208
- const source = derive(await readFile(path, 'utf8'), {
209
- repo: snapshot.repo ?? DEFAULT_REPO,
210
- ref: snapshot.ref ?? DEFAULT_REF,
211
- source: 'bundled',
212
- });
213
-
214
- source.meta.reason = reason;
215
-
216
- return source;
217
- }
218
-
219
- /** `$generated` from the shipped grammar, so logs name the real snapshot. */
220
- async function bundledProvenance() {
221
- try {
222
- const { $generated } = JSON.parse(await readFile(fileURLToPath(BUNDLED_GRAMMAR), 'utf8'));
223
-
224
- return { repo: $generated?.repo, ref: $generated?.ref };
225
- } catch {
226
- return {};
227
- }
228
- }
229
-
230
- /** Short content hash — the identity that actually changes highlighting. */
231
- export function grammarSha(source) {
232
- return createHash('sha256').update(source).digest('hex').slice(0, 12);
233
- }
234
-
235
- // region refs
236
-
237
- /**
238
- * Rejects anything that cannot name a ref, before it reaches the network.
239
- * @param {string} ref
240
- */
241
- export function validateRef(ref) {
242
- if (typeof ref !== 'string' || ref.length === 0) {
243
- throw new GrammarRefError('the LikeC4 grammar `ref` must be a branch, tag or commit SHA');
244
- }
245
-
246
- if (!REF_PATTERN.test(ref) || ref.includes('..') || ref.endsWith('/') || ref.includes('//')) {
247
- throw new GrammarRefError(
248
- `"${ref}" is not a usable LikeC4 grammar ref — expected a branch, tag or commit SHA such as "main" or "v1.59.4"`,
249
- );
250
- }
251
-
252
- return ref;
253
- }
254
-
255
-
256
- // endregion
257
-
258
- // region fetch
259
-
260
- /**
261
- * @param {GrammarSourceOptions & { repo: string, ref: string, etag?: string }} options
262
- * @returns {Promise<{ text: string, etag?: string } | { notModified: true }>}
263
- */
264
- export async function fetchLangiumSource(options) {
265
- const repo = options.repo;
266
- const ref = options.ref;
267
- const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
268
- const url = `${baseUrl}/${repo}/${ref}/${LANGIUM_PATH}`;
269
- const timeout = options.timeout ?? 15_000;
270
- const request = options.fetchImpl ?? fetch;
271
-
272
- /** @type {Record<string, string>} */
273
- const headers = { 'user-agent': 'astroc4', accept: 'text/plain' };
274
- if (options.etag) headers['if-none-match'] = options.etag;
275
-
276
- let response;
277
-
278
- try {
279
- response = await request(url, { headers, redirect: 'follow', signal: AbortSignal.timeout(timeout) });
280
- } catch (cause) {
281
- throw new GrammarFetchError(
282
- `could not fetch the LikeC4 grammar from ${url} — ${cause instanceof Error ? cause.message : String(cause)}. ` +
283
- 'Builds are strict by default: pass `highlighting: { offline: true }` to use the bundled grammar instead.',
284
- { cause },
285
- );
286
- }
287
-
288
- if (response.status === 304) return { notModified: true };
289
-
290
- if (response.status === 404) {
291
- throw new GrammarRefError(
292
- `${repo} has no "${ref}", or it no longer has ${LANGIUM_PATH} (${url}). ` +
293
- 'Use a tag such as "v1.59.4", a commit SHA, or "main".',
294
- );
295
- }
296
-
297
- if (!response.ok) {
298
- throw new GrammarFetchError(
299
- `${url} responded ${response.status} ${response.statusText} — set ` +
300
- '`highlighting: { onError: "warn" }` to fall back to the bundled grammar.',
301
- );
302
- }
303
-
304
- const text = await response.text();
305
-
306
- if (text.trim().length === 0) {
307
- throw new GrammarFetchError(`${url} returned an empty file — the grammar is not usable`);
308
- }
309
-
310
- return { text, etag: response.headers.get('etag') ?? undefined };
311
- }
312
-
313
- // endregion
314
-
315
- // region cache
316
-
317
- /**
318
- * Cache location: `node_modules/.cache/astroc4`, which every Astro project
319
- * already ignores. One file per repo@ref, so switching `ref` never thrashes.
320
- * @param {GrammarSourceOptions} options
321
- * @param {{ repo: string, ref: string }} identity
322
- */
323
- function cachePath(options, identity) {
324
- const dir =
325
- options.cacheDir ?? join(options.root ?? process.cwd(), 'node_modules', '.cache', 'astroc4');
326
- const key = createHash('sha256').update(`${identity.repo}@${identity.ref}`).digest('hex').slice(0, 16);
327
-
328
- return join(dir, `grammar-${key}.json`);
329
- }
330
-
331
- /**
332
- * @param {GrammarSourceOptions} options
333
- * @param {{ repo: string, ref: string }} identity
334
- * @returns {Promise<{ langium: string, etag?: string, fetchedAt: number, fresh: boolean } | undefined>}
335
- */
336
- async function readCache(options, identity) {
337
- let raw;
338
-
339
- try {
340
- raw = await readFile(cachePath(options, identity), 'utf8');
341
- } catch {
342
- return undefined;
343
- }
344
-
345
- try {
346
- const entry = JSON.parse(raw);
347
-
348
- if (
349
- entry?.version !== CACHE_VERSION ||
350
- entry.repo !== identity.repo ||
351
- entry.ref !== identity.ref ||
352
- typeof entry.langium !== 'string' ||
353
- typeof entry.fetchedAt !== 'number'
354
- ) {
355
- // A cache written by a different version of this package, or a
356
- // truncated file. Refetching is cheaper than debugging it.
357
- return undefined;
358
- }
359
-
360
- const ttl = options.cacheTtl ?? 3600;
361
-
362
- return { ...entry, fresh: Date.now() - entry.fetchedAt < ttl * 1000 };
363
- } catch {
364
- return undefined;
365
- }
366
- }
367
-
368
- /**
369
- * Best effort: a read-only `node_modules` must not fail a build, it only means
370
- * the next one has to fetch again.
371
- * @param {GrammarSourceOptions} options
372
- * @param {{ repo: string, ref: string }} identity
373
- * @param {{ langium: string, etag?: string }} entry
374
- */
375
- async function writeCache(options, identity, entry) {
376
- const path = cachePath(options, identity);
377
- const temporary = `${path}.${process.pid}.tmp`;
378
-
379
- try {
380
- await mkdir(join(path, '..'), { recursive: true });
381
- await writeFile(
382
- temporary,
383
- JSON.stringify({
384
- version: CACHE_VERSION,
385
- repo: identity.repo,
386
- ref: identity.ref,
387
- etag: entry.etag,
388
- fetchedAt: Date.now(),
389
- langium: entry.langium,
390
- }),
391
- 'utf8',
392
- );
393
- // Rename rather than write in place: two Astro processes can resolve the
394
- // same cache entry at once during `astro check && astro build`.
395
- await rename(temporary, path);
396
- } catch (error) {
397
- options.logger?.warn?.(
398
- `[astroc4] could not cache the LikeC4 grammar in ${path} — ${error instanceof Error ? error.message : String(error)}`,
399
- );
400
- }
401
- }
402
-
403
- // endregion
404
-
405
- function isTruthy(value) {
406
- return typeof value === 'string' && /^(1|true|yes|on)$/i.test(value.trim());
407
- }
408
-
409
- export default resolveLikeC4Grammar;
410
-