@place-framework/place-block-image 1.0.1 → 1.0.3
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/dist/constants/index.d.ts +2 -2
- package/dist/constants/index.d.ts.map +1 -1
- package/dist/constants/index.js +4 -5
- package/dist/constants/index.js.map +1 -1
- package/dist/generate-components.d.ts +16 -0
- package/dist/generate-components.d.ts.map +1 -0
- package/dist/generate-components.js +93 -0
- package/dist/generate-components.js.map +1 -0
- package/dist/react/PlaceBlockImage.cjs +1 -0
- package/dist/react/PlaceBlockImage.js +67 -0
- package/dist/templates/shared/index.d.ts.map +1 -1
- package/dist/templates/shared/index.js +8 -16
- package/dist/templates/shared/index.js.map +1 -1
- package/dist/templates/shared/vue.d.ts +6 -0
- package/dist/templates/shared/vue.d.ts.map +1 -0
- package/dist/templates/shared/vue.js +26 -0
- package/dist/templates/shared/vue.js.map +1 -0
- package/dist/templates/vue.d.ts.map +1 -1
- package/dist/templates/vue.js +14 -19
- package/dist/templates/vue.js.map +1 -1
- package/dist/utils/index.d.ts +61 -14
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +108 -40
- package/dist/utils/index.js.map +1 -1
- package/dist/vue/PlaceBlockImage.cjs +1 -0
- package/dist/vue/PlaceBlockImage.js +83 -0
- package/dist/webpack-plugin.d.ts +20 -7
- package/dist/webpack-plugin.d.ts.map +1 -1
- package/dist/webpack-plugin.js +22 -28
- package/dist/webpack-plugin.js.map +1 -1
- package/package.json +36 -10
- package/src/constants/index.ts +5 -6
- package/src/react/PlaceBlockImage.tsx +63 -0
- package/src/react/index.ts +1 -0
- package/src/utils/index.ts +121 -44
- package/src/vue/PlaceBlockImage.vue +98 -0
- package/src/vue/index.ts +1 -0
- package/src/webpack-plugin.ts +45 -40
- package/tsconfig.json +8 -3
- package/vite.config.mts +27 -0
- package/vite.react.config.mts +25 -0
- package/vite.vue.config.mts +26 -0
- package/src/templates/react-jsx.ts +0 -27
- package/src/templates/react-tsx.ts +0 -33
- package/src/templates/shared/index.ts +0 -47
- package/src/templates/shared/react.ts +0 -51
- package/src/templates/vue.ts +0 -98
- package/src/templates.ts +0 -29
package/src/utils/index.ts
CHANGED
|
@@ -1,53 +1,130 @@
|
|
|
1
|
-
// Shared
|
|
1
|
+
// Shared utilities
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { CLASS_NAMES } from '../constants/index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts any image URL or path into a CSS-safe kebab-case string.
|
|
7
|
+
* Used at build time by generateImageClassName() and at runtime by
|
|
8
|
+
* the Vue/React components to derive the CSS class from the src prop.
|
|
9
|
+
*/
|
|
10
|
+
export const cleanImagePath = (src: string): string =>
|
|
11
|
+
src
|
|
12
|
+
.replace(/^https?:\/\/[^/]+/, '') // Strip protocol + domain
|
|
13
|
+
.replace(/^[/\\]+/, '') // Strip leading slashes
|
|
14
|
+
.replace(/\.[^/.]+$/, '') // Remove file extension
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9/-]/g, '-') // Special chars → hyphens (keep / and -)
|
|
17
|
+
.replace(/\//g, '-') // Path separators → hyphens
|
|
18
|
+
.replace(/-+/g, '-') // Collapse duplicate hyphens
|
|
19
|
+
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate CSS class name from a file path — path-agnostic, works with any URL format.
|
|
23
|
+
* Matches the runtime getImageClassName logic emitted by getSharedLogic().
|
|
24
|
+
*
|
|
25
|
+
* @param filePath - Relative path from imageDir (e.g. "story/accent-orchid.png")
|
|
26
|
+
* @param imagePrefix - The prefix to add to the class name (e.g. "image-")
|
|
27
|
+
* @param outputPrefix - Optional output path prefix prepended before filePath to mirror
|
|
28
|
+
* the served URL (e.g. "images/" when assetModuleFilename outputs
|
|
29
|
+
* to "images/…" so the browser requests "/images/story/…")
|
|
30
|
+
* @returns The generated CSS class name (e.g. "image-images-story-accent-orchid")
|
|
31
|
+
*/
|
|
32
|
+
export function generateImageClassName(
|
|
33
|
+
filePath: string,
|
|
34
|
+
imagePrefix: string,
|
|
35
|
+
outputPrefix: string = ''
|
|
36
|
+
): string {
|
|
37
|
+
const fullPath = outputPrefix
|
|
38
|
+
? `${outputPrefix.replace(/\/$/, '')}/${filePath}`
|
|
39
|
+
: filePath;
|
|
40
|
+
|
|
41
|
+
return `${imagePrefix}${cleanImagePath(fullPath)}`;
|
|
7
42
|
}
|
|
8
43
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Strips the Vite/webpack plugin's injected asset-path prefix from a src string
|
|
46
|
+
* so the remaining path matches what the plugin used when generating CSS classes.
|
|
47
|
+
*
|
|
48
|
+
* @param src - The raw src value (may include the asset path prefix)
|
|
49
|
+
* @param assetPath - The value of __PLUGIN_ASSET_PATH__ (empty string if undefined)
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAssetPath(src: string, assetPath: string): string {
|
|
52
|
+
if (!assetPath) return src;
|
|
53
|
+
return src.replace(new RegExp(`^${assetPath.replace(/\//g, '\\/')}\\/?`), '');
|
|
15
54
|
}
|
|
16
55
|
|
|
17
56
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* @
|
|
57
|
+
* Derives the per-image CSS class from a src URL, stripping the asset-path
|
|
58
|
+
* prefix first so the class matches what the plugin generated at build time.
|
|
59
|
+
*
|
|
60
|
+
* @param src - Raw src prop value
|
|
61
|
+
* @param imagePrefix - e.g. "image-"
|
|
62
|
+
* @param assetPath - Value of __PLUGIN_ASSET_PATH__ (pass '' if not defined)
|
|
22
63
|
*/
|
|
23
|
-
export function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
64
|
+
export function getImageClass(src: string, imagePrefix: string, assetPath: string): string {
|
|
65
|
+
if (!src) return '';
|
|
66
|
+
return `${imagePrefix}${cleanImagePath(resolveAssetPath(src, assetPath))}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Builds the full className string for the <picture> wrapper element.
|
|
71
|
+
*
|
|
72
|
+
* @param src - Raw src prop value
|
|
73
|
+
* @param imagePrefix - e.g. "image-"
|
|
74
|
+
* @param assetPath - Value of __PLUGIN_ASSET_PATH__ (pass '' if not defined)
|
|
75
|
+
* @param extraClass - Any additional class(es) passed by the consumer (e.g. className prop)
|
|
76
|
+
*/
|
|
77
|
+
export function getWrapperClass(
|
|
78
|
+
src: string,
|
|
79
|
+
imagePrefix: string,
|
|
80
|
+
assetPath: string,
|
|
81
|
+
extraClass: string = ''
|
|
82
|
+
): string {
|
|
83
|
+
const parts = [
|
|
84
|
+
`${imagePrefix}${CLASS_NAMES.WRAPPER_SUFFIX}`,
|
|
85
|
+
getImageClass(src, imagePrefix, assetPath),
|
|
86
|
+
extraClass,
|
|
87
|
+
].filter(Boolean);
|
|
88
|
+
return parts.join(' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the CSS class string for the <img> element based on lazy-load state.
|
|
93
|
+
*
|
|
94
|
+
* @param lazy - Whether lazy loading is enabled
|
|
95
|
+
* @param isLoaded - Whether the image has been loaded (intersected)
|
|
96
|
+
*/
|
|
97
|
+
export function getLazyClass(lazy: boolean, isLoaded: boolean): string {
|
|
98
|
+
if (!lazy) return '';
|
|
99
|
+
return isLoaded ? `${CLASS_NAMES.LAZY} ${CLASS_NAMES.LOADED}` : CLASS_NAMES.LAZY;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates an IntersectionObserver that fires `onIntersect` once when the given
|
|
104
|
+
* element enters the viewport, then automatically stops observing.
|
|
105
|
+
*
|
|
106
|
+
* @param element - The DOM element to observe
|
|
107
|
+
* @param onIntersect - Callback invoked when the element intersects
|
|
108
|
+
* @param threshold - Intersection threshold (default 0.1)
|
|
109
|
+
* @returns The created IntersectionObserver (call .disconnect() to clean up early)
|
|
110
|
+
*/
|
|
111
|
+
export function createLazyObserver(
|
|
112
|
+
element: Element,
|
|
113
|
+
onIntersect: () => void,
|
|
114
|
+
threshold: number = 0.1
|
|
115
|
+
): IntersectionObserver {
|
|
116
|
+
const observer = new IntersectionObserver(
|
|
117
|
+
(entries) => {
|
|
118
|
+
entries.forEach((entry) => {
|
|
119
|
+
if (entry.isIntersecting) {
|
|
120
|
+
onIntersect();
|
|
121
|
+
observer.unobserve(entry.target);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
{ threshold }
|
|
126
|
+
);
|
|
127
|
+
observer.observe(element);
|
|
128
|
+
return observer;
|
|
33
129
|
}
|
|
34
130
|
|
|
35
|
-
export function getSharedLogic(imagePrefix: string): string {
|
|
36
|
-
return `
|
|
37
|
-
// Extract filename from src to generate class name
|
|
38
|
-
const getImageClassName = (imageSrc) => {
|
|
39
|
-
// Remove /images/ prefix and file extension, convert to kebab-case
|
|
40
|
-
const filename = imageSrc
|
|
41
|
-
.replace(/^.*\\/images\\//, '') // Remove path up to /images/
|
|
42
|
-
.replace(/\\.[^/.]+$/, '') // Remove file extension
|
|
43
|
-
.toLowerCase()
|
|
44
|
-
.replace(/[^a-z0-9-]/g, '-') // Convert special chars to hyphens
|
|
45
|
-
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
46
|
-
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
47
|
-
|
|
48
|
-
return \`${imagePrefix}\${filename}\`;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const imageClassName = getImageClassName(src);
|
|
52
|
-
const wrapperClassName = \`${imagePrefix}wrapper \${imageClassName}\`;`;
|
|
53
|
-
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<picture :class="[wrapperClassName, attrs.class]">
|
|
3
|
+
<img
|
|
4
|
+
ref="imgRef"
|
|
5
|
+
:src="imageSrc"
|
|
6
|
+
:alt="alt"
|
|
7
|
+
:class="imgClassName"
|
|
8
|
+
v-bind="imgAttrs"
|
|
9
|
+
/>
|
|
10
|
+
</picture>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import { computed, ref, onMounted, onUnmounted, watch, useAttrs } from 'vue';
|
|
15
|
+
import { getWrapperClass, getLazyClass, createLazyObserver } from '../utils/index';
|
|
16
|
+
import { IMAGE_PREFIX } from '../constants/index';
|
|
17
|
+
|
|
18
|
+
declare const __PLUGIN_ASSET_PATH__: string;
|
|
19
|
+
|
|
20
|
+
defineOptions({ inheritAttrs: false });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PlaceBlockImage — prevents layout shift using CSS custom properties.
|
|
24
|
+
* Import: import PlaceBlockImage from '@place-framework/place-block-image/vue'
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* <PlaceBlockImage src="/assets/logo.png" alt="Logo" />
|
|
28
|
+
* <PlaceBlockImage src="/assets/hero.jpg" alt="Hero" :lazy="true" />
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const attrs = useAttrs();
|
|
32
|
+
|
|
33
|
+
const props = defineProps({
|
|
34
|
+
src: { type: String, required: true },
|
|
35
|
+
alt: { type: String, required: true },
|
|
36
|
+
imagePrefix: { type: String, default: () => IMAGE_PREFIX },
|
|
37
|
+
lazy: { type: Boolean, default: false }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// All attrs except class go to the <img>
|
|
41
|
+
const imgAttrs = computed(() => {
|
|
42
|
+
const { class: _, ...rest } = attrs;
|
|
43
|
+
return rest;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const imgRef = ref(null);
|
|
47
|
+
const imageSrc = ref(props.lazy ? '' : props.src);
|
|
48
|
+
const isLoaded = ref(!props.lazy);
|
|
49
|
+
let observer: IntersectionObserver | null = null;
|
|
50
|
+
|
|
51
|
+
const assetPath: string = typeof __PLUGIN_ASSET_PATH__ !== 'undefined' ? __PLUGIN_ASSET_PATH__ : '';
|
|
52
|
+
|
|
53
|
+
const wrapperClassName = computed(() =>
|
|
54
|
+
getWrapperClass(props.src, props.imagePrefix, assetPath)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const imgClassName = computed(() => getLazyClass(props.lazy, isLoaded.value));
|
|
58
|
+
|
|
59
|
+
const setupLazyLoading = () => {
|
|
60
|
+
if (!props.lazy || isLoaded.value || !imgRef.value) return;
|
|
61
|
+
|
|
62
|
+
observer = createLazyObserver(imgRef.value, () => {
|
|
63
|
+
imageSrc.value = props.src;
|
|
64
|
+
isLoaded.value = true;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const cleanupObserver = () => {
|
|
69
|
+
if (observer) {
|
|
70
|
+
observer.disconnect();
|
|
71
|
+
observer = null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
onMounted(() => {
|
|
76
|
+
setupLazyLoading();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onUnmounted(() => {
|
|
80
|
+
cleanupObserver();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
watch(() => props.src, (newSrc) => {
|
|
84
|
+
if (!props.lazy) {
|
|
85
|
+
imageSrc.value = newSrc;
|
|
86
|
+
} else if (!isLoaded.value) {
|
|
87
|
+
cleanupObserver();
|
|
88
|
+
setupLazyLoading();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
watch(isLoaded, (loaded) => {
|
|
93
|
+
if (loaded) {
|
|
94
|
+
imageSrc.value = props.src;
|
|
95
|
+
cleanupObserver();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
</script>
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as PlaceBlockImage } from './PlaceBlockImage.vue';
|
package/src/webpack-plugin.ts
CHANGED
|
@@ -2,18 +2,33 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
4
|
import sizeOf from 'image-size';
|
|
5
|
-
import { Compiler } from 'webpack';
|
|
6
|
-
import { getTemplate } from './templates';
|
|
5
|
+
import { Compiler, DefinePlugin } from 'webpack';
|
|
7
6
|
import { generateImageClassName } from './utils';
|
|
8
7
|
|
|
9
8
|
export interface PlaceBlockImagePluginOptions {
|
|
10
9
|
imagePrefix?: string;
|
|
11
10
|
imageDir: string;
|
|
12
11
|
scssPath: string;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The URL path at which images are served (e.g. '/images').
|
|
14
|
+
* Used by the webpack plugin to inject a __PLACE_ASSET_PATH__ constant
|
|
15
|
+
* that the React/Vue components use to strip the prefix from src before
|
|
16
|
+
* generating the CSS class name — ensuring it matches what the plugin
|
|
17
|
+
* generates from the file path relative to imageDir.
|
|
18
|
+
*
|
|
19
|
+
* Defaults to the basename of imageDir prefixed with '/' (e.g. '/images').
|
|
20
|
+
*/
|
|
21
|
+
assetPath?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Output path prefix prepended to the relative image path when generating
|
|
24
|
+
* CSS class names — must match the prefix used in assetModuleFilename so
|
|
25
|
+
* the class name the plugin writes into the SCSS matches what the component
|
|
26
|
+
* derives from the served URL at runtime.
|
|
27
|
+
*
|
|
28
|
+
* e.g. if assetModuleFilename outputs "images/story/foo.png" (served as
|
|
29
|
+
* "/images/story/foo.png"), set outputPathPrefix: 'images'
|
|
30
|
+
*/
|
|
31
|
+
outputPathPrefix?: string;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
export interface ImageDimensions {
|
|
@@ -31,8 +46,8 @@ export class PlaceBlockImagePlugin {
|
|
|
31
46
|
constructor(options: PlaceBlockImagePluginOptions) {
|
|
32
47
|
this.options = {
|
|
33
48
|
imagePrefix: 'image-',
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
outputPathPrefix: '',
|
|
50
|
+
assetPath: `/${path.basename(options.imageDir)}`,
|
|
36
51
|
...options
|
|
37
52
|
};
|
|
38
53
|
}
|
|
@@ -41,7 +56,12 @@ export class PlaceBlockImagePlugin {
|
|
|
41
56
|
* Generate CSS class name from filename - uses shared logic
|
|
42
57
|
*/
|
|
43
58
|
private generateClassName(filename: string): string {
|
|
44
|
-
|
|
59
|
+
// When outputPathPrefix is set the asset output is flat (just basename),
|
|
60
|
+
// so use only the basename to mirror the served URL: /assets/accent-orchid.png
|
|
61
|
+
const name = this.options.outputPathPrefix
|
|
62
|
+
? path.basename(filename)
|
|
63
|
+
: filename;
|
|
64
|
+
return generateImageClassName(name, this.options.imagePrefix, this.options.outputPathPrefix);
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
/**
|
|
@@ -130,35 +150,6 @@ export class PlaceBlockImagePlugin {
|
|
|
130
150
|
fs.writeFileSync(this.options.scssPath, scssContent);
|
|
131
151
|
}
|
|
132
152
|
|
|
133
|
-
/**
|
|
134
|
-
* Generate and write component file
|
|
135
|
-
*/
|
|
136
|
-
private async writeComponentFile(): Promise<void> {
|
|
137
|
-
if (!this.options.generateComponent || !this.options.componentPath || !this.options.componentType) {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const componentFileName = `PlaceBlockImage.${this.options.componentType}`;
|
|
142
|
-
const componentFilePath = path.resolve(this.options.componentPath, componentFileName);
|
|
143
|
-
|
|
144
|
-
// Check if component already exists
|
|
145
|
-
if (fs.existsSync(componentFilePath)) {
|
|
146
|
-
console.log(`📦 Component already exists, skipping: ${componentFilePath}`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const componentContent = getTemplate(this.options.componentType, this.options.imagePrefix);
|
|
151
|
-
const outputDir = path.dirname(componentFilePath);
|
|
152
|
-
|
|
153
|
-
// Ensure output directory exists
|
|
154
|
-
if (!fs.existsSync(outputDir)) {
|
|
155
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
fs.writeFileSync(componentFilePath, componentContent);
|
|
159
|
-
console.log(`📦 Generated component: ${componentFilePath}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
153
|
/**
|
|
163
154
|
* Check if images have changed since last generation
|
|
164
155
|
*/
|
|
@@ -188,6 +179,21 @@ export class PlaceBlockImagePlugin {
|
|
|
188
179
|
}
|
|
189
180
|
|
|
190
181
|
apply(compiler: Compiler): void {
|
|
182
|
+
// Inject @place-block-image alias so the Vue/React source components can
|
|
183
|
+
// resolve their shared utils/constants imports without requiring the
|
|
184
|
+
// consuming app to configure anything manually.
|
|
185
|
+
const pkgSrc = path.resolve(__dirname, '../src');
|
|
186
|
+
compiler.options.resolve.alias = {
|
|
187
|
+
...((compiler.options.resolve.alias as Record<string, string>) || {}),
|
|
188
|
+
'@place-block-image': pkgSrc,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Inject __PLUGIN_ASSET_PATH__ so the React/Vue components can strip the
|
|
192
|
+
// served URL prefix from src before generating the CSS class name.
|
|
193
|
+
new DefinePlugin({
|
|
194
|
+
__PLUGIN_ASSET_PATH__: JSON.stringify(this.options.assetPath),
|
|
195
|
+
}).apply(compiler);
|
|
196
|
+
|
|
191
197
|
// Use environment hook to run only once at startup
|
|
192
198
|
compiler.hooks.environment.tap('PlaceBlockImagePlugin', () => {
|
|
193
199
|
this.generateImageStyles();
|
|
@@ -240,8 +246,7 @@ export class PlaceBlockImagePlugin {
|
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
await this.writeScssFile(images);
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
|
|
245
250
|
console.log(`✅ Generated CSS custom properties for ${images.length} images`);
|
|
246
251
|
console.log(`📝 Output: ${this.options.scssPath}`);
|
|
247
252
|
|
package/tsconfig.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2020",
|
|
4
4
|
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2020"],
|
|
5
|
+
"lib": ["ES2020", "DOM"],
|
|
6
6
|
"outDir": "./dist",
|
|
7
7
|
"rootDir": "./src",
|
|
8
8
|
"strict": true,
|
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
"declarationMap": true,
|
|
14
14
|
"sourceMap": true,
|
|
15
15
|
"moduleResolution": "node",
|
|
16
|
-
"resolveJsonModule": true
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"jsx": "react",
|
|
18
|
+
"baseUrl": ".",
|
|
19
|
+
"paths": {
|
|
20
|
+
"@place-block-image/*": ["./src/*"]
|
|
21
|
+
}
|
|
17
22
|
},
|
|
18
23
|
"include": ["src/**/*"],
|
|
19
|
-
"exclude": ["node_modules", "dist"]
|
|
24
|
+
"exclude": ["node_modules", "dist", "src/vue"]
|
|
20
25
|
}
|
package/vite.config.mts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig, build, Plugin } from 'vite';
|
|
2
|
+
import { vueConfig } from './vite.vue.config.mts';
|
|
3
|
+
import { reactConfig } from './vite.react.config.mts';
|
|
4
|
+
|
|
5
|
+
// Runs the React build once after the Vue build completes.
|
|
6
|
+
// The instance-level flag prevents closeBundle from re-firing
|
|
7
|
+
// when the spawned React build itself reaches closeBundle.
|
|
8
|
+
function buildReact(): Plugin {
|
|
9
|
+
let done = false;
|
|
10
|
+
return {
|
|
11
|
+
name: 'build-react',
|
|
12
|
+
apply: 'build',
|
|
13
|
+
async closeBundle() {
|
|
14
|
+
if (done) return;
|
|
15
|
+
done = true;
|
|
16
|
+
await build({ configFile: false, ...reactConfig });
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Builds:
|
|
22
|
+
// src/vue/PlaceBlockImage.vue → dist/vue/PlaceBlockImage.{js,cjs} (via vite.vue.config.mts)
|
|
23
|
+
// src/react/PlaceBlockImage.tsx → dist/react/PlaceBlockImage.{js,cjs} (via vite.react.config.mts)
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
...vueConfig,
|
|
26
|
+
plugins: [...(vueConfig.plugins as Plugin[]), buildReact()],
|
|
27
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import type { UserConfig } from 'vite';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Builds src/react/PlaceBlockImage.tsx → dist/react/PlaceBlockImage.{js,cjs}
|
|
6
|
+
// React and its runtime are kept external — consumers supply their own.
|
|
7
|
+
export const reactConfig: UserConfig = {
|
|
8
|
+
build: {
|
|
9
|
+
outDir: 'dist/react',
|
|
10
|
+
emptyOutDir: true,
|
|
11
|
+
lib: {
|
|
12
|
+
entry: resolve(__dirname, 'src/react/PlaceBlockImage.tsx'),
|
|
13
|
+
name: 'PlaceBlockImage',
|
|
14
|
+
fileName: (format: string, name: string) => (format === 'es' ? `${name}.js` : `${name}.cjs`),
|
|
15
|
+
formats: ['es', 'cjs'],
|
|
16
|
+
},
|
|
17
|
+
rollupOptions: {
|
|
18
|
+
external: ['react', 'react/jsx-runtime'],
|
|
19
|
+
output: {
|
|
20
|
+
globals: { react: 'React' },
|
|
21
|
+
exports: 'named',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import vue from '@vitejs/plugin-vue';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import type { UserConfig } from 'vite';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Builds src/vue/PlaceBlockImage.vue → dist/vue/PlaceBlockImage.{js,cjs}
|
|
7
|
+
// Vue and its runtime are kept external — consumers supply their own.
|
|
8
|
+
export const vueConfig: UserConfig = {
|
|
9
|
+
plugins: [vue()],
|
|
10
|
+
build: {
|
|
11
|
+
outDir: 'dist/vue',
|
|
12
|
+
emptyOutDir: true,
|
|
13
|
+
lib: {
|
|
14
|
+
entry: resolve(__dirname, 'src/vue/PlaceBlockImage.vue'),
|
|
15
|
+
name: 'PlaceBlockImage',
|
|
16
|
+
fileName: (format: string, name: string) => (format === 'es' ? `${name}.js` : `${name}.cjs`),
|
|
17
|
+
formats: ['es', 'cjs'],
|
|
18
|
+
},
|
|
19
|
+
rollupOptions: {
|
|
20
|
+
external: ['vue'],
|
|
21
|
+
output: {
|
|
22
|
+
globals: { vue: 'Vue' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { getSharedReactTemplate } from './shared/react';
|
|
2
|
-
|
|
3
|
-
export function getReactJsxTemplate(imagePrefix: string): string {
|
|
4
|
-
const shared = getSharedReactTemplate(imagePrefix);
|
|
5
|
-
|
|
6
|
-
return `${shared.imports}
|
|
7
|
-
|
|
8
|
-
${shared.comment}
|
|
9
|
-
export const PlaceBlockImage = ({
|
|
10
|
-
src,
|
|
11
|
-
alt,
|
|
12
|
-
lazy = false,
|
|
13
|
-
className = '',
|
|
14
|
-
...props
|
|
15
|
-
}) => {
|
|
16
|
-
${shared.hooks}
|
|
17
|
-
|
|
18
|
-
${shared.getImageClassName}
|
|
19
|
-
|
|
20
|
-
${shared.classNames}
|
|
21
|
-
|
|
22
|
-
${shared.jsx}
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
${shared.export}
|
|
26
|
-
`;
|
|
27
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { getSharedReactTemplate } from './shared/react';
|
|
2
|
-
|
|
3
|
-
export function getReactTsxTemplate(imagePrefix: string): string {
|
|
4
|
-
const shared = getSharedReactTemplate(imagePrefix);
|
|
5
|
-
|
|
6
|
-
return `${shared.imports}
|
|
7
|
-
|
|
8
|
-
interface PlaceBlockImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
9
|
-
src: string;
|
|
10
|
-
alt: string;
|
|
11
|
-
lazy?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
${shared.comment}
|
|
15
|
-
export const PlaceBlockImage: React.FC<PlaceBlockImageProps> = ({
|
|
16
|
-
src,
|
|
17
|
-
alt,
|
|
18
|
-
lazy = false,
|
|
19
|
-
className = '',
|
|
20
|
-
...props
|
|
21
|
-
}) => {
|
|
22
|
-
${shared.hooks}
|
|
23
|
-
|
|
24
|
-
${shared.getImageClassName}
|
|
25
|
-
|
|
26
|
-
${shared.classNames}
|
|
27
|
-
|
|
28
|
-
${shared.jsx}
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
${shared.export}
|
|
32
|
-
`;
|
|
33
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { CLASS_NAMES } from '../../constants';
|
|
2
|
-
|
|
3
|
-
export const getSharedTemplate = (imagePrefix: string) => ({
|
|
4
|
-
// Common comment block
|
|
5
|
-
comment: `/**
|
|
6
|
-
* PlaceBlockImage component that prevents layout shift using CSS custom properties
|
|
7
|
-
* Generated by place-block-image webpack plugin
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* <PlaceBlockImage src="/images/logo.svg" alt="Logo" />
|
|
11
|
-
* <PlaceBlockImage src="/images/hero.jpg" alt="Hero" lazy={true} />
|
|
12
|
-
*
|
|
13
|
-
* This will automatically apply:
|
|
14
|
-
* - .${imagePrefix}wrapper class on picture (for dimensions)
|
|
15
|
-
* - .${imagePrefix}logo class on picture (specific dimensions via CSS custom properties)
|
|
16
|
-
* - ${CLASS_NAMES.LAZY}/${CLASS_NAMES.LAZY}.${CLASS_NAMES.LOADED} classes for lazy loading states
|
|
17
|
-
*/`,
|
|
18
|
-
|
|
19
|
-
// Common filename extraction logic (as string for interpolation)
|
|
20
|
-
getImageClassNameTemplate: `// Extract filename from src to generate class name
|
|
21
|
-
const getImageClassName = (imageSrc: string): string => {
|
|
22
|
-
// Remove /images/ prefix and file extension, convert to kebab-case
|
|
23
|
-
const filename = imageSrc
|
|
24
|
-
.replace(/^.*\\/images\\//, '') // Remove path up to /images/
|
|
25
|
-
.replace(/\\.[^/.]+$/, '') // Remove file extension
|
|
26
|
-
.toLowerCase()
|
|
27
|
-
.replace(/[^a-z0-9-]/g, '-') // Convert special chars to hyphens
|
|
28
|
-
.replace(/-+/g, '-') // Remove duplicate hyphens
|
|
29
|
-
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
30
|
-
|
|
31
|
-
return \`${imagePrefix}\${filename}\`;
|
|
32
|
-
};`,
|
|
33
|
-
|
|
34
|
-
// Common intersection observer logic
|
|
35
|
-
intersectionObserverTemplate: `const observer = new IntersectionObserver(
|
|
36
|
-
(entries) => {
|
|
37
|
-
entries.forEach((entry) => {
|
|
38
|
-
if (entry.isIntersecting) {
|
|
39
|
-
setImageSrc(src);
|
|
40
|
-
setIsLoaded(true);
|
|
41
|
-
observer.unobserve(entry.target);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
},
|
|
45
|
-
{ threshold: 0.1 }
|
|
46
|
-
);`
|
|
47
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { getSharedTemplate } from './index';
|
|
2
|
-
import { CLASS_NAMES } from '../../constants';
|
|
3
|
-
|
|
4
|
-
export const getSharedReactTemplate = (imagePrefix: string) => {
|
|
5
|
-
const shared = getSharedTemplate(imagePrefix);
|
|
6
|
-
|
|
7
|
-
return {
|
|
8
|
-
imports: `import React, { useRef, useEffect, useState } from 'react';`,
|
|
9
|
-
|
|
10
|
-
comment: shared.comment,
|
|
11
|
-
|
|
12
|
-
hooks: ` const imgRef = useRef(null);
|
|
13
|
-
const [imageSrc, setImageSrc] = useState(lazy ? '' : src);
|
|
14
|
-
const [isLoaded, setIsLoaded] = useState(!lazy);
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
if (!lazy || isLoaded) return;
|
|
18
|
-
|
|
19
|
-
${shared.intersectionObserverTemplate}
|
|
20
|
-
|
|
21
|
-
if (imgRef.current) {
|
|
22
|
-
observer.observe(imgRef.current);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return () => observer.disconnect();
|
|
26
|
-
}, [src, lazy, isLoaded]);`,
|
|
27
|
-
|
|
28
|
-
getImageClassName: shared.getImageClassNameTemplate,
|
|
29
|
-
|
|
30
|
-
classNames: ` const imageClassName = getImageClassName(src);
|
|
31
|
-
const wrapperClassName = \`${imagePrefix}wrapper \${imageClassName} \${className || ''}\`.trim();
|
|
32
|
-
|
|
33
|
-
// Build img className with lazy states
|
|
34
|
-
const lazyClass = lazy ? (isLoaded ? '${CLASS_NAMES.LAZY} ${CLASS_NAMES.LOADED}' : '${CLASS_NAMES.LAZY}') : '';
|
|
35
|
-
const imgClassName = \`${CLASS_NAMES.IMAGE_BLOCK} \${lazyClass}\`.trim();`,
|
|
36
|
-
|
|
37
|
-
jsx: ` return (
|
|
38
|
-
<picture className={wrapperClassName}>
|
|
39
|
-
<img
|
|
40
|
-
ref={imgRef}
|
|
41
|
-
src={imageSrc}
|
|
42
|
-
alt={alt}
|
|
43
|
-
className={imgClassName}
|
|
44
|
-
{...props}
|
|
45
|
-
/>
|
|
46
|
-
</picture>
|
|
47
|
-
);`,
|
|
48
|
-
|
|
49
|
-
export: `export default PlaceBlockImage;`
|
|
50
|
-
};
|
|
51
|
-
};
|