@profitlich/template-toolkit 1.0.0

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.
@@ -0,0 +1,124 @@
1
+ import './menu-toggle.scss';
2
+
3
+ export class MenuToggle {
4
+ static #instance;
5
+ #menuButton;
6
+ #menu;
7
+ #menuLinkClass;
8
+ #menuItemClass;
9
+ #scrollbarWidth;
10
+ #shiftElement;
11
+ #y;
12
+ #bodyClickHandler;
13
+ #resizeHandler;
14
+ #escapeHandler;
15
+ isActive;
16
+
17
+ constructor(menuButtonId, menuId, menuLinkClass, menuItemClass, shiftElementId) {
18
+ this.#menuButton = document.getElementById(menuButtonId);
19
+ this.#menu = document.getElementById(menuId);
20
+ this.#menuLinkClass = menuLinkClass;
21
+ this.#menuItemClass = menuItemClass;
22
+ this.#shiftElement = shiftElementId ? document.getElementById(shiftElementId) : null;
23
+ this.#scrollbarWidth = 0;
24
+ this.isActive = false;
25
+ this.#y = 0;
26
+
27
+ this.#bodyClickHandler = this.#onBodyClick.bind(this);
28
+ this.#resizeHandler = this.#onResize.bind(this);
29
+ this.#escapeHandler = this.#onEscape.bind(this);
30
+
31
+ this.#menuButton.addEventListener('click', (event) => {
32
+ event.preventDefault();
33
+ event.stopPropagation();
34
+ this.#toggleMenu();
35
+ });
36
+
37
+ this.#menu.addEventListener('click', (event) => {
38
+ if (this.isActive && event.target.matches(this.#menuLinkClass)) {
39
+ this.#toggleMenu();
40
+ }
41
+ });
42
+ }
43
+
44
+ static getInstance(menuButtonId, menuId, menuLinkClass, menuItemClass, shiftElementId) {
45
+ if (!MenuToggle.#instance) {
46
+ if (!menuButtonId || !menuId || !menuLinkClass || !menuItemClass) {
47
+ throw new Error("MenuToggle muss beim ersten Aufruf mit menuButtonId, menuId, menuLinkClass und menuItemClass initialisiert werden.");
48
+ }
49
+ MenuToggle.#instance = new MenuToggle(menuButtonId, menuId, menuLinkClass, menuItemClass, shiftElementId);
50
+ }
51
+ return MenuToggle.#instance;
52
+ }
53
+
54
+ #toggleMenu() {
55
+ if (this.isActive) {
56
+ this.isActive = false;
57
+ document.body.removeEventListener('click', this.#bodyClickHandler);
58
+ window.removeEventListener('resize', this.#resizeHandler);
59
+ this.#setBodyAttribute('data-menu-active', 'false');
60
+ if (this.#shiftElement) {
61
+ this.#shiftElement.style.marginRight = '';
62
+ this.#shiftElement.style.width = '';
63
+ }
64
+ document.body.style.paddingRight = '';
65
+ document.body.style.top = '';
66
+ window.scrollTo(0, this.#y);
67
+ this.#toggleEscape(false);
68
+ } else {
69
+ this.isActive = true;
70
+ document.body.addEventListener('click', this.#bodyClickHandler);
71
+ window.addEventListener('resize', this.#resizeHandler);
72
+ this.#scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
73
+ this.#y = window.scrollY;
74
+ this.#setBodyAttribute('data-menu-active', 'true');
75
+ document.body.style.paddingRight = `${this.#scrollbarWidth}px`;
76
+ document.body.style.top = `-${this.#y}px`;
77
+ if (this.#shiftElement) {
78
+ const marginOriginal = parseFloat(window.getComputedStyle(this.#menuButton).marginRight);
79
+ this.#shiftElement.style.marginRight = `${marginOriginal + this.#scrollbarWidth}px`;
80
+ this.#adjustShiftElementWidth();
81
+ }
82
+ this.#toggleEscape(true);
83
+ }
84
+ const event = new CustomEvent('eventMenuestatus', {
85
+ detail: { menueStatus: this.isActive }
86
+ });
87
+ document.dispatchEvent(event);
88
+ }
89
+
90
+ #adjustShiftElementWidth() {
91
+ const contentWidth = document.documentElement.clientWidth;
92
+ this.#shiftElement.style.width = `${contentWidth - this.#scrollbarWidth}px`;
93
+ }
94
+
95
+ #onBodyClick(event) {
96
+ if (this.isActive && !event.target.closest('.' + this.#menuItemClass)) {
97
+ this.#toggleMenu();
98
+ }
99
+ }
100
+
101
+ #onResize() {
102
+ if (this.#shiftElement) {
103
+ this.#adjustShiftElementWidth();
104
+ }
105
+ }
106
+
107
+ #setBodyAttribute(attr, value) {
108
+ document.body.setAttribute(attr, value);
109
+ }
110
+
111
+ #toggleEscape(status) {
112
+ if (status) {
113
+ document.addEventListener('keydown', this.#escapeHandler);
114
+ } else {
115
+ document.removeEventListener('keydown', this.#escapeHandler);
116
+ }
117
+ }
118
+
119
+ #onEscape(event) {
120
+ if (event.key === 'Escape') {
121
+ this.#toggleMenu();
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,14 @@
1
+ body {
2
+
3
+ &[data-menu-active="true"] {
4
+ position: fixed;
5
+ width: 100%;
6
+
7
+ // Bedeckt das Menü nur einen Teil der Seite, ist also ein Teil der Seite weiterhin zu sehen,
8
+ // soll die Maus Interaktionen ausserhalb des Menüs ignorieren
9
+ .main {
10
+ pointer-events: none;
11
+ }
12
+ }
13
+
14
+ }
@@ -0,0 +1,106 @@
1
+ import '@mux/mux-player';
2
+ import './mux-player.scss';
3
+
4
+ export class MuxPlayer {
5
+ #lazyLoadObserver;
6
+ #playPauseObserver;
7
+
8
+ constructor() {
9
+ this.#lazyLoadObserver = new IntersectionObserver(this.#handleLazyLoad.bind(this));
10
+ this.#playPauseObserver = new IntersectionObserver(this.#handlePlayPause.bind(this));
11
+ }
12
+
13
+ observeLazyElements() {
14
+ document.querySelectorAll('.mux-player').forEach(el => {
15
+ this.#lazyLoadObserver.observe(el);
16
+ });
17
+ }
18
+
19
+ #handleLazyLoad(entries) {
20
+ for (const entry of entries) {
21
+ if (entry.isIntersecting) {
22
+ this.#lazyLoadObserver.unobserve(entry.target);
23
+ const container = entry.target;
24
+ const playbackId = container.dataset.playbackId;
25
+ const aspectRatio = container.dataset.aspectRatio || '16 / 9';
26
+ const autoplay = container.dataset.autoplay === "true";
27
+
28
+ const player = document.createElement('mux-player');
29
+ player.playbackId = playbackId;
30
+ player.streamType = 'on-demand';
31
+ player.playsInline = true;
32
+ player.style.aspectRatio = aspectRatio;
33
+
34
+ // Autoplay-Attribut am Player-Element setzen, damit es später gefunden werden kann
35
+ if (autoplay) {
36
+ player.setAttribute('data-autoplay', 'true');
37
+ }
38
+
39
+ const wrapper = container.closest('[data-video-wrapper]');
40
+
41
+ // Wenn ein Wrapper gefunden wird, richte den MutationObserver ein
42
+ if (wrapper) {
43
+ const visibilityObserver = new MutationObserver(() => {
44
+ this.#handleVisibilityChange(player);
45
+ });
46
+
47
+ // Beobachte den gefundenen Wrapper
48
+ visibilityObserver.observe(wrapper, { attributes: true, childList: true, subtree: true });
49
+
50
+ // Container durch Player ersetzen BEVOR die Sichtbarkeitsprüfung
51
+ container.replaceWith(player);
52
+
53
+ // Führe eine initiale Prüfung durch, falls es schon sichtbar ist
54
+ this.#handleVisibilityChange(player);
55
+ } else if (autoplay) {
56
+ // Fallback für normale Autoplay-Videos ohne speziellen Wrapper
57
+ player.muted = true;
58
+ player.style.setProperty('--controls', 'none');
59
+ container.replaceWith(player);
60
+
61
+ player.addEventListener('loadeddata', () => {
62
+ this.#playPauseObserver.observe(player);
63
+ });
64
+ } else {
65
+ // Für Videos ohne Autoplay
66
+ container.replaceWith(player);
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ #handleVisibilityChange(player) {
73
+ // Prüfe direkt am Player, ob er durch CSS sichtbar ist
74
+ const isVisible = window.getComputedStyle(player).display !== 'none';
75
+
76
+ if (isVisible && player.hasAttribute('data-autoplay')) {
77
+ // Wenn der Player sichtbar ist und Autoplay haben soll
78
+ player.muted = true;
79
+ player.style.setProperty('--controls', 'none');
80
+
81
+ // Warte auf loadeddata bevor der IntersectionObserver aktiviert wird
82
+ if (player.readyState >= 2) { // HAVE_CURRENT_DATA oder höher
83
+ this.#playPauseObserver.observe(player);
84
+ } else {
85
+ player.addEventListener('loadeddata', () => {
86
+ this.#playPauseObserver.observe(player);
87
+ }, { once: true });
88
+ }
89
+ } else if (!isVisible) {
90
+ // Wenn der Player unsichtbar wird, pausiere ihn und stoppe die Beobachtung
91
+ this.#playPauseObserver.unobserve(player);
92
+ player.pause();
93
+ }
94
+ }
95
+
96
+ #handlePlayPause(entries) {
97
+ for (const entry of entries) {
98
+ const player = entry.target;
99
+ if (entry.isIntersecting) {
100
+ player.play().catch(e => console.error("Player-Fehler:", e));
101
+ } else {
102
+ player.pause();
103
+ }
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,3 @@
1
+ mux-player {
2
+ display: block;
3
+ }
package/dev/Dev.js ADDED
@@ -0,0 +1,19 @@
1
+ import { Toolbar } from './toolbar/Toolbar.js';
2
+ import { FormieFillIn } from './formie-fill-in/FormieFillIn.js';
3
+
4
+ let _config = {};
5
+
6
+ /**
7
+ * @param {Object} config - Aus config.json des Projekts
8
+ */
9
+ export function initDev(config = {}) {
10
+ _config = config;
11
+ }
12
+
13
+ document.addEventListener('DOMContentLoaded', () => {
14
+ new Toolbar();
15
+ if (_config.formie) {
16
+ new FormieFillIn(_config.formie);
17
+ }
18
+ console.info('Dev initialized.');
19
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * FormieFillIn – Füllt Formie-Formulare in der Entwicklungsumgebung automatisch aus.
3
+ *
4
+ * Initialisierung im Projekt-Dev-Script:
5
+ * import config from '../config.json';
6
+ * import { FormieFillIn } from 'profitlich-template-toolkit/dev/formie-fill-in/FormieFillIn';
7
+ * new FormieFillIn(config.formie);
8
+ *
9
+ * Konfiguration in config.json (zwei Ebenen: Form-Handle → Feld-Handle → Wert):
10
+ * "formie": {
11
+ * "kontaktformular": {
12
+ * "vorname": "Max",
13
+ * "email": "max@example.com",
14
+ * "nachricht": "Testanfrage"
15
+ * }
16
+ * }
17
+ */
18
+ export class FormieFillIn {
19
+ #formConfig;
20
+
21
+ /**
22
+ * @param {Object} formConfig - Aus config.json, z.B. { kontaktformular: { email: 'test@example.com' } }
23
+ */
24
+ constructor(formConfig) {
25
+ if (!formConfig || typeof formConfig !== 'object') return;
26
+
27
+ this.#formConfig = formConfig;
28
+ document.addEventListener('onFormieInit', this.#onFormieInit);
29
+ }
30
+
31
+ #onFormieInit = () => {
32
+ const forms = document.querySelectorAll('form[data-fui-form]');
33
+ forms.forEach((formElement) => {
34
+ const handle = this.#getFormHandle(formElement);
35
+ if (!handle || !this.#formConfig[handle]) return;
36
+
37
+ this.#fillForm(formElement, this.#formConfig[handle]);
38
+ console.info(`FormieFillIn: "${handle}" ausgefüllt.`);
39
+ });
40
+ };
41
+
42
+ #getFormHandle(formElement) {
43
+ try {
44
+ const data = JSON.parse(formElement.getAttribute('data-fui-form'));
45
+ return data?.handle ?? null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ #fillForm(formElement, fields) {
52
+ for (const [fieldHandle, value] of Object.entries(fields)) {
53
+ const name = `fields[${fieldHandle}]`;
54
+ const inputs = formElement.querySelectorAll(`[name="${name}"], [name="${name}[]"]`);
55
+
56
+ if (!inputs.length) {
57
+ console.warn(`FormieFillIn: Feld "${fieldHandle}" nicht gefunden.`);
58
+ continue;
59
+ }
60
+
61
+ this.#setFieldValue(inputs, value);
62
+ }
63
+ }
64
+
65
+ #setFieldValue(inputs, value) {
66
+ const first = inputs[0];
67
+ const type = first.type?.toLowerCase();
68
+
69
+ if (type === 'radio') {
70
+ inputs.forEach((radio) => {
71
+ radio.checked = radio.value === String(value);
72
+ if (radio.checked) radio.dispatchEvent(new Event('change', { bubbles: true }));
73
+ });
74
+ } else if (type === 'checkbox') {
75
+ if (inputs.length === 1) {
76
+ first.checked = Boolean(value);
77
+ } else {
78
+ // Checkbox-Gruppe: value ist Array mit den gewünschten Werten
79
+ const selected = Array.isArray(value) ? value.map(String) : [String(value)];
80
+ inputs.forEach((checkbox) => {
81
+ checkbox.checked = selected.includes(checkbox.value);
82
+ });
83
+ }
84
+ inputs.forEach((cb) => cb.dispatchEvent(new Event('change', { bubbles: true })));
85
+ } else {
86
+ first.value = value;
87
+ first.dispatchEvent(new Event('input', { bubbles: true }));
88
+ first.dispatchEvent(new Event('change', { bubbles: true }));
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,102 @@
1
+ import GUI from 'lil-gui';
2
+ import { MediaQueries } from '../../utils/MediaQueries.js';
3
+ import './toolbar.scss';
4
+
5
+ export class Toolbar {
6
+ #mediaQueries = MediaQueries.getInstance();
7
+ #gui;
8
+ #state;
9
+ #pictureElements;
10
+
11
+ constructor() {
12
+ // State aus localStorage laden
13
+ const saved = localStorage.getItem('devTools');
14
+ this.#state = saved
15
+ ? JSON.parse(saved)
16
+ : { visible: false, grid: 'aus', imageSize: false };
17
+
18
+ // Grid-Overlay DOM-Element erstellen
19
+ const gridOverlay = document.createElement('div');
20
+ gridOverlay.classList.add('dev-toolbar__grid');
21
+ document.body.prepend(gridOverlay);
22
+
23
+ // Picture-Elemente sammeln
24
+ this.#pictureElements = document.getElementsByTagName('picture');
25
+
26
+ // lil-gui initialisieren
27
+ this.#gui = new GUI({ title: this.#getViewportText() });
28
+
29
+ this.#gui.add(this.#state, 'grid', ['aus', 'lines', 'ribbons'])
30
+ .name('Grid')
31
+ .onChange(() => this.#onStateChange());
32
+
33
+ this.#gui.add(this.#state, 'imageSize')
34
+ .name('Bildgrössen')
35
+ .onChange(() => this.#onStateChange());
36
+
37
+ // State anwenden
38
+ this.#applyState();
39
+
40
+ // Panel ein-/ausblenden
41
+ if (!this.#state.visible) {
42
+ this.#gui.hide();
43
+ }
44
+
45
+ // Event-Listener
46
+ window.addEventListener('resize', this.#onResize);
47
+ document.addEventListener('keydown', this.#handleKeyDown);
48
+ }
49
+
50
+ #getViewportText() {
51
+ return `${this.#mediaQueries.layout} @ ${window.innerWidth}×${window.innerHeight}`;
52
+ }
53
+
54
+ #onStateChange() {
55
+ this.#applyState();
56
+ this.#saveState();
57
+ }
58
+
59
+ #applyState() {
60
+ const gridActive = this.#state.grid !== 'aus';
61
+ document.body.setAttribute('data-dev', String(gridActive));
62
+ document.body.setAttribute('data-dev-grid', this.#state.grid);
63
+ this.#updateImageSize();
64
+ }
65
+
66
+ #saveState() {
67
+ localStorage.setItem('devTools', JSON.stringify(this.#state));
68
+ }
69
+
70
+ #updateImageSize() {
71
+ if (!this.#pictureElements) return;
72
+ const pictures = Array.from(this.#pictureElements);
73
+ if (this.#state.imageSize) {
74
+ const windowWidth = window.innerWidth;
75
+ pictures.forEach((picture) => {
76
+ picture.setAttribute('data-size', `${Math.round(picture.offsetWidth / windowWidth * 100)}vw`);
77
+ });
78
+ } else {
79
+ pictures.forEach((picture) => {
80
+ picture.setAttribute('data-size', '');
81
+ });
82
+ }
83
+ }
84
+
85
+ #onResize = () => {
86
+ this.#gui.title(this.#getViewportText());
87
+ this.#updateImageSize();
88
+ }
89
+
90
+ #handleKeyDown = (event) => {
91
+ if (event.key === 'Control') {
92
+ event.preventDefault();
93
+ this.#state.visible = !this.#state.visible;
94
+ if (this.#state.visible) {
95
+ this.#gui.show();
96
+ } else {
97
+ this.#gui.hide();
98
+ }
99
+ this.#saveState();
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,102 @@
1
+ @use "sass:map";
2
+ @use "../../scss/forward" as *;
3
+
4
+ // grid
5
+ $color--dev-grid: rgba(0, 0, 255, 0.5);
6
+ $color--dev-grid-ribbons: rgba(191, 255, 254, 0.5);
7
+ $color--dev-grid-center: rgba(0, 0, 0, 0.2);
8
+
9
+
10
+ @mixin dev-toolbar-grid($layout, $columns, $gutter, $margin-left, $margin-right) {
11
+
12
+ $gutter: size($layout, $gutter);
13
+ $margin-left: size($layout, $margin-left);
14
+ $margin-right: size($layout, $margin-right);
15
+
16
+ body[data-dev='true'] {
17
+ .dev-toolbar__grid::after {
18
+ // the grid is made through repition
19
+ // 'important' because background settings are more specific than the grid settings
20
+ background-size: calc((100% - ($columns * $gutter)) * calc(1 / $columns) + $gutter) 1px !important;
21
+ margin: 0 ($margin-right - calc($gutter / 2)) 0 ($margin-left - calc($gutter / 2));
22
+ // The grid is calculated starting from the middle
23
+ // Thats why its necessary to add the half of the column gap to the left and right side
24
+ width: calc(100% - $margin-right + calc($gutter / 2) - $margin-left + calc($gutter / 2));
25
+ }
26
+ }
27
+
28
+ body[data-dev='true'][data-dev-grid="lines"] {
29
+ .dev-toolbar__grid::after {
30
+ background:
31
+ // center of the column spacing
32
+ linear-gradient(90deg, transparent 0.5px, transparent 0.5px) 0 0,
33
+ // Left column line
34
+ // position: from the column spacing center to the right by half a column spacing
35
+ linear-gradient(90deg, $color--dev-grid 0.5px, transparent 0.5px) calc($gutter / 2) 0,
36
+ // right column line
37
+ linear-gradient(90deg, $color--dev-grid 0.5px, transparent 0.5px) calc($gutter / 2 * -1) 0;
38
+ }
39
+ }
40
+
41
+ body[data-dev='true'][data-dev-grid="ribbons"] {
42
+ .dev-toolbar__grid::after {
43
+ background:
44
+ // ceft column ribbon
45
+ // position: from the column spacing center to the right by half a column spacing
46
+ linear-gradient(90deg, $color--dev-grid-ribbons calc(100% - $gutter), transparent 0.5px) calc($gutter / 2) 0,
47
+ }
48
+ }
49
+ }
50
+
51
+ // lil-gui z-index
52
+ .lil-gui.root {
53
+ z-index: 9999999;
54
+ }
55
+
56
+ .dev-toolbar__grid {
57
+ display: none;
58
+ width: 100%;
59
+ height: 100%;
60
+ left: 0;
61
+ pointer-events: none;
62
+ position: fixed;
63
+ top: 0;
64
+
65
+ body[data-dev='true'] & {
66
+ display: block;
67
+ }
68
+
69
+ body[data-dev-grid='lines'] & {
70
+ z-index: 9999999;
71
+ }
72
+
73
+ &::after {
74
+ content: "";
75
+ height: 100%;
76
+ left: 0;
77
+ position: absolute;
78
+ top: 0;
79
+ }
80
+ }
81
+
82
+ @each $layout, $breite in $layouts {
83
+ @include mediaquery($layout) using ($layout) {
84
+ @include dev-toolbar-grid($layout, map.get($columns, $layout), map.get($gutter, $layout), map.get(map.get($margins, $layout), 'left'), map.get(map.get($margins, $layout), 'right'));
85
+ }
86
+ }
87
+
88
+ picture {
89
+ position: relative;
90
+
91
+ &::before{
92
+ content: attr(data-size);
93
+ font-family: sans-serif;
94
+ position: absolute;
95
+ top: 10px;
96
+ left: 10px;
97
+ color: white;
98
+ font-size: 20px;
99
+ text-shadow: 0 0 10px black;
100
+ z-index: 10;
101
+ }
102
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@profitlich/template-toolkit",
3
+ "version": "1.0.0",
4
+ "description": "Shared SCSS layout system, JS utilities, components and build scripts for profitlich template repos",
5
+ "type": "module",
6
+ "exports": {
7
+ "./dev": "./dev/Dev.js",
8
+ "./dev/toolbar": "./dev/toolbar/Toolbar.js",
9
+ "./dev/formie-fill-in/FormieFillIn": "./dev/formie-fill-in/FormieFillIn.js",
10
+ "./scss/forward": "./scss/forward.scss",
11
+ "./scss/core/layout": "./scss/core/layout.scss",
12
+ "./scss/core/mediaqueries": "./scss/core/mediaqueries.scss",
13
+ "./scss/core/hamburger": "./scss/core/hamburger.scss",
14
+ "./utils/MediaQueries": "./utils/MediaQueries.js",
15
+ "./utils/Vh100": "./utils/Vh100.js",
16
+ "./utils/BodyScrolled": "./utils/BodyScrolled.js",
17
+ "./components/menu-toggle/MenuToggle": "./components/menu-toggle/MenuToggle.js",
18
+ "./components/mux-player/MuxPlayer": "./components/mux-player/MuxPlayer.js",
19
+ "./scripts/copy-files": "./scripts/copy-files.js",
20
+ "./scripts/deploy": "./scripts/deploy.js"
21
+ },
22
+ "files": [
23
+ "dev/",
24
+ "scss/",
25
+ "utils/",
26
+ "components/",
27
+ "scripts/"
28
+ ],
29
+ "dependencies": {
30
+ "basic-ftp": "^5.0.0",
31
+ "chokidar": "^4.0.0",
32
+ "cli-progress": "^3.12.0",
33
+ "dotenv": "^17.0.0",
34
+ "fs-extra": "^11.3.0",
35
+ "glob": "^11.0.0",
36
+ "lil-gui": "^0.21.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@mux/mux-player": ">=3.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@mux/mux-player": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/profitlich-ch/profitlich-template-toolkit.git"
49
+ },
50
+ "license": "MIT"
51
+ }
@@ -0,0 +1,74 @@
1
+ import fs from 'fs-extra';
2
+ import { glob } from 'glob';
3
+ import path from 'path';
4
+ import chokidar from 'chokidar';
5
+
6
+ async function runTask(task) {
7
+ const files = await glob(task.src, { nodir: true, dot: true });
8
+ if (files.length === 0) return;
9
+
10
+ for (const file of files) {
11
+ const relativePath = path.relative(task.base, file);
12
+ const destPath = path.join(task.dest, relativePath);
13
+ await fs.copy(file, destPath);
14
+ }
15
+ }
16
+
17
+ export async function copyAll(copyTasks) {
18
+ console.log('🚀 Starting initial copy of all files...');
19
+ // empty folders to prevent orphaned files
20
+ for (const task of copyTasks) {
21
+ await fs.emptyDir(task.dest);
22
+ }
23
+ // run tasks parallely
24
+ await Promise.all(copyTasks.map(task => runTask(task)));
25
+ console.log('✅ Initial copy complete.');
26
+ }
27
+
28
+ export function watchFiles(copyTasks, watchDir = 'src') {
29
+ console.log(`👀 Watching for file changes in ${watchDir}/`);
30
+
31
+ // set lock variable to prevent parallel copy tasks
32
+ let isCopying = false;
33
+
34
+ const watcher = chokidar.watch(watchDir, { ignored: /(^|[\/\\])\../, persistent: true });
35
+
36
+ watcher.on('all', async (event, filePath) => {
37
+ if (['add', 'change', 'unlink'].includes(event)) {
38
+
39
+ // only proceed if copy job is not running
40
+ if (isCopying) {
41
+ return;
42
+ }
43
+
44
+ console.log(`[${event}] ${filePath}. Re-copying all files...`);
45
+
46
+ // secure copy job with try...finally
47
+ try {
48
+ isCopying = true; // activate lock
49
+ await copyAll(copyTasks);
50
+ } catch (err) {
51
+ console.error("Error during copy:", err);
52
+ } finally {
53
+ isCopying = false; // deactivate lock, allow new copy job
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Run the copy-files script.
61
+ * @param {Array} copyTasks - array of { name, src, dest, base } objects
62
+ * @param {object} [options] - optional settings
63
+ * @param {string} [options.watchDir] - directory to watch (default: 'src')
64
+ */
65
+ export function run(copyTasks, options = {}) {
66
+ const command = process.argv[2];
67
+ const watchDir = options.watchDir || 'src';
68
+
69
+ if (command === 'dev') {
70
+ copyAll(copyTasks).then(() => watchFiles(copyTasks, watchDir));
71
+ } else if (command === 'build') {
72
+ copyAll(copyTasks);
73
+ }
74
+ }