@hagicode/hagilight-core 0.9.1-dev.98.1.e071fc7 → 0.9.1

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/README.md CHANGED
@@ -47,7 +47,7 @@ const links = resolveSiteLinks('en-US');
47
47
 
48
48
  Images inside a `<picture>` element are skipped. An MDX `class={expression}` gets no marker because its value is not known at build time. Raw HTML `<img>` tags inside a `.md` file are not normalized; use Markdown image syntax or MDX for body images.
49
49
 
50
- Local Markdown (`.md`) body images that Astro resolves are also wrapped in a `<picture>` element, with an AVIF `<source>` and a WebP `<source>` ahead of the `<img>`. Astro generates the source URLs through its image service, so AVIF needs a service that can encode it. The default sharp service does. The passthrough no-op service (`astro/assets/services/noop`) does not, so the Astro integrations skip the wrapper there and log one warning. A plugin added by hand does not check the service, and its sources then point at the original bytes. Site-root, remote, raw HTML, and MDX images are not wrapped. Encoding adds build time, which Astro caches. Each `<source>` also carries `src`, `loading`, `decoding`, `width`, and `height` copied from Astro's output. Browsers ignore these on `source`, but they are not valid HTML there.
50
+ Local body images in `.md` and `.mdx` files that Astro resolves are also wrapped in a `<picture>` element, with an AVIF `<source>` and a WebP `<source>` ahead of the `<img>`. The rule is shared by both file types. An image is wrapped only when all of these hold: it is a relative path (`./` or `../`) that Astro collects as a local file; its extension is not `.svg` or `.gif`, ignoring case, query strings, and fragments; it does not resolve into the project's `public/` directory (`publicDir`), where it keeps its plain `<img>`; the step is enabled; and it is not already inside a `<picture>` or `Picture` the author wrote. A direct import of `rehypeContentImages` or `satteriContentImages` without registration has no `publicDir`, so it skips the `public/` check and wraps every other image that passes. In `.mdx`, the plugin imports Astro's `Picture` component from `astro:assets`, and the inner `<img>` carries the marker class, `loading`, and `decoding`. Site-root, remote, and raw HTML `<img>` images are not wrapped. Astro generates the source URLs through its image service, so AVIF needs a service that can encode it. The default sharp service does. The passthrough no-op service (`astro/assets/services/noop`) does not, so the Astro integrations skip the wrapper there and log one warning. A plugin added by hand does not check the service, and its sources then point at the original bytes. Encoding adds build time, which Astro caches. In `.md`, each `<source>` also carries `src`, `loading`, `decoding`, `width`, and `height` copied from Astro's output; in `.mdx`, `<source>` carries only `srcset` and `type`. Browsers ignore the extra attributes on `source`, but they are not valid HTML there.
51
51
 
52
52
  `hagilight()` from `@hagicode/hagilight-starlight` and `hagilight()` from `@hagicode/hagilight/integration` register the plugin automatically, for the whole site and regardless of integration order. Each one opts out through its `contentImages` option; see those packages' READMEs.
53
53
 
@@ -36,18 +36,35 @@ export interface ContentImageNode {
36
36
  properties?: Record<string, unknown>;
37
37
  attributes?: MdxAttributeLike[];
38
38
  children?: ContentImageNode[];
39
+ /** Source text of an `mdxjsEsm` node. */
40
+ value?: string;
41
+ /** ESTree data of an `mdxjsEsm` node, read by the unified MDX compiler. */
42
+ data?: unknown;
43
+ }
44
+ /** Per-document state for MDX picture output on the Sätteri processor. */
45
+ export interface MdxPictureScope {
46
+ /** Decoded `src` to the identifier its asset import binds. */
47
+ assets: Map<string, string>;
48
+ /** Whether the `Picture` component import has been queued for this document. */
49
+ component: boolean;
50
+ /** Import nodes not yet placed in the document. */
51
+ pending: ContentImageNode[];
39
52
  }
40
53
  export interface LocalImageData {
41
54
  astro?: {
42
55
  localImagePaths?: Set<string> | readonly string[];
43
56
  };
57
+ /** Sätteri's per-document MDX picture state, created on first use. */
58
+ hagilightContentPicture?: MdxPictureScope;
44
59
  }
