Merge iframe: complete web-to-print architecture with Pinia

Merges the iframe branch which implements a clean, scalable architecture:
- Iframe-based PagedJS preview with reactive CSS editing
- Pinia store for centralized stylesheet management
- Autonomous components (EditorPanel, ElementPopup) with no prop drilling
- CSS parsing utilities with proper selector escaping
- Clean separation of concerns with functional domain organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
isUnknown 2025-11-24 18:20:47 +01:00
commit 913e41190c
10 changed files with 383 additions and 160 deletions

136
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {
@ -920,6 +921,39 @@
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
@ -970,12 +1004,36 @@
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/clear-cut": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz",
"integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==",
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
@ -1192,6 +1250,24 @@
"node": ">=12.0.0"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1207,6 +1283,12 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -1244,6 +1326,12 @@
"event-emitter": "^0.3.5"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1263,6 +1351,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -1297,6 +1406,12 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@ -1357,6 +1472,27 @@
"node": ">=0.10.0"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": {
"highlight.js": "^11.11.1",
"pagedjs": "^0.4.3",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {

View file

@ -1,7 +1,7 @@
.about {
font-size: 1rem;
}
#chapter-2 {
font-size: 2rem;
}
p {
font-size: 1rem;
}

View file

@ -4,22 +4,16 @@ import EditorPanel from './components/EditorPanel.vue';
import StylesheetViewer from './components/StylesheetViewer.vue';
import ElementPopup from './components/ElementPopup.vue';
import { onMounted, ref, watch } from 'vue';
import { useStylesheetStore } from './stores/stylesheet';
// Main state
const previewFrame = ref(null);
const stylesheetContent = ref('');
const aboutFontSize = ref(2);
const aboutFontSizeUnit = ref('rem');
// ============================================================================
// Store
// ============================================================================
const stylesheetStore = useStylesheetStore();
// Popup state
const popupVisible = ref(false);
const popupPosition = ref({ x: 0, y: 0 });
const popupSelector = ref('');
const popupElementCss = ref('');
const popupFontSize = ref(null);
const popupFontSizeUnit = ref('rem');
// PagedJS print rules
// ============================================================================
// PagedJS configuration
// ============================================================================
const printStyles = `
h2 { break-before: page; }
@ -35,25 +29,11 @@ h2 { break-before: page; }
.chapter > h2 { string-set: title content(text); }
`;
// CSS parsing utilities
const extractCssBlock = (css, selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi'));
return match ? match[0] : '';
};
// ============================================================================
// Iframe preview
// ============================================================================
const previewFrame = ref(null);
const extractCssValue = (css, selector, property) => {
const regex = new RegExp(`${selector}\\s*{[^}]*${property}:\\s*([\\d.]+)(px|rem|em)`, 'i');
const match = css.match(regex);
return match ? { value: parseFloat(match[1]), unit: match[2] } : null;
};
const updateCssValue = (selector, property, value, unit) => {
const regex = new RegExp(`(${selector}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em)`, 'gi');
stylesheetContent.value = stylesheetContent.value.replace(regex, `$1${value}${unit}`);
};
// Iframe style injection
const injectStylesToIframe = () => {
const iframe = previewFrame.value;
if (!iframe?.contentDocument) return;
@ -64,93 +44,39 @@ const injectStylesToIframe = () => {
styleElement.id = 'dynamic-styles';
iframe.contentDocument.head.appendChild(styleElement);
}
styleElement.textContent = stylesheetContent.value;
styleElement.textContent = stylesheetStore.content;
};
// Popup handlers
const handleIframeClick = (event) => {
const element = event.target;
const elementPopup = ref(null);
if (element.tagName === 'BODY' || element.tagName === 'HTML') {
popupVisible.value = false;
return;
}
const selector = element.id
? `#${element.id}`
: `.${element.className.split(' ')[0]}`;
popupSelector.value = selector;
popupElementCss.value = extractCssBlock(stylesheetContent.value, selector);
const fontSizeData = extractCssValue(stylesheetContent.value, selector, 'font-size');
popupFontSize.value = fontSizeData?.value ?? null;
popupFontSizeUnit.value = fontSizeData?.unit ?? 'rem';
const rect = element.getBoundingClientRect();
const iframeRect = previewFrame.value.getBoundingClientRect();
popupPosition.value = {
x: iframeRect.left + rect.left,
y: iframeRect.top + rect.bottom + 5
};
popupVisible.value = true;
};
const closePopup = () => {
popupVisible.value = false;
};
const updatePopupFontSize = (newValue) => {
updateCssValue(popupSelector.value, 'font-size', newValue, popupFontSizeUnit.value);
popupFontSize.value = newValue;
popupElementCss.value = extractCssBlock(stylesheetContent.value, popupSelector.value);
};
// Watchers
watch(aboutFontSize, (newVal) => {
updateCssValue('.about', 'font-size', newVal, aboutFontSizeUnit.value);
});
watch(stylesheetContent, injectStylesToIframe);
// Initial render
const renderPreview = async () => {
const iframe = previewFrame.value;
if (!iframe) return;
const response = await fetch('/assets/css/stylesheet.css');
stylesheetContent.value = await response.text();
await stylesheetStore.loadStylesheet();
const initialFontSize = extractCssValue(stylesheetContent.value, '.about', 'font-size');
if (initialFontSize) {
aboutFontSize.value = initialFontSize.value;
aboutFontSizeUnit.value = initialFontSize.unit;
}
const contentSource = document.getElementById('content-source');
const iframeDoc = iframe.contentDocument;
iframeDoc.open();
iframeDoc.write(`
iframe.srcdoc = `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/css/pagedjs-interface.css">
<style id="dynamic-styles">${stylesheetContent.value}</style>
<style id="dynamic-styles">${stylesheetStore.content}</style>
<style>${printStyles}</style>
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"><\/script>
</head>
<body>${contentSource.innerHTML}</body>
<body>${document.getElementById('content-source').innerHTML}</body>
</html>
`);
iframeDoc.close();
`;
iframe.onload = () => {
iframe.contentDocument.addEventListener('click', handleIframeClick);
iframe.contentDocument.addEventListener('click', elementPopup.value.handleIframeClick);
};
};
// ============================================================================
// Lifecycle
// ============================================================================
watch(() => stylesheetStore.content, injectStylesToIframe);
onMounted(renderPreview);
</script>
@ -159,26 +85,13 @@ onMounted(renderPreview);
<PagedJsWrapper />
</div>
<EditorPanel
:fontSize="aboutFontSize"
:unit="aboutFontSizeUnit"
@update:fontSize="aboutFontSize = $event"
/>
<EditorPanel />
<iframe ref="previewFrame" id="preview-frame"></iframe>
<StylesheetViewer :stylesheet="stylesheetContent" />
<StylesheetViewer :stylesheet="stylesheetStore.content" />
<ElementPopup
:visible="popupVisible"
:position="popupPosition"
:selector="popupSelector"
:elementCss="popupElementCss"
:currentFontSize="popupFontSize"
:fontSizeUnit="popupFontSizeUnit"
@close="closePopup"
@update:fontSize="updatePopupFontSize"
/>
<ElementPopup ref="elementPopup" :iframeRef="previewFrame" />
</template>
<style>

View file

@ -2,39 +2,36 @@
<aside id="editor-panel">
<h3>Éditeur</h3>
<div class="control">
<label>Font-size .about</label>
<label>Taille de police des paragraphes</label>
<input
type="number"
step="0.1"
v-model.number="localFontSize"
@input="updateFontSize"
:value="fontSizeData?.value ?? 1"
@input="updateFontSize(parseFloat($event.target.value))"
/>
<span>{{ unit }}</span>
<span>{{ fontSizeData?.unit ?? 'rem' }}</span>
</div>
</aside>
</template>
<script setup>
import { ref, watch } from 'vue';
import { computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
const props = defineProps({
fontSize: Number,
unit: String,
const stylesheetStore = useStylesheetStore();
const fontSizeData = computed(() => {
return stylesheetStore.extractValue('p', 'font-size');
});
const emit = defineEmits(['update:fontSize']);
const localFontSize = ref(props.fontSize);
watch(
() => props.fontSize,
(newVal) => {
localFontSize.value = newVal;
}
const updateFontSize = (value) => {
if (!fontSizeData.value) return;
stylesheetStore.updateProperty(
'p',
'font-size',
value,
fontSizeData.value.unit
);
const updateFontSize = () => {
emit('update:fontSize', localFontSize.value);
};
</script>

View file

@ -6,19 +6,19 @@
>
<div class="popup-header">
<span>{{ selector }}</span>
<button class="close-btn" @click="$emit('close')">×</button>
<button class="close-btn" @click="close">×</button>
</div>
<div class="popup-body">
<div class="popup-controls">
<div class="control" v-if="currentFontSize !== null">
<div class="control" v-if="fontSizeData">
<label>font-size</label>
<input
type="number"
step="0.1"
:value="currentFontSize"
@input="$emit('update:fontSize', parseFloat($event.target.value))"
:value="fontSizeData.value"
@input="updateFontSize(parseFloat($event.target.value))"
/>
<span>{{ fontSizeUnit }}</span>
<span>{{ fontSizeData.unit }}</span>
</div>
<p v-else class="no-styles">Aucun style éditable</p>
</div>
@ -30,28 +30,86 @@
</template>
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet';
import hljs from 'highlight.js/lib/core';
import css from 'highlight.js/lib/languages/css';
import 'highlight.js/styles/github.css';
hljs.registerLanguage('css', css);
const stylesheetStore = useStylesheetStore();
const props = defineProps({
visible: Boolean,
position: Object,
selector: String,
elementCss: String,
currentFontSize: Number,
fontSizeUnit: String
iframeRef: Object
});
defineEmits(['close', 'update:fontSize']);
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const selector = ref('');
const getSelectorFromElement = (element) => {
return element.id
? `#${element.id}`
: `.${element.className.split(' ')[0]}`;
};
const calculatePosition = (element) => {
const rect = element.getBoundingClientRect();
const iframeRect = props.iframeRef.getBoundingClientRect();
return {
x: iframeRect.left + rect.left,
y: iframeRect.top + rect.bottom + 5,
};
};
const open = (element) => {
selector.value = getSelectorFromElement(element);
position.value = calculatePosition(element);
visible.value = true;
};
const close = () => {
visible.value = false;
};
const handleIframeClick = (event) => {
const element = event.target;
if (element.tagName === 'BODY' || element.tagName === 'HTML') {
close();
return;
}
open(element);
};
const elementCss = computed(() => {
if (!selector.value) return '';
return stylesheetStore.extractBlock(selector.value);
});
const fontSizeData = computed(() => {
if (!selector.value) return null;
return stylesheetStore.extractValue(selector.value, 'font-size');
});
const highlightedCss = computed(() => {
if (!props.elementCss) return '<span class="no-css">Aucun style défini</span>';
return hljs.highlight(props.elementCss, { language: 'css' }).value;
if (!elementCss.value) return '<span class="no-css">Aucun style défini</span>';
return hljs.highlight(elementCss.value, { language: 'css' }).value;
});
const updateFontSize = (value) => {
if (!fontSizeData.value) return;
stylesheetStore.updateProperty(
selector.value,
'font-size',
value,
fontSizeData.value.unit
);
};
defineExpose({ handleIframeClick });
</script>
<style scoped>

View file

@ -1,6 +1,6 @@
<template>
<section class="chapter">
<p class="about">
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex
@ -14,7 +14,7 @@
condimentum morbi eros amet portaest sit quam a hendrerit fusce quam
tristique arcu id maximus nunc fusce suspendisse.
</p>
<p class="about">
<p>
Accumsan arcu tristique purus eros pellentesque rutrum hendrerit phasellus
euismod maximus rutrum vivamus dolor erat sollicitudin ut quam metus
gravida proin nisl lacus sed lacus. Morbi fusce eros euismod varius ex

View file

@ -1,5 +1,8 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import './style.css';
import App from './App.vue';
createApp(App).mount('#app')
const app = createApp(App);
app.use(createPinia());
app.mount('#app');

38
src/stores/stylesheet.js Normal file
View file

@ -0,0 +1,38 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import cssParsingUtils from '../utils/css-parsing';
export const useStylesheetStore = defineStore('stylesheet', () => {
const content = ref('');
const loadStylesheet = async () => {
const response = await fetch('/assets/css/stylesheet.css');
content.value = await response.text();
};
const updateProperty = (selector, property, value, unit) => {
content.value = cssParsingUtils.updateCssValue({
css: content.value,
selector,
property,
value,
unit
});
};
const extractValue = (selector, property) => {
return cssParsingUtils.extractCssValue(content.value, selector, property);
};
const extractBlock = (selector) => {
return cssParsingUtils.extractCssBlock(content.value, selector);
};
return {
content,
loadStylesheet,
updateProperty,
extractValue,
extractBlock
};
});

77
src/utils/css-parsing.js Normal file
View file

@ -0,0 +1,77 @@
/**
* CSS parsing utilities for extracting and manipulating CSS rules
* @module css-parsing
*/
/**
* Extracts a complete CSS block for a given selector
* @param {string} css - The CSS stylesheet content
* @param {string} selector - The CSS selector to find (e.g., '@page', '.my-class')
* @returns {string} The matched CSS block including selector and braces, or empty string if not found
* @example
* const css = '@page { margin: 20mm; }';
* extractCssBlock(css, '@page'); // '@page { margin: 20mm; }'
*/
const extractCssBlock = (css, selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*{[^}]*}`, 'gi'));
return match ? match[0] : '';
};
/**
* Extracts a CSS property value and its unit from a stylesheet
* @param {string} css - The CSS stylesheet content
* @param {string} selector - The CSS selector to search within
* @param {string} property - The CSS property name to extract
* @returns {{value: number, unit: string}|null} Object with numeric value and unit, or null if not found
* @example
* const css = '@page { margin: 20mm; }';
* extractCssValue(css, '@page', 'margin'); // { value: 20, unit: 'mm' }
*/
const extractCssValue = (css, selector, property) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`${escaped}\\s*{[^}]*${property}:\\s*([\\d.]+)(px|rem|em|mm|cm|in)`,
'i'
);
const match = css.match(regex);
return match ? { value: parseFloat(match[1]), unit: match[2] } : null;
};
/**
* Updates a CSS property value in a stylesheet
* @param {Object} options - Configuration object
* @param {string} options.css - The CSS stylesheet content to modify
* @param {string} options.selector - The CSS selector to target
* @param {string} options.property - The CSS property to update
* @param {number} options.value - The new numeric value
* @param {string} options.unit - The CSS unit (px, rem, em, mm, cm, in, etc.)
* @returns {string} The modified CSS stylesheet content
* @example
* updateCssValue({
* css: '@page { margin: 20mm; }',
* selector: '@page',
* property: 'margin',
* value: 30,
* unit: 'mm'
* }); // '@page { margin: 30mm; }'
*/
const updateCssValue = ({ css, selector, property, value, unit }) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`(${escaped}\\s*{[^}]*${property}:\\s*)[\\d.]+(px|rem|em|mm|cm|in)`,
'gi'
);
return css.replace(regex, `$1${value}${unit}`);
};
/**
* Collection of CSS parsing utilities
* @type {Object}
* @property {Function} extractCssBlock - Extract a CSS block by selector
* @property {Function} extractCssValue - Extract a property value with unit
* @property {Function} updateCssValue - Update a property value in CSS
*/
const cssParsingUtils = { extractCssBlock, extractCssValue, updateCssValue };
export default cssParsingUtils;