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

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 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.
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.
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,35 +36,18 @@ 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[];
52
39
  }
53
40
  export interface LocalImageData {
54
41
  astro?: {
55
42
  localImagePaths?: Set<string> | readonly string[];
56
43
  };
57
- /** Sätteri's per-document MDX picture state, created on first use. */
58
- hagilightContentPicture?: MdxPictureScope;
59
44
  }
60
45
  export interface SatteriContentImageContext {
61
46
  readonly sourceFormat: string;
62
- readonly fileURL?: URL | undefined;
63
47
  readonly data: LocalImageData;
64
48
  parent(node: unknown): unknown;
65
49
  setProperty(node: unknown, key: string, value: unknown): void;
66
50
  replaceNode(node: unknown, newNode: ContentImageNode): void;
67
- prependChild(node: unknown, childNode: ContentImageNode | ContentImageNode[]): void;
68
51
  }
69
52
  export interface SatteriContentImageVisitor {
70
53
  filter: string[];
@@ -78,8 +61,6 @@ export interface SatteriContentImagePlugin {
78
61
  }
79
62
  export interface PictureStep {
80
63
  enabled: boolean;
81
- /** Filesystem path of the project's `publicDir`. Images that resolve into it keep their markup. */
82
- publicDir?: string;
83
64
  }
84
65
  export interface ContentImageRegistrationInput {
85
66
  config: {
@@ -91,7 +72,6 @@ export interface ContentImageRegistrationInput {
91
72
  entrypoint?: unknown;
92
73
  };
93
74
  };
94
- publicDir?: unknown;
95
75
  };
96
76
  logger: {
97
77
  warn(message: string): void;
@@ -1,5 +1,3 @@
1
- import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
2
- import { fileURLToPath } from 'node:url';
3
1
  export const CONTENT_IMAGE_CLASS = 'hagilight-content-image';
4
2
  function isRecord(value) {
5
3
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -72,7 +70,7 @@ function isMdxJsx(node) {
72
70
  }
73
71
  function isPicture(node) {
74
72
  return (node.type === 'element' && node.tagName === 'picture')
75
- || (isMdxJsx(node) && (node.name === 'picture' || node.name === 'Picture'));
73
+ || (isMdxJsx(node) && node.name === 'picture');
76
74
  }
77
75
  function upsertMdxAttribute(attributes, name, value) {
78
76
  const existing = attributes.find((attribute) => attribute.name === name);
@@ -111,20 +109,19 @@ function applyUnifiedTree(node, options, inPicture) {
111
109
  for (const child of node.children ?? [])
112
110
  applyUnifiedTree(child, options, childrenInPicture);
113
111
  }
114
- /** Replaces each `img` outside a picture for which `replace` returns a node. */
115
- function replaceUnifiedImages(node, inPicture, replace) {
112
+ function wrapUnifiedPictures(node, inPicture, srcOf, nextIndex) {
116
113
  const children = node.children;
117
114
  if (!children)
118
115
  return;
119
116
  children.forEach((child, index) => {
120
117
  if (!inPicture && child.type === 'element' && child.tagName === 'img') {
121
- const replacement = replace(child);
122
- if (replacement !== undefined) {
123
- children[index] = replacement;
118
+ const src = srcOf(child);
119
+ if (src !== undefined) {
120
+ children[index] = pictureElement(child, src, nextIndex());
124
121
  return;
125
122
  }
126
123
  }
127
- replaceUnifiedImages(child, inPicture || isPicture(child), replace);
124
+ wrapUnifiedPictures(child, inPicture || isPicture(child), srcOf, nextIndex);
128
125
  });
129
126
  }
130
127
  /** Unified rehype plugin for sites whose `markdown.processor` is `unified()`. */
@@ -133,28 +130,10 @@ export function rehypeContentImages(options = {}, picture = { enabled: true }) {
133
130
  let pictureCount = 0;
134
131
  return (tree, file) => {
135
132
  applyUnifiedTree(tree, resolved, false);
136
- const format = documentFormat(file?.path);
137
- if (!picture.enabled || format === undefined)
133
+ if (!picture.enabled || !String(file?.path ?? '').endsWith('.md'))
138
134
  return;
139
135
  const astro = file?.data?.astro;
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);
136
+ wrapUnifiedPictures(tree, false, (img) => localImageSrc(img.properties?.src, astro), () => ++pictureCount);
158
137
  };
159
138
  }
160
139
  function isInsidePicture(node, ctx) {
@@ -165,12 +144,7 @@ function isInsidePicture(node, ctx) {
165
144
  return false;
166
145
  }
167
146
  const PICTURE_FORMATS = ['avif', 'webp'];
168
- const PICTURE_FALLBACK_FORMAT = 'webp';
169
- const PICTURE_COMPONENT = 'HagilightContentPicture';
170
- const PICTURE_COMPONENT_SOURCE = 'astro:assets';
171
147
  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;
174
148
  function localImageSrc(value, astro) {
175
149
  const localPaths = astro?.localImagePaths;
176
150
  if (typeof value !== 'string' || localPaths === undefined)
@@ -179,59 +153,6 @@ function localImageSrc(value, astro) {
179
153
  const isLocal = localPaths instanceof Set ? localPaths.has(src) : localPaths.includes(src);
180
154
  return isLocal ? src : undefined;
181
155
  }
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
- }
235
156
  function pictureElement(img, src, index) {
236
157
  const sources = PICTURE_FORMATS.map((format) => ({
237
158
  type: 'element',
@@ -249,106 +170,6 @@ function pictureElement(img, src, index) {
249
170
  }));
250
171
  return { type: 'element', tagName: 'picture', properties: {}, children: [...sources, img] };
251
172
  }
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
- }
352
173
  /** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
353
174
  export function satteriContentImages(options = {}, picture = { enabled: true }) {
354
175
  const resolved = resolveContentImageOptions(options);
@@ -374,19 +195,6 @@ export function satteriContentImages(options = {}, picture = { enabled: true })
374
195
  visit(node, ctx) {
375
196
  if (isInsidePicture(node, ctx))
376
197
  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
- }
390
198
  const patch = hastImagePatch(node.properties ?? {}, resolved);
391
199
  if (patch.loading !== undefined)
392
200
  ctx.setProperty(node, 'loading', patch.loading);
@@ -394,8 +202,10 @@ export function satteriContentImages(options = {}, picture = { enabled: true })
394
202
  ctx.setProperty(node, 'decoding', patch.decoding);
395
203
  if (patch.classNames !== undefined)
396
204
  ctx.setProperty(node, 'className', patch.classNames);
397
- if (src !== undefined && ctx.sourceFormat === 'markdown') {
398
- ctx.replaceNode(node, pictureElement(node, src, ++pictureCount));
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));
399
209
  }
400
210
  },
401
211
  },
@@ -458,9 +268,6 @@ export function registerContentImages({ config, logger, enabled }) {
458
268
  return;
459
269
  }
460
270
  const picture = { enabled: true };
461
- const publicDir = toFilePath(config.publicDir);
462
- if (publicDir !== undefined)
463
- picture.publicDir = publicDir;
464
271
  const plugin = processor.name === 'satteri'
465
272
  ? satteriContentImages({}, picture)
466
273
  : (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.100.1.8af2d7a",
3
+ "version": "0.9.1-dev.98.1.e071fc7",
4
4
  "type": "module",
5
5
  "description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
6
6
  "repository": {