@hagicode/hagilight-core 0.8.1-dev.93.1.d28d470 → 0.8.1-dev.95.1.7e01c25

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,6 +47,8 @@ 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.
51
+
50
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.
51
53
 
52
54
  To use the plugin without a feature package, add it to the Markdown processor. With the default Sätteri processor:
@@ -37,9 +37,17 @@ export interface ContentImageNode {
37
37
  attributes?: MdxAttributeLike[];
38
38
  children?: ContentImageNode[];
39
39
  }
40
+ export interface LocalImageData {
41
+ astro?: {
42
+ localImagePaths?: Set<string> | readonly string[];
43
+ };
44
+ }
40
45
  export interface SatteriContentImageContext {
46
+ readonly sourceFormat: string;
47
+ readonly data: LocalImageData;
41
48
  parent(node: unknown): unknown;
42
49
  setProperty(node: unknown, key: string, value: unknown): void;
50
+ replaceNode(node: unknown, newNode: ContentImageNode): void;
43
51
  }
44
52
  export interface SatteriContentImageVisitor {
45
53
  filter: string[];
@@ -51,11 +59,19 @@ export interface SatteriContentImagePlugin {
51
59
  mdxJsxFlowElement: SatteriContentImageVisitor;
52
60
  mdxJsxTextElement: SatteriContentImageVisitor;
53
61
  }
62
+ export interface PictureStep {
63
+ enabled: boolean;
64
+ }
54
65
  export interface ContentImageRegistrationInput {
55
66
  config: {
56
67
  markdown?: {
57
68
  processor?: unknown;
58
69
  };
70
+ image?: {
71
+ service?: {
72
+ entrypoint?: unknown;
73
+ };
74
+ };
59
75
  };
60
76
  logger: {
61
77
  warn(message: string): void;
@@ -72,10 +88,14 @@ export interface MdxImagePatch {
72
88
  }
73
89
  /** A class written as an expression cannot be merged statically, so its marker is left out. */
74
90
  export declare function mdxImagePatch(attributes: readonly MdxAttributeLike[], options: ResolvedContentImageOptions): MdxImagePatch;
91
+ export interface UnifiedFileLike {
92
+ path?: unknown;
93
+ data?: LocalImageData;
94
+ }
75
95
  /** Unified rehype plugin: `unified` processors and Astro 6 `markdown.rehypePlugins`. */
76
- export declare function rehypeContentImages(options?: ContentImageOptions): (tree: ContentImageNode) => void;
96
+ export declare function rehypeContentImages(options?: ContentImageOptions, picture?: PictureStep): (tree: ContentImageNode, file?: UnifiedFileLike) => void;
77
97
  /** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
78
- export declare function satteriContentImages(options?: ContentImageOptions): SatteriContentImagePlugin;
98
+ export declare function satteriContentImages(options?: ContentImageOptions, picture?: PictureStep): SatteriContentImagePlugin;
79
99
  /**
80
100
  * Register the content-image plugin on the active Markdown processor. The first enabled
81
101
  * call appends one adapter; an `enabled: false` call opts the whole site out and removes
@@ -109,11 +109,31 @@ function applyUnifiedTree(node, options, inPicture) {
109
109
  for (const child of node.children ?? [])
110
110
  applyUnifiedTree(child, options, childrenInPicture);
111
111
  }
112
+ function wrapUnifiedPictures(node, inPicture, srcOf, nextIndex) {
113
+ const children = node.children;
114
+ if (!children)
115
+ return;
116
+ children.forEach((child, index) => {
117
+ 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
+ return;
122
+ }
123
+ }
124
+ wrapUnifiedPictures(child, inPicture || isPicture(child), srcOf, nextIndex);
125
+ });
126
+ }
112
127
  /** Unified rehype plugin: `unified` processors and Astro 6 `markdown.rehypePlugins`. */
113
- export function rehypeContentImages(options = {}) {
128
+ export function rehypeContentImages(options = {}, picture = { enabled: true }) {
114
129
  const resolved = resolveContentImageOptions(options);
115
- return (tree) => {
130
+ let pictureCount = 0;
131
+ return (tree, file) => {
116
132
  applyUnifiedTree(tree, resolved, false);
133
+ if (!picture.enabled || !String(file?.path ?? '').endsWith('.md'))
134
+ return;
135
+ const astro = file?.data?.astro;
136
+ wrapUnifiedPictures(tree, false, (img) => localImageSrc(img.properties?.src, astro), () => ++pictureCount);
117
137
  };
118
138
  }
119
139
  function isInsidePicture(node, ctx) {
@@ -123,9 +143,37 @@ function isInsidePicture(node, ctx) {
123
143
  }
124
144
  return false;
125
145
  }
146
+ const PICTURE_FORMATS = ['avif', 'webp'];
147
+ const NOOP_IMAGE_SERVICE = 'astro/assets/services/noop';
148
+ function localImageSrc(value, astro) {
149
+ const localPaths = astro?.localImagePaths;
150
+ if (typeof value !== 'string' || localPaths === undefined)
151
+ return undefined;
152
+ const src = decodeURI(value);
153
+ const isLocal = localPaths instanceof Set ? localPaths.has(src) : localPaths.includes(src);
154
+ return isLocal ? src : undefined;
155
+ }
156
+ function pictureElement(img, src, index) {
157
+ const sources = PICTURE_FORMATS.map((format) => ({
158
+ type: 'element',
159
+ tagName: 'source',
160
+ properties: {
161
+ __ASTRO_IMAGE_: JSON.stringify({
162
+ src,
163
+ format,
164
+ type: `image/${format}`,
165
+ densities: [1],
166
+ index: `picture-${format}-${index}`,
167
+ }),
168
+ },
169
+ children: [],
170
+ }));
171
+ return { type: 'element', tagName: 'picture', properties: {}, children: [...sources, img] };
172
+ }
126
173
  /** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
127
- export function satteriContentImages(options = {}) {
174
+ export function satteriContentImages(options = {}, picture = { enabled: true }) {
128
175
  const resolved = resolveContentImageOptions(options);
176
+ let pictureCount = 0;
129
177
  const mdxVisitor = {
130
178
  filter: ['img'],
131
179
  visit(node, ctx) {
@@ -154,6 +202,11 @@ export function satteriContentImages(options = {}) {
154
202
  ctx.setProperty(node, 'decoding', patch.decoding);
155
203
  if (patch.classNames !== undefined)
156
204
  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));
209
+ }
157
210
  },
158
211
  },
159
212
  mdxJsxFlowElement: mdxVisitor,
@@ -204,6 +257,7 @@ export function registerContentImages({ config, logger, enabled }) {
204
257
  }
205
258
  record.entry = undefined;
206
259
  record.list = undefined;
260
+ record.picture = undefined;
207
261
  return;
208
262
  }
209
263
  if (record.optedOut || record.entry !== undefined)
@@ -213,10 +267,14 @@ export function registerContentImages({ config, logger, enabled }) {
213
267
  warnOnce(record, 'unsupported', logger, `Hagilight content images cannot run on the "${String(processor.name)}" Markdown processor. Body images are left unchanged.`);
214
268
  return;
215
269
  }
216
- const plugin = processor.name === 'satteri' ? satteriContentImages() : rehypeContentImages;
270
+ const picture = { enabled: true };
271
+ const plugin = processor.name === 'satteri'
272
+ ? satteriContentImages({}, picture)
273
+ : (options) => rehypeContentImages(options, picture);
217
274
  list.push(plugin);
218
275
  record.entry = plugin;
219
276
  record.list = list;
277
+ record.picture = picture;
220
278
  }
221
279
  /** `astro:config:done` re-check: warns once if an enabled site's adapter is missing from the final processor. */
222
280
  export function checkContentImageRegistration({ config, logger, enabled }) {
@@ -228,7 +286,12 @@ export function checkContentImageRegistration({ config, logger, enabled }) {
228
286
  const record = recordOf(processor);
229
287
  if (record.optedOut || record.warned.has('unsupported'))
230
288
  return;
231
- if (record.entry !== undefined && pluginListOf(processor)?.includes(record.entry))
289
+ if (record.entry === undefined || !pluginListOf(processor)?.includes(record.entry)) {
290
+ warnOnce(record, 'lost', logger, `Hagilight content images were not found in the final "${String(processor.name)}" Markdown processor. Body images are left unchanged.`);
232
291
  return;
233
- warnOnce(record, 'lost', logger, `Hagilight content images were not found in the final "${String(processor.name)}" Markdown processor. Body images are left unchanged.`);
292
+ }
293
+ if (record.picture?.enabled && config.image?.service?.entrypoint === NOOP_IMAGE_SERVICE) {
294
+ record.picture.enabled = false;
295
+ warnOnce(record, 'picture', logger, 'Hagilight content images skip the <picture> wrapper because the Astro image service is the passthrough no-op, which cannot encode AVIF. Body images keep the baseline output. Use the default sharp service to get AVIF and WebP sources.');
296
+ }
234
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-core",
3
- "version": "0.8.1-dev.93.1.d28d470",
3
+ "version": "0.8.1-dev.95.1.7e01c25",
4
4
  "type": "module",
5
5
  "description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
6
6
  "repository": {
@@ -75,7 +75,7 @@
75
75
  "favicon.ico"
76
76
  ],
77
77
  "peerDependencies": {
78
- "astro": "^6.0.7 || ^7.3.5"
78
+ "astro": ">=7"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "astro": {