@place-framework/place-block-image 1.0.1 → 1.0.2

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.
Files changed (48) hide show
  1. package/dist/constants/index.d.ts +2 -2
  2. package/dist/constants/index.d.ts.map +1 -1
  3. package/dist/constants/index.js +4 -5
  4. package/dist/constants/index.js.map +1 -1
  5. package/dist/generate-components.d.ts +16 -0
  6. package/dist/generate-components.d.ts.map +1 -0
  7. package/dist/generate-components.js +93 -0
  8. package/dist/generate-components.js.map +1 -0
  9. package/dist/react/PlaceBlockImage.cjs +1 -0
  10. package/dist/react/PlaceBlockImage.js +67 -0
  11. package/dist/templates/shared/index.d.ts.map +1 -1
  12. package/dist/templates/shared/index.js +8 -16
  13. package/dist/templates/shared/index.js.map +1 -1
  14. package/dist/templates/shared/vue.d.ts +6 -0
  15. package/dist/templates/shared/vue.d.ts.map +1 -0
  16. package/dist/templates/shared/vue.js +26 -0
  17. package/dist/templates/shared/vue.js.map +1 -0
  18. package/dist/templates/vue.d.ts.map +1 -1
  19. package/dist/templates/vue.js +14 -19
  20. package/dist/templates/vue.js.map +1 -1
  21. package/dist/utils/index.d.ts +61 -14
  22. package/dist/utils/index.d.ts.map +1 -1
  23. package/dist/utils/index.js +108 -40
  24. package/dist/utils/index.js.map +1 -1
  25. package/dist/vue/PlaceBlockImage.cjs +1 -0
  26. package/dist/vue/PlaceBlockImage.js +83 -0
  27. package/dist/webpack-plugin.d.ts +20 -7
  28. package/dist/webpack-plugin.d.ts.map +1 -1
  29. package/dist/webpack-plugin.js +22 -28
  30. package/dist/webpack-plugin.js.map +1 -1
  31. package/package.json +36 -10
  32. package/src/constants/index.ts +5 -6
  33. package/src/react/PlaceBlockImage.tsx +63 -0
  34. package/src/react/index.ts +1 -0
  35. package/src/utils/index.ts +121 -44
  36. package/src/vue/PlaceBlockImage.vue +98 -0
  37. package/src/vue/index.ts +1 -0
  38. package/src/webpack-plugin.ts +45 -40
  39. package/tsconfig.json +8 -3
  40. package/vite.config.mts +27 -0
  41. package/vite.react.config.mts +25 -0
  42. package/vite.vue.config.mts +26 -0
  43. package/src/templates/react-jsx.ts +0 -27
  44. package/src/templates/react-tsx.ts +0 -33
  45. package/src/templates/shared/index.ts +0 -47
  46. package/src/templates/shared/react.ts +0 -51
  47. package/src/templates/vue.ts +0 -98
  48. package/src/templates.ts +0 -29
@@ -1,53 +1,130 @@
1
- // Shared template utilities
1
+ // Shared utilities
2
2
 
3
- export interface TemplateData {
4
- imagePrefix: string;
5
- baseClassName: string;
6
- wrapperClassName: string;
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
- export function getTemplateData(imagePrefix: string): TemplateData {
10
- return {
11
- imagePrefix,
12
- baseClassName: `${imagePrefix}block`,
13
- wrapperClassName: `${imagePrefix}wrapper`
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
- * Generate CSS class name from file path - shared logic for both webpack plugin and templates
19
- * @param filePath - The file path (can be relative path from images dir or full path)
20
- * @param imagePrefix - The prefix to add to the class name
21
- * @returns The generated CSS class name
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 generateImageClassName(filePath: string, imagePrefix: string): string {
24
- // Remove file extension and convert to kebab-case
25
- const cleanName = filePath
26
- .replace(/\.[^/.]+$/, '') // Remove file extension
27
- .toLowerCase()
28
- .replace(/[^a-z0-9-]/g, '-') // Convert special chars to hyphens
29
- .replace(/-+/g, '-') // Remove duplicate hyphens
30
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
31
-
32
- return `${imagePrefix}${cleanName}`;
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>
@@ -0,0 +1 @@
1
+ export { default as PlaceBlockImage } from './PlaceBlockImage.vue';
@@ -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
- // Component generation options
14
- componentPath?: string;
15
- componentType?: 'tsx' | 'jsx' | 'vue';
16
- generateComponent?: boolean;
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
- generateComponent: true,
35
- componentType: 'tsx',
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
- return generateImageClassName(filename, this.options.imagePrefix);
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
- await this.writeComponentFile();
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
  }
@@ -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
- };