45
60
  export interface SatteriContentImageContext {
46
61
  readonly sourceFormat: string;
62
+ readonly fileURL?: URL | undefined;
47
63
  readonly data: LocalImageData;
48
64
  parent(node: unknown): unknown;
49
65
  setProperty(node: unknown, key: string, value: unknown): void;
50
66
  replaceNode(node: unknown, newNode: ContentImageNode): void;
67
+ prependChild(node: unknown, childNode: ContentImageNode | ContentImageNode[]): void;
51
68
  }
52
69
  export interface SatteriContentImageVisitor {
53
70
  filter: string[];
@@ -61,6 +78,8 @@ export interface SatteriContentImagePlugin {
61
78
  }
62
79
  export interface PictureStep {
63
80
  enabled: boolean;
81
+ /** Filesystem path of the project's `publicDir`. Images that resolve into it keep their markup. */
82
+ publicDir?: string;
64
83
  }
65
84
  export interface ContentImageRegistrationInput {
66
85
  config: {
@@ -72,6 +91,7 @@ export interface ContentImageRegistrationInput {
72
91
  entrypoint?: unknown;
73
92
  };
74
93
  };
94
+ publicDir?: unknown;
75
95
  };
76
96
  logger: {
77
97
  warn(message: string): void;
@@ -1,3 +1,5 @@
1
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
1
3
  export const CONTENT_IMAGE_CLASS = 'hagilight-content-image';
2
4
  function isRecord(value) {
3
5
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -70,7 +72,7 @@ function isMdxJsx(node) {
70
72
  }
71
73
  function isPicture(node) {
72
74
  return (node.type === 'element' && node.tagName === 'picture')
73
- || (isMdxJsx(node) && node.name === 'picture');
75
+ || (isMdxJsx(node) && (node.name === 'picture' || node.name === 'Picture'));
74
76
  }
75
77
  function upsertMdxAttribute(attributes, name, value) {
76
78
  const existing = attributes.find((attribute) => attribute.name === name);
@@ -109,19 +111,20 @@ function applyUnifiedTree(node, options, inPicture) {
109
111
  for (const child of node.children ?? [])
110
112
  applyUnifiedTree(child, options, childrenInPicture);
111
113
  }
112
- function wrapUnifiedPictures(node, inPicture, srcOf, nextIndex) {
114
+ /** Replaces each `img` outside a picture for which `replace` returns a node. */
115
+ function replaceUnifiedImages(node, inPicture, replace) {
113
116
  const children = node.children;
114
117
  if (!children)
115
118
  return;
116
119
  children.forEach((child, index) => {
117
120
  if (!inPicture && child.type === 'element' && child.tagName === 'img') {
118
- const src = srcOf(child);
119
- if (src !== undefined) {
120
- children[index] = pictureElement(child, src, nextIndex());
121
+ const replacement = replace(child);
122
+ if (replacement !== undefined) {
123
+ children[index] = replacement;
121
124
  return;
122
125
  }
123
126
  }
124
- wrapUnifiedPictures(child, inPicture || isPicture(child), srcOf, nextIndex);
127
+ replaceUnifiedImages(child, inPicture || isPicture(child), replace);
125
128
  });
126
129
  }
127
130
  /** Unified rehype plugin for sites whose `markdown.processor` is `unified()`. */
@@ -130,10 +133,28 @@ export function rehypeContentImages(options = {}, picture = { enabled: true }) {
130
133
  let pictureCount = 0;
131
134
  return (tree, file) => {
132
135
  applyUnifiedTree(tree, resolved, false);
133
- if (!picture.enabled || !String(file?.path ?? '').endsWith('.md'))
136
+ const format = documentFormat(file?.path);
137
+ if (!picture.enabled || format === undefined)
134
138
  return;
135
139
  const astro = file?.data?.astro;
136
- wrapUnifiedPictures(tree, false, (img) => localImageSrc(img.properties?.src, astro), () => ++pictureCount);
140
+ const documentPath = toFilePath(file?.path);
141
+ const eligibleSrc = (img) => pictureSource(img.properties?.src, astro, picture, documentPath);
142
+ if (format === 'markdown') {
143
+ replaceUnifiedImages(tree, false, (img) => {
144
+ const src = eligibleSrc(img);
145
+ return src === undefined ? undefined : pictureElement(img, src, ++pictureCount);
146
+ });
147
+ return;
148
+ }
149
+ const scope = newMdxPictureScope();
150
+ replaceUnifiedImages(tree, false, (img) => {
151
+ const src = eligibleSrc(img);
152
+ if (src === undefined)
153
+ return undefined;
154
+ return pictureComponent(pictureProperties(img.properties ?? {}, resolved), assetIdentifier(scope, src, 'estree'), 'estree');
155
+ });
156
+ if (scope.pending.length > 0)
157
+ tree.children?.unshift(...scope.pending);
137
158
  };
138
159
  }
139
160
  function isInsidePicture(node, ctx) {
@@ -144,7 +165,12 @@ function isInsidePicture(node, ctx) {
144
165
  return false;
145
166
  }
146
167
  const PICTURE_FORMATS = ['avif', 'webp'];
168
+ const PICTURE_FALLBACK_FORMAT = 'webp';
169
+ const PICTURE_COMPONENT = 'HagilightContentPicture';
170
+ const PICTURE_COMPONENT_SOURCE = 'astro:assets';
147
171
  const NOOP_IMAGE_SERVICE = 'astro/assets/services/noop';
172
+ /** Extensions the picture step leaves alone: SVG is already vector, and a GIF would lose its animation. */
173
+ const VECTOR_OR_ANIMATED = /\.(?:svg|gif)$/iu;
148
174
  function localImageSrc(value, astro) {
149
175
  const localPaths = astro?.localImagePaths;
150
176
  if (typeof value !== 'string' || localPaths === undefined)
@@ -153,6 +179,59 @@ function localImageSrc(value, astro) {
153
179
  const isLocal = localPaths instanceof Set ? localPaths.has(src) : localPaths.includes(src);
154
180
  return isLocal ? src : undefined;
155
181
  }
182
+ /** A file URL or absolute path as a filesystem path; `undefined` for anything else. */
183
+ function toFilePath(value) {
184
+ const text = value instanceof URL ? value.href : value;
185
+ if (typeof text !== 'string')
186
+ return undefined;
187
+ if (text.startsWith('file:'))
188
+ return fileURLToPath(text);
189
+ return isAbsolute(text) ? text : undefined;
190
+ }
191
+ function documentFormat(filePath) {
192
+ const text = String(filePath ?? '');
193
+ if (text.endsWith('.mdx'))
194
+ return 'mdx';
195
+ if (text.endsWith('.md'))
196
+ return 'markdown';
197
+ return undefined;
198
+ }
199
+ function withoutQueryOrFragment(src) {
200
+ return src.replace(/[?#].*$/u, '');
201
+ }
202
+ /**
203
+ * The file a relative reference names, resolved against the document's directory.
204
+ * Site-root, bare and aliased specifiers, and a missing document location, resolve to `undefined`.
205
+ */
206
+ function resolveLocalFile(src, documentPath) {
207
+ if (documentPath === undefined)
208
+ return undefined;
209
+ const file = withoutQueryOrFragment(src);
210
+ if (!file.startsWith('./') && !file.startsWith('../'))
211
+ return undefined;
212
+ return resolve(dirname(documentPath), file);
213
+ }
214
+ function isInsideDirectory(directory, file) {
215
+ const offset = relative(directory, file);
216
+ return offset !== '..' && !offset.startsWith(`..${sep}`) && !isAbsolute(offset);
217
+ }
218
+ /**
219
+ * The decoded `src` when the picture step may wrap this image, otherwise `undefined`. One rule for
220
+ * `.md` and `.mdx`: collected by Astro as local, not SVG or GIF, not inside `publicDir`, step enabled.
221
+ * Without a document location or a `publicDir`, the `public/` check is skipped.
222
+ */
223
+ function pictureSource(value, astro, picture, documentPath) {
224
+ if (!picture.enabled)
225
+ return undefined;
226
+ const src = localImageSrc(value, astro);
227
+ if (src === undefined || VECTOR_OR_ANIMATED.test(withoutQueryOrFragment(src)))
228
+ return undefined;
229
+ const file = resolveLocalFile(src, documentPath);
230
+ if (picture.publicDir !== undefined && file !== undefined && isInsideDirectory(picture.publicDir, file)) {
231
+ return undefined;
232
+ }
233
+ return src;
234
+ }
156
235
  function pictureElement(img, src, index) {
157
236
  const sources = PICTURE_FORMATS.map((format) => ({
158
237
  type: 'element',
@@ -170,6 +249,106 @@ function pictureElement(img, src, index) {
170
249
  }));
171
250
  return { type: 'element', tagName: 'picture', properties: {}, children: [...sources, img] };
172
251
  }
252
+ function estreeProgram(body) {
253
+ return { type: 'Program', sourceType: 'module', comments: [], body };
254
+ }
255
+ function jsxAttributeNode(attribute, flavor) {
256
+ if ('text' in attribute)
257
+ return { type: 'mdxJsxAttribute', name: attribute.name, value: attribute.text };
258
+ const expression = 'identifier' in attribute ? attribute.identifier : JSON.stringify(attribute.list);
259
+ const value = { type: 'mdxJsxAttributeValueExpression', value: expression };
260
+ if (flavor === 'estree') {
261
+ const node = 'identifier' in attribute
262
+ ? { type: 'Identifier', name: attribute.identifier }
263
+ : {
264
+ type: 'ArrayExpression',
265
+ elements: attribute.list.map((item) => ({ type: 'Literal', value: item, raw: JSON.stringify(item) })),
266
+ };
267
+ value.data = { estree: estreeProgram([{ type: 'ExpressionStatement', expression: node }]) };
268
+ }
269
+ return { type: 'mdxJsxAttribute', name: attribute.name, value };
270
+ }
271
+ function importNode(flavor, text, declaration) {
272
+ if (flavor === 'source')
273
+ return { type: 'mdxjsEsm', value: text };
274
+ return { type: 'mdxjsEsm', value: '', data: { estree: estreeProgram([declaration]) } };
275
+ }
276
+ function newMdxPictureScope() {
277
+ return { assets: new Map(), component: false, pending: [] };
278
+ }
279
+ /** The identifier an asset import binds for `src`, queuing the imports the first time each is needed. */
280
+ function assetIdentifier(scope, src, flavor) {
281
+ const existing = scope.assets.get(src);
282
+ if (existing !== undefined)
283
+ return existing;
284
+ const name = `HagilightContentImage${scope.assets.size}`;
285
+ if (!scope.component) {
286
+ scope.component = true;
287
+ scope.pending.push(importNode(flavor, `import { Picture as ${PICTURE_COMPONENT} } from "${PICTURE_COMPONENT_SOURCE}";`, {
288
+ type: 'ImportDeclaration',
289
+ attributes: [],
290
+ source: { type: 'Literal', value: PICTURE_COMPONENT_SOURCE, raw: JSON.stringify(PICTURE_COMPONENT_SOURCE) },
291
+ specifiers: [{
292
+ type: 'ImportSpecifier',
293
+ imported: { type: 'Identifier', name: 'Picture' },
294
+ local: { type: 'Identifier', name: PICTURE_COMPONENT },
295
+ }],
296
+ }));
297
+ }
298
+ scope.pending.push(importNode(flavor, `import ${name} from ${JSON.stringify(src)};`, {
299
+ type: 'ImportDeclaration',
300
+ attributes: [],
301
+ source: { type: 'Literal', value: src, raw: JSON.stringify(src) },
302
+ specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name } }],
303
+ }));
304
+ scope.assets.set(src, name);
305
+ return name;
306
+ }
307
+ /** The image's properties after the baseline patch, with `alt` present, as `Picture` receives them. */
308
+ function pictureProperties(properties, options) {
309
+ const patch = hastImagePatch(properties, options);
310
+ const next = { ...properties };
311
+ if (patch.loading !== undefined)
312
+ next.loading = patch.loading;
313
+ if (patch.decoding !== undefined)
314
+ next.decoding = patch.decoding;
315
+ if (patch.classNames !== undefined)
316
+ next.className = patch.classNames;
317
+ if (next.alt === undefined || next.alt === null)
318
+ next.alt = '';
319
+ return next;
320
+ }
321
+ /** The `Picture` element an author would write by hand (D2), with the image's own attributes. */
322
+ function pictureComponent(properties, identifier, flavor) {
323
+ const attributes = [
324
+ { name: 'src', identifier },
325
+ { name: 'formats', list: [...PICTURE_FORMATS] },
326
+ { name: 'fallbackFormat', text: PICTURE_FALLBACK_FORMAT },
327
+ ];
328
+ for (const [key, value] of Object.entries(properties)) {
329
+ if (key === 'src' || value === undefined || value === null || value === false)
330
+ continue;
331
+ const name = key === 'className' ? 'class' : key === 'htmlFor' ? 'for' : key;
332
+ attributes.push({ name, text: Array.isArray(value) ? value.map(String).join(' ') : String(value) });
333
+ }
334
+ return {
335
+ type: 'mdxJsxFlowElement',
336
+ name: PICTURE_COMPONENT,
337
+ attributes: attributes.map((attribute) => jsxAttributeNode(attribute, flavor)),
338
+ children: [],
339
+ };
340
+ }
341
+ /** The root of the document, which holds the ESM imports a `Picture` needs. */
342
+ function documentRoot(node, ctx) {
343
+ let current = node;
344
+ for (let parent = ctx.parent(current); parent; parent = ctx.parent(current))
345
+ current = parent;
346
+ return current;
347
+ }
348
+ function mdxScope(data) {
349
+ data.hagilightContentPicture ??= newMdxPictureScope();
350
+ return data.hagilightContentPicture;
351
+ }
173
352
  /** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
174
353
  export function satteriContentImages(options = {}, picture = { enabled: true }) {
175
354
  const resolved = resolveContentImageOptions(options);
@@ -195,6 +374,19 @@ export function satteriContentImages(options = {}, picture = { enabled: true })
195
374
  visit(node, ctx) {
196
375
  if (isInsidePicture(node, ctx))
197
376
  return;
377
+ const isDocument = ctx.sourceFormat === 'markdown' || ctx.sourceFormat === 'mdx';
378
+ const src = isDocument
379
+ ? pictureSource(node.properties?.src, ctx.data.astro, picture, toFilePath(ctx.fileURL))
380
+ : undefined;
381
+ if (src !== undefined && ctx.sourceFormat === 'mdx') {
382
+ const root = documentRoot(node, ctx);
383
+ const scope = mdxScope(ctx.data);
384
+ const identifier = assetIdentifier(scope, src, 'source');
385
+ ctx.replaceNode(node, pictureComponent(pictureProperties(node.properties ?? {}, resolved), identifier, 'source'));
386
+ if (scope.pending.length > 0)
387
+ ctx.prependChild(root, scope.pending.splice(0));
388
+ return;
389
+ }
198
390
  const patch = hastImagePatch(node.properties ?? {}, resolved);
199
391
  if (patch.loading !== undefined)
200
392
  ctx.setProperty(node, 'loading', patch.loading);
@@ -202,10 +394,8 @@ export function satteriContentImages(options = {}, picture = { enabled: true })
202
394
  ctx.setProperty(node, 'decoding', patch.decoding);
203
395
  if (patch.classNames !== undefined)
204
396
  ctx.setProperty(node, 'className', patch.classNames);
205
- if (picture.enabled && ctx.sourceFormat === 'markdown') {
206
- const src = localImageSrc(node.properties?.src, ctx.data.astro);
207
- if (src !== undefined)
208
- ctx.replaceNode(node, pictureElement(node, src, ++pictureCount));
397
+ if (src !== undefined && ctx.sourceFormat === 'markdown') {
398
+ ctx.replaceNode(node, pictureElement(node, src, ++pictureCount));
209
399
  }
210
400
  },
211
401
  },
@@ -268,6 +458,9 @@ export function registerContentImages({ config, logger, enabled }) {
268
458
  return;
269
459
  }
270
460
  const picture = { enabled: true };
461
+ const publicDir = toFilePath(config.publicDir);
462
+ if (publicDir !== undefined)
463
+ picture.publicDir = publicDir;
271
464
  const plugin = processor.name === 'satteri'
272
465
  ? satteriContentImages({}, picture)
273
466
  : (options) => rehypeContentImages(options, picture);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-core",
3
- "version": "0.9.1-dev.98.1.e071fc7",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
6
6
  "repository": {