@hagicode/hagilight-core 0.8.1-dev.86.1.25db025 → 0.8.1-dev.88.1.80b3713
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/PromotoBanner.astro +4 -0
- package/README.md +32 -0
- package/dist/content-images.d.ts +86 -0
- package/dist/content-images.js +234 -0
- package/dist/promotions.d.ts +5 -0
- package/dist/promotions.js +22 -0
- package/dist/promoto-banner.d.ts +2 -1
- package/dist/promoto-banner.js +41 -3
- package/package.json +5 -1
package/PromotoBanner.astro
CHANGED
|
@@ -196,6 +196,10 @@ const { fallback, locale = Astro.currentLocale ?? 'en' } = Astro.props;
|
|
|
196
196
|
object-fit: cover;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
.hagilight-promoto__media picture {
|
|
200
|
+
display: contents;
|
|
201
|
+
}
|
|
202
|
+
|
|
199
203
|
.hagilight-promoto__cta {
|
|
200
204
|
grid-area: cta;
|
|
201
205
|
display: inline-flex;
|
package/README.md
CHANGED
|
@@ -35,10 +35,42 @@ import { resolveSiteLinks } from '@hagicode/hagilight-core/links';
|
|
|
35
35
|
const links = resolveSiteLinks('en-US');
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Content images
|
|
39
|
+
|
|
40
|
+
`@hagicode/hagilight-core/content-images` normalizes the `<img>` elements in Markdown and MDX body content at build time. A body image gets the marker class `hagilight-content-image`, `loading="lazy"`, and `decoding="async"`. Values the author already set are kept, existing classes are kept, and the marker is added once.
|
|
41
|
+
|
|
42
|
+
| Option | Default | Accepted values |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `loading` | `lazy` | `lazy` or `eager`, applied only when the image has no `loading` |
|
|
45
|
+
| `decoding` | `async` | `async`, `sync`, or `auto`, applied only when the image has no `decoding` |
|
|
46
|
+
| `className` | `hagilight-content-image` | A class name without whitespace |
|
|
47
|
+
|
|
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
|
+
|
|
50
|
+
`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
|
+
|
|
52
|
+
To use the plugin without a feature package, add it to the Markdown processor. With the default Sätteri processor:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { defineConfig } from 'astro/config';
|
|
56
|
+
import { satteri } from '@astrojs/markdown-satteri';
|
|
57
|
+
import { satteriContentImages } from '@hagicode/hagilight-core/content-images';
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
markdown: {
|
|
61
|
+
processor: satteri({ hastPlugins: [satteriContentImages()] }),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
With the unified processor, pass `rehypeContentImages()` in `rehypePlugins` instead, from `@astrojs/markdown-remark`'s `unified()`. Integrations that wrap Astro can call `registerContentImages({ config, logger, enabled })` in `astro:config:setup` and `checkContentImageRegistration({ config, logger, enabled })` in `astro:config:done`, both exported from the same entry point.
|
|
67
|
+
|
|
38
68
|
## Promotion platforms
|
|
39
69
|
|
|
40
70
|
The shared banner (`PromotoBanner`, `loadActivePromotions`) displays only promotions whose `platforms` array in Index's `/promote.json` includes `web`, in addition to being enabled, inside their activation window, and matched with `/promote_content.json`. This follows the `platforms` flag documented in the Index README. An entry whose `platforms` is missing, empty, or not an array of strings is not displayed, other values such as `hagicode` are ignored, and matching is exact (`Web` does not match). When no promotion remains, the banner shows its site fallback or stays hidden, as before.
|
|
41
71
|
|
|
72
|
+
A promotion's `image` may also carry `sources`, the alternative image files that Index publishes in the order AVIF, WebP, then the original. The banner renders them as a `<picture>` in that order, with the original as its `<img>`, and skips any entry whose `type` is not an `image/*` media type or whose URL is unsafe. Each `src` is resolved against `https://index.hagicode.com` when the promotion documents load. If the browser fails to load a tier, the banner removes it and the next tier is used. A card without `sources` renders the plain `<img>` as before, and `sources` never affect the dismissal signature, so a dismissed banner stays dismissed when only its image chain changes.
|
|
73
|
+
|
|
42
74
|
## Google Analytics click events
|
|
43
75
|
|
|
44
76
|
`GoogleAnalytics` also reports clicks on the key links and download buttons that the shared components render. There is nothing to configure: events follow the same rules as page views, so they are sent only on production pages where Google Analytics is enabled, never on the Starlight 404 page, and silently skipped when `gtag` is missing (development builds, blocked scripts).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export declare const CONTENT_IMAGE_CLASS = "hagilight-content-image";
|
|
2
|
+
export type ContentImageLoading = 'lazy' | 'eager';
|
|
3
|
+
export type ContentImageDecoding = 'async' | 'sync' | 'auto';
|
|
4
|
+
export interface ContentImageOptions {
|
|
5
|
+
/** Loading value for body images whose author set none. Defaults to `lazy`. */
|
|
6
|
+
loading?: ContentImageLoading;
|
|
7
|
+
/** Decoding value for body images whose author set none. Defaults to `async`. */
|
|
8
|
+
decoding?: ContentImageDecoding;
|
|
9
|
+
/** Marker class added once to every body image. Defaults to `hagilight-content-image`. */
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolvedContentImageOptions {
|
|
13
|
+
loading: ContentImageLoading;
|
|
14
|
+
decoding: ContentImageDecoding;
|
|
15
|
+
className: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ContentImageAuthorValues {
|
|
18
|
+
hasLoading: boolean;
|
|
19
|
+
hasDecoding: boolean;
|
|
20
|
+
classNames: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export interface ContentImagePatch {
|
|
23
|
+
loading?: ContentImageLoading;
|
|
24
|
+
decoding?: ContentImageDecoding;
|
|
25
|
+
classNames?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface MdxAttributeLike {
|
|
28
|
+
type?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
value?: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface ContentImageNode {
|
|
33
|
+
type: string;
|
|
34
|
+
tagName?: string;
|
|
35
|
+
name?: string | null;
|
|
36
|
+
properties?: Record<string, unknown>;
|
|
37
|
+
attributes?: MdxAttributeLike[];
|
|
38
|
+
children?: ContentImageNode[];
|
|
39
|
+
}
|
|
40
|
+
export interface SatteriContentImageContext {
|
|
41
|
+
parent(node: unknown): unknown;
|
|
42
|
+
setProperty(node: unknown, key: string, value: unknown): void;
|
|
43
|
+
}
|
|
44
|
+
export interface SatteriContentImageVisitor {
|
|
45
|
+
filter: string[];
|
|
46
|
+
visit(node: ContentImageNode, ctx: SatteriContentImageContext): void;
|
|
47
|
+
}
|
|
48
|
+
export interface SatteriContentImagePlugin {
|
|
49
|
+
name: string;
|
|
50
|
+
element: SatteriContentImageVisitor;
|
|
51
|
+
mdxJsxFlowElement: SatteriContentImageVisitor;
|
|
52
|
+
mdxJsxTextElement: SatteriContentImageVisitor;
|
|
53
|
+
}
|
|
54
|
+
export interface ContentImageRegistrationInput {
|
|
55
|
+
config: {
|
|
56
|
+
markdown?: {
|
|
57
|
+
processor?: unknown;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
logger: {
|
|
61
|
+
warn(message: string): void;
|
|
62
|
+
};
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare function resolveContentImageOptions(options?: ContentImageOptions): ResolvedContentImageOptions;
|
|
66
|
+
export declare function contentImagePatch(author: ContentImageAuthorValues, options: ResolvedContentImageOptions): ContentImagePatch;
|
|
67
|
+
export declare function hastImagePatch(properties: Record<string, unknown>, options: ResolvedContentImageOptions): ContentImagePatch;
|
|
68
|
+
export interface MdxImagePatch {
|
|
69
|
+
loading?: ContentImageLoading;
|
|
70
|
+
decoding?: ContentImageDecoding;
|
|
71
|
+
className?: string;
|
|
72
|
+
}
|
|
73
|
+
/** A class written as an expression cannot be merged statically, so its marker is left out. */
|
|
74
|
+
export declare function mdxImagePatch(attributes: readonly MdxAttributeLike[], options: ResolvedContentImageOptions): MdxImagePatch;
|
|
75
|
+
/** Unified rehype plugin: `unified` processors and Astro 6 `markdown.rehypePlugins`. */
|
|
76
|
+
export declare function rehypeContentImages(options?: ContentImageOptions): (tree: ContentImageNode) => void;
|
|
77
|
+
/** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
|
|
78
|
+
export declare function satteriContentImages(options?: ContentImageOptions): SatteriContentImagePlugin;
|
|
79
|
+
/**
|
|
80
|
+
* Register the content-image plugin on the active Markdown processor. The first enabled
|
|
81
|
+
* call appends one adapter; an `enabled: false` call opts the whole site out and removes
|
|
82
|
+
* only the adapter it did not append, so the result does not depend on hook order.
|
|
83
|
+
*/
|
|
84
|
+
export declare function registerContentImages({ config, logger, enabled }: ContentImageRegistrationInput): void;
|
|
85
|
+
/** `astro:config:done` re-check: warns once if an enabled site's adapter is missing from the final processor. */
|
|
86
|
+
export declare function checkContentImageRegistration({ config, logger, enabled }: ContentImageRegistrationInput): void;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
export const CONTENT_IMAGE_CLASS = 'hagilight-content-image';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
export function resolveContentImageOptions(options = {}) {
|
|
6
|
+
if (!isRecord(options))
|
|
7
|
+
throw new TypeError('Hagilight content image options must be an object.');
|
|
8
|
+
const { loading = 'lazy', decoding = 'async', className = CONTENT_IMAGE_CLASS } = options;
|
|
9
|
+
if (loading !== 'lazy' && loading !== 'eager') {
|
|
10
|
+
throw new TypeError('Hagilight content image loading option must be "lazy" or "eager".');
|
|
11
|
+
}
|
|
12
|
+
if (decoding !== 'async' && decoding !== 'sync' && decoding !== 'auto') {
|
|
13
|
+
throw new TypeError('Hagilight content image decoding option must be "async", "sync", or "auto".');
|
|
14
|
+
}
|
|
15
|
+
if (typeof className !== 'string' || className === '' || /\s/u.test(className)) {
|
|
16
|
+
throw new TypeError('Hagilight content image className option must be a non-empty class name without whitespace.');
|
|
17
|
+
}
|
|
18
|
+
return { loading, decoding, className };
|
|
19
|
+
}
|
|
20
|
+
export function contentImagePatch(author, options) {
|
|
21
|
+
const patch = {};
|
|
22
|
+
if (!author.hasLoading)
|
|
23
|
+
patch.loading = options.loading;
|
|
24
|
+
if (!author.hasDecoding)
|
|
25
|
+
patch.decoding = options.decoding;
|
|
26
|
+
if (!author.classNames.includes(options.className)) {
|
|
27
|
+
patch.classNames = [...author.classNames, options.className];
|
|
28
|
+
}
|
|
29
|
+
return patch;
|
|
30
|
+
}
|
|
31
|
+
function splitClassNames(value) {
|
|
32
|
+
return value.split(/\s+/u).filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
function classNamesOf(value) {
|
|
35
|
+
if (typeof value === 'string')
|
|
36
|
+
return splitClassNames(value);
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return value.filter((item) => typeof item === 'string').flatMap(splitClassNames);
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
function mdxClassNames(attribute) {
|
|
43
|
+
if (attribute === undefined || attribute.value === undefined || attribute.value === null)
|
|
44
|
+
return [];
|
|
45
|
+
return typeof attribute.value === 'string' ? splitClassNames(attribute.value) : undefined;
|
|
46
|
+
}
|
|
47
|
+
export function hastImagePatch(properties, options) {
|
|
48
|
+
return contentImagePatch({
|
|
49
|
+
hasLoading: properties.loading !== undefined,
|
|
50
|
+
hasDecoding: properties.decoding !== undefined,
|
|
51
|
+
classNames: classNamesOf(properties.className),
|
|
52
|
+
}, options);
|
|
53
|
+
}
|
|
54
|
+
/** A class written as an expression cannot be merged statically, so its marker is left out. */
|
|
55
|
+
export function mdxImagePatch(attributes, options) {
|
|
56
|
+
const classNames = mdxClassNames(attributes.find((attribute) => attribute.name === 'class'));
|
|
57
|
+
const patch = contentImagePatch({
|
|
58
|
+
hasLoading: attributes.some((attribute) => attribute.name === 'loading'),
|
|
59
|
+
hasDecoding: attributes.some((attribute) => attribute.name === 'decoding'),
|
|
60
|
+
classNames: classNames ?? [options.className],
|
|
61
|
+
}, options);
|
|
62
|
+
return {
|
|
63
|
+
loading: patch.loading,
|
|
64
|
+
decoding: patch.decoding,
|
|
65
|
+
className: patch.classNames?.join(' '),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function isMdxJsx(node) {
|
|
69
|
+
return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement';
|
|
70
|
+
}
|
|
71
|
+
function isPicture(node) {
|
|
72
|
+
return (node.type === 'element' && node.tagName === 'picture')
|
|
73
|
+
|| (isMdxJsx(node) && node.name === 'picture');
|
|
74
|
+
}
|
|
75
|
+
function upsertMdxAttribute(attributes, name, value) {
|
|
76
|
+
const existing = attributes.find((attribute) => attribute.name === name);
|
|
77
|
+
if (existing)
|
|
78
|
+
existing.value = value;
|
|
79
|
+
else
|
|
80
|
+
attributes.push({ type: 'mdxJsxAttribute', name, value });
|
|
81
|
+
}
|
|
82
|
+
function applyUnifiedNode(node, options) {
|
|
83
|
+
if (node.type === 'element' && node.tagName === 'img') {
|
|
84
|
+
const properties = (node.properties ??= {});
|
|
85
|
+
const patch = hastImagePatch(properties, options);
|
|
86
|
+
if (patch.loading !== undefined)
|
|
87
|
+
properties.loading = patch.loading;
|
|
88
|
+
if (patch.decoding !== undefined)
|
|
89
|
+
properties.decoding = patch.decoding;
|
|
90
|
+
if (patch.classNames !== undefined)
|
|
91
|
+
properties.className = patch.classNames;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (isMdxJsx(node) && node.name === 'img') {
|
|
95
|
+
const attributes = (node.attributes ??= []);
|
|
96
|
+
const patch = mdxImagePatch(attributes, options);
|
|
97
|
+
if (patch.loading !== undefined)
|
|
98
|
+
upsertMdxAttribute(attributes, 'loading', patch.loading);
|
|
99
|
+
if (patch.decoding !== undefined)
|
|
100
|
+
upsertMdxAttribute(attributes, 'decoding', patch.decoding);
|
|
101
|
+
if (patch.className !== undefined)
|
|
102
|
+
upsertMdxAttribute(attributes, 'class', patch.className);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function applyUnifiedTree(node, options, inPicture) {
|
|
106
|
+
if (!inPicture)
|
|
107
|
+
applyUnifiedNode(node, options);
|
|
108
|
+
const childrenInPicture = inPicture || isPicture(node);
|
|
109
|
+
for (const child of node.children ?? [])
|
|
110
|
+
applyUnifiedTree(child, options, childrenInPicture);
|
|
111
|
+
}
|
|
112
|
+
/** Unified rehype plugin: `unified` processors and Astro 6 `markdown.rehypePlugins`. */
|
|
113
|
+
export function rehypeContentImages(options = {}) {
|
|
114
|
+
const resolved = resolveContentImageOptions(options);
|
|
115
|
+
return (tree) => {
|
|
116
|
+
applyUnifiedTree(tree, resolved, false);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function isInsidePicture(node, ctx) {
|
|
120
|
+
for (let parent = ctx.parent(node); parent; parent = ctx.parent(parent)) {
|
|
121
|
+
if (isPicture(parent))
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
/** Sätteri hast plugin: Astro's default Markdown processor, through `hastPlugins`. */
|
|
127
|
+
export function satteriContentImages(options = {}) {
|
|
128
|
+
const resolved = resolveContentImageOptions(options);
|
|
129
|
+
const mdxVisitor = {
|
|
130
|
+
filter: ['img'],
|
|
131
|
+
visit(node, ctx) {
|
|
132
|
+
if (isInsidePicture(node, ctx))
|
|
133
|
+
return;
|
|
134
|
+
const patch = mdxImagePatch(node.attributes ?? [], resolved);
|
|
135
|
+
if (patch.loading !== undefined)
|
|
136
|
+
ctx.setProperty(node, 'loading', patch.loading);
|
|
137
|
+
if (patch.decoding !== undefined)
|
|
138
|
+
ctx.setProperty(node, 'decoding', patch.decoding);
|
|
139
|
+
if (patch.className !== undefined)
|
|
140
|
+
ctx.setProperty(node, 'class', patch.className);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
name: 'hagilight:content-images',
|
|
145
|
+
element: {
|
|
146
|
+
filter: ['img'],
|
|
147
|
+
visit(node, ctx) {
|
|
148
|
+
if (isInsidePicture(node, ctx))
|
|
149
|
+
return;
|
|
150
|
+
const patch = hastImagePatch(node.properties ?? {}, resolved);
|
|
151
|
+
if (patch.loading !== undefined)
|
|
152
|
+
ctx.setProperty(node, 'loading', patch.loading);
|
|
153
|
+
if (patch.decoding !== undefined)
|
|
154
|
+
ctx.setProperty(node, 'decoding', patch.decoding);
|
|
155
|
+
if (patch.classNames !== undefined)
|
|
156
|
+
ctx.setProperty(node, 'className', patch.classNames);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
mdxJsxFlowElement: mdxVisitor,
|
|
160
|
+
mdxJsxTextElement: mdxVisitor,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const RECORD_KEY = Symbol.for('hagilight.content-images');
|
|
164
|
+
function recordOf(processor) {
|
|
165
|
+
const holder = processor;
|
|
166
|
+
holder[RECORD_KEY] ??= { optedOut: false, warned: new Set() };
|
|
167
|
+
return holder[RECORD_KEY];
|
|
168
|
+
}
|
|
169
|
+
function pluginListOf(processor) {
|
|
170
|
+
const hastPlugins = processor.options?.hastPlugins;
|
|
171
|
+
const rehypePlugins = processor.options?.rehypePlugins;
|
|
172
|
+
if (processor.name === 'satteri' && Array.isArray(hastPlugins))
|
|
173
|
+
return hastPlugins;
|
|
174
|
+
if (processor.name === 'unified' && Array.isArray(rehypePlugins))
|
|
175
|
+
return rehypePlugins;
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
function warnOnce(record, key, logger, message) {
|
|
179
|
+
if (record.warned.has(key))
|
|
180
|
+
return;
|
|
181
|
+
record.warned.add(key);
|
|
182
|
+
logger.warn(message);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Register the content-image plugin on the active Markdown processor. The first enabled
|
|
186
|
+
* call appends one adapter; an `enabled: false` call opts the whole site out and removes
|
|
187
|
+
* only the adapter it did not append, so the result does not depend on hook order.
|
|
188
|
+
*/
|
|
189
|
+
export function registerContentImages({ config, logger, enabled }) {
|
|
190
|
+
const processor = config.markdown?.processor;
|
|
191
|
+
if (!processor) {
|
|
192
|
+
if (enabled) {
|
|
193
|
+
logger.warn('Hagilight content images need a Markdown processor, but the Astro config has none. Body images are left unchanged.');
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const record = recordOf(processor);
|
|
198
|
+
if (!enabled) {
|
|
199
|
+
record.optedOut = true;
|
|
200
|
+
if (record.list && record.entry !== undefined) {
|
|
201
|
+
const index = record.list.indexOf(record.entry);
|
|
202
|
+
if (index !== -1)
|
|
203
|
+
record.list.splice(index, 1);
|
|
204
|
+
}
|
|
205
|
+
record.entry = undefined;
|
|
206
|
+
record.list = undefined;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (record.optedOut || record.entry !== undefined)
|
|
210
|
+
return;
|
|
211
|
+
const list = pluginListOf(processor);
|
|
212
|
+
if (!list) {
|
|
213
|
+
warnOnce(record, 'unsupported', logger, `Hagilight content images cannot run on the "${String(processor.name)}" Markdown processor. Body images are left unchanged.`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const plugin = processor.name === 'satteri' ? satteriContentImages() : rehypeContentImages;
|
|
217
|
+
list.push(plugin);
|
|
218
|
+
record.entry = plugin;
|
|
219
|
+
record.list = list;
|
|
220
|
+
}
|
|
221
|
+
/** `astro:config:done` re-check: warns once if an enabled site's adapter is missing from the final processor. */
|
|
222
|
+
export function checkContentImageRegistration({ config, logger, enabled }) {
|
|
223
|
+
if (!enabled)
|
|
224
|
+
return;
|
|
225
|
+
const processor = config.markdown?.processor;
|
|
226
|
+
if (!processor)
|
|
227
|
+
return;
|
|
228
|
+
const record = recordOf(processor);
|
|
229
|
+
if (record.optedOut || record.warned.has('unsupported'))
|
|
230
|
+
return;
|
|
231
|
+
if (record.entry !== undefined && pluginListOf(processor)?.includes(record.entry))
|
|
232
|
+
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.`);
|
|
234
|
+
}
|
package/dist/promotions.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
type FetchLike = typeof fetch;
|
|
2
|
+
export interface PromotionImageSource {
|
|
3
|
+
type: string;
|
|
4
|
+
src: string;
|
|
5
|
+
}
|
|
2
6
|
export interface PromotionImage {
|
|
3
7
|
src: string;
|
|
4
8
|
alt: string;
|
|
5
9
|
variant?: string;
|
|
6
10
|
width?: number;
|
|
7
11
|
height?: number;
|
|
12
|
+
sources?: PromotionImageSource[];
|
|
8
13
|
}
|
|
9
14
|
export interface PromotionCard {
|
|
10
15
|
id: string;
|
package/dist/promotions.js
CHANGED
|
@@ -15,6 +15,26 @@ function parseLocalizedMap(value) {
|
|
|
15
15
|
const entries = Object.entries(value).filter((entry) => nonEmptyString(entry[0]) && nonEmptyString(entry[1]));
|
|
16
16
|
return entries.length ? Object.fromEntries(entries.map(([key, text]) => [key.trim(), text.trim()])) : null;
|
|
17
17
|
}
|
|
18
|
+
function parseImageSources(value) {
|
|
19
|
+
if (!Array.isArray(value))
|
|
20
|
+
return [];
|
|
21
|
+
return value.flatMap((item) => {
|
|
22
|
+
if (!isRecord(item) || !nonEmptyString(item.type) || !nonEmptyString(item.src))
|
|
23
|
+
return [];
|
|
24
|
+
const type = item.type.trim().toLowerCase();
|
|
25
|
+
if (!type.startsWith('image/'))
|
|
26
|
+
return [];
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(item.src.trim(), INDEX_ORIGIN);
|
|
29
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
30
|
+
return [];
|
|
31
|
+
return [{ type, src: url.toString() }];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
18
38
|
function parseImage(value, record) {
|
|
19
39
|
const image = isRecord(value) ? value : {};
|
|
20
40
|
const src = nonEmptyString(value)
|
|
@@ -46,6 +66,7 @@ function parseImage(value, record) {
|
|
|
46
66
|
catch {
|
|
47
67
|
return undefined;
|
|
48
68
|
}
|
|
69
|
+
const sources = parseImageSources(image.sources);
|
|
49
70
|
return {
|
|
50
71
|
src: normalizedSrc,
|
|
51
72
|
alt: nonEmptyString(image.alt)
|
|
@@ -56,6 +77,7 @@ function parseImage(value, record) {
|
|
|
56
77
|
variant: nonEmptyString(image.variant) ? image.variant.trim() : undefined,
|
|
57
78
|
width,
|
|
58
79
|
height,
|
|
80
|
+
...(sources.length ? { sources } : {}),
|
|
59
81
|
};
|
|
60
82
|
}
|
|
61
83
|
function isSafeLink(value) {
|
package/dist/promoto-banner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type PromotionCard } from './promotions.js';
|
|
1
|
+
import { type PromotionCard, type PromotionImage, type PromotionImageSource } from './promotions.js';
|
|
2
2
|
export type PromotionVisibility = 'dismissed' | 'footer-hidden' | 'hidden' | 'ready';
|
|
3
3
|
export declare function selectPromotionCards(remote: PromotionCard[], fallback?: PromotionCard | null): PromotionCard[];
|
|
4
|
+
export declare function getPromotionImageSources(image: PromotionImage | undefined): PromotionImageSource[];
|
|
4
5
|
export declare function getPromotionSetSignature(cards: PromotionCard[]): string | null;
|
|
5
6
|
export declare function getPromotionVisibility(cards: PromotionCard[], signature: string | null, dismissedSignature: string | null, footerVisible: boolean): PromotionVisibility;
|
|
6
7
|
export declare function getNextPromotionIndex(index: number, count: number, direction: -1 | 1): number;
|
package/dist/promoto-banner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { gaEventAttributes } from './analytics-events.js';
|
|
2
|
-
import { loadActivePromotions } from './promotions.js';
|
|
2
|
+
import { loadActivePromotions, } from './promotions.js';
|
|
3
3
|
const ELEMENT_NAME = 'hagilight-promoto-banner';
|
|
4
4
|
const DISMISSED_SIGNATURE_KEY = 'hagilight:promoto-banner:dismissed-signature';
|
|
5
5
|
const ROTATION_INTERVAL_MS = 8000;
|
|
@@ -37,6 +37,28 @@ export function selectPromotionCards(remote, fallback) {
|
|
|
37
37
|
return eligibleRemote;
|
|
38
38
|
return fallback && isUsableCard(fallback) ? [fallback] : [];
|
|
39
39
|
}
|
|
40
|
+
export function getPromotionImageSources(image) {
|
|
41
|
+
if (!image || !Array.isArray(image.sources))
|
|
42
|
+
return [];
|
|
43
|
+
return image.sources.filter((source) => source.type.toLowerCase().startsWith('image/')
|
|
44
|
+
&& isSafeUrl(source.src)
|
|
45
|
+
&& source.src !== image.src);
|
|
46
|
+
}
|
|
47
|
+
function sourceUrl(source) {
|
|
48
|
+
return new URL(source.srcset, document.baseURI).href;
|
|
49
|
+
}
|
|
50
|
+
function advancePromotionImageSource(image) {
|
|
51
|
+
const picture = image.parentElement;
|
|
52
|
+
if (!(picture instanceof HTMLPictureElement))
|
|
53
|
+
return;
|
|
54
|
+
const failed = Array.from(picture.querySelectorAll('source'))
|
|
55
|
+
.find((source) => sourceUrl(source) === image.currentSrc);
|
|
56
|
+
if (!failed)
|
|
57
|
+
return;
|
|
58
|
+
failed.remove();
|
|
59
|
+
// Re-assigning src makes the browser re-run source selection over the remaining tiers.
|
|
60
|
+
image.src = image.src;
|
|
61
|
+
}
|
|
40
62
|
export function getPromotionSetSignature(cards) {
|
|
41
63
|
if (!cards.length)
|
|
42
64
|
return null;
|
|
@@ -241,7 +263,6 @@ export function definePromotoBannerElement() {
|
|
|
241
263
|
media.className = 'hagilight-promoto__media';
|
|
242
264
|
const image = document.createElement('img');
|
|
243
265
|
image.className = 'hagilight-promoto__image';
|
|
244
|
-
image.src = card.image.src;
|
|
245
266
|
image.alt = card.image.alt || card.title;
|
|
246
267
|
image.loading = index === this.currentIndex ? 'eager' : 'lazy';
|
|
247
268
|
image.decoding = 'async';
|
|
@@ -249,7 +270,24 @@ export function definePromotoBannerElement() {
|
|
|
249
270
|
image.width = card.image.width;
|
|
250
271
|
if (card.image.height)
|
|
251
272
|
image.height = card.image.height;
|
|
252
|
-
|
|
273
|
+
const sources = getPromotionImageSources(card.image);
|
|
274
|
+
if (sources.length) {
|
|
275
|
+
const picture = document.createElement('picture');
|
|
276
|
+
picture.className = 'hagilight-promoto__picture';
|
|
277
|
+
for (const source of sources) {
|
|
278
|
+
const element = document.createElement('source');
|
|
279
|
+
element.type = source.type;
|
|
280
|
+
element.srcset = source.src;
|
|
281
|
+
picture.append(element);
|
|
282
|
+
}
|
|
283
|
+
picture.append(image);
|
|
284
|
+
image.addEventListener('error', () => advancePromotionImageSource(image));
|
|
285
|
+
media.append(picture);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
media.append(image);
|
|
289
|
+
}
|
|
290
|
+
image.src = card.image.src;
|
|
253
291
|
slide.append(media);
|
|
254
292
|
}
|
|
255
293
|
slide.append(link);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight-core",
|
|
3
|
-
"version": "0.8.1-dev.
|
|
3
|
+
"version": "0.8.1-dev.88.1.80b3713",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
|
|
6
6
|
"repository": {
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
"types": "./dist/analytics-events.d.ts",
|
|
51
51
|
"default": "./dist/analytics-events.js"
|
|
52
52
|
},
|
|
53
|
+
"./content-images": {
|
|
54
|
+
"types": "./dist/content-images.d.ts",
|
|
55
|
+
"default": "./dist/content-images.js"
|
|
56
|
+
},
|
|
53
57
|
"./Copyright": "./Copyright.astro",
|
|
54
58
|
"./PromotoBanner": "./PromotoBanner.astro",
|
|
55
59
|
"./GoogleAnalytics": "./GoogleAnalytics.astro",
|