@schalkneethling/miyagi-core 4.0.2 → 4.1.1

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.
@@ -380,6 +380,19 @@ export default () => {
380
380
  });
381
381
  }
382
382
 
383
+ const sharedCss = (assetsConfig.shared?.css || []).map((file) =>
384
+ path.join(
385
+ assetsConfig.root,
386
+ typeof file === "string" ? file : file.src,
387
+ ),
388
+ );
389
+ const sharedJs = (assetsConfig.shared?.js || []).map((file) =>
390
+ path.join(
391
+ assetsConfig.root,
392
+ typeof file === "string" ? file : file.src,
393
+ ),
394
+ );
395
+
383
396
  const cssJsFiles = [
384
397
  ...new Set([
385
398
  ...assetsConfig.css.map((file) =>
@@ -394,6 +407,8 @@ export default () => {
394
407
  typeof file === "string" ? file : file.src,
395
408
  ),
396
409
  ),
410
+ ...sharedCss,
411
+ ...sharedJs,
397
412
  ...assetsConfig.customProperties.files.map((file) =>
398
413
  path.join(assetsConfig.root, file),
399
414
  ),
@@ -655,6 +670,7 @@ export default () => {
655
670
  res: global.app,
656
671
  component,
657
672
  componentData: data.resolved,
673
+ componentDeclaredAssets: data.$assets || null,
658
674
  cb: async (err, response) => {
659
675
  if (err) {
660
676
  if (typeof err === "string") {
@@ -0,0 +1,159 @@
1
+ // @ts-check
2
+
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+ import log from "../logger.js";
7
+ import { loadAssetsConfig } from "../drupal/load-assets-config.js";
8
+ import {
9
+ parseLibrariesYaml,
10
+ resolveComponentAssets,
11
+ mapLibraryToComponent,
12
+ } from "../drupal/resolve-library-assets.js";
13
+
14
+ /**
15
+ * @typedef {{src: string, type?: string}} JsEntry
16
+ * @typedef {{css: string[], js: JsEntry[]}} ComponentAssets
17
+ */
18
+
19
+ /**
20
+ * @param {object} args - CLI arguments from yargs
21
+ */
22
+ export default async function drupalAssets(args) {
23
+ let config;
24
+ try {
25
+ config = await loadAssetsConfig(args);
26
+ } catch (err) {
27
+ log("error", /** @type {Error} */ (err).message);
28
+ process.exit(1);
29
+ }
30
+
31
+ if (!config.libraries) {
32
+ log("error", "No libraries file specified. Use --libraries or configure it in .miyagi-assets.js.");
33
+ process.exit(1);
34
+ }
35
+
36
+ let yamlContent;
37
+ try {
38
+ yamlContent = await readFile(config.libraries, "utf8");
39
+ } catch {
40
+ log("error", `Could not read libraries file: ${config.libraries}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const librariesMap = parseLibrariesYaml(yamlContent);
45
+ const targetLibraries = config.components || Object.keys(librariesMap);
46
+ const componentsFolder = global.config?.components?.folder || "src";
47
+
48
+ let updatedCount = 0;
49
+
50
+ for (const libraryName of targetLibraries) {
51
+ if (!librariesMap[libraryName]) {
52
+ log("warn", `Library "${libraryName}" not found in ${config.libraries} — skipping.`);
53
+ continue;
54
+ }
55
+
56
+ const componentPath = mapLibraryToComponent(
57
+ libraryName,
58
+ config.mapping,
59
+ componentsFolder,
60
+ config.autoDiscoveryPrefixes ?? undefined,
61
+ );
62
+
63
+ if (!componentPath) {
64
+ log("warn", `Could not map library "${libraryName}" to a component folder — skipping.`);
65
+ continue;
66
+ }
67
+
68
+ const assets = resolveComponentAssets(
69
+ libraryName,
70
+ librariesMap,
71
+ config.ignorePrefixes,
72
+ );
73
+
74
+ if (config.dryRun) {
75
+ log("info", `[dry-run] ${libraryName} → ${componentPath}`);
76
+ console.log(JSON.stringify({ $assets: assets }, null, "\t"));
77
+ continue;
78
+ }
79
+
80
+ const updated = await updateMockFile(
81
+ path.join(componentsFolder, componentPath),
82
+ assets,
83
+ );
84
+
85
+ if (updated) {
86
+ updatedCount++;
87
+ log("info", `Updated $assets in ${componentPath}`);
88
+ }
89
+ }
90
+
91
+ if (!config.dryRun) {
92
+ log("success", `Done. Updated ${updatedCount} component(s).`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Reads a component's mock file, injects/replaces $assets, writes back.
98
+ * @param {string} componentDir - absolute or relative path to component folder
99
+ * @param {ComponentAssets} assets
100
+ * @returns {Promise<boolean>} true if file was updated
101
+ */
102
+ async function updateMockFile(componentDir, assets) {
103
+ const mocksConfig = global.config?.files?.mocks || {
104
+ name: "mocks",
105
+ extension: ["yaml", "yml", "json", "js"],
106
+ };
107
+ const extensions = Array.isArray(mocksConfig.extension)
108
+ ? mocksConfig.extension
109
+ : [mocksConfig.extension];
110
+
111
+ for (const ext of extensions) {
112
+ const filePath = path.join(componentDir, `${mocksConfig.name}.${ext}`);
113
+
114
+ let content;
115
+ try {
116
+ content = await readFile(filePath, "utf8");
117
+ } catch {
118
+ continue;
119
+ }
120
+
121
+ if (["yaml", "yml"].includes(ext)) {
122
+ /** @type {Record<string, unknown>} */
123
+ const data = /** @type {Record<string, unknown>} */ (
124
+ yaml.load(content) || {}
125
+ );
126
+ data.$assets = cleanAssets(assets);
127
+ await writeFile(filePath, yaml.dump(data, { indent: 2 }));
128
+ return true;
129
+ }
130
+
131
+ if (ext === "json") {
132
+ /** @type {Record<string, unknown>} */
133
+ const data = JSON.parse(content);
134
+ data.$assets = cleanAssets(assets);
135
+ await writeFile(filePath, JSON.stringify(data, null, 2) + "\n");
136
+ return true;
137
+ }
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Strips empty arrays from assets to keep mock files clean.
145
+ * @param {ComponentAssets} assets
146
+ * @returns {Partial<ComponentAssets>}
147
+ */
148
+ function cleanAssets(assets) {
149
+ /** @type {Partial<ComponentAssets>} */
150
+ const result = {};
151
+ if (assets.css?.length > 0) {
152
+ result.css = assets.css;
153
+ }
154
+
155
+ if (assets.js?.length > 0) {
156
+ result.js = assets.js;
157
+ }
158
+ return result;
159
+ }
package/lib/cli/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import lintImport from "./lint.js";
2
2
  import componentImport from "./component.js";
3
+ import drupalAssetsImport from "./drupal-assets.js";
3
4
 
4
5
  export const lint = lintImport;
5
6
  export const component = componentImport;
7
+ export const drupalAssets = drupalAssetsImport;
@@ -5,6 +5,11 @@ export default {
5
5
  assets: {
6
6
  root: "",
7
7
  css: [],
8
+ shared: {
9
+ css: [],
10
+ js: [],
11
+ },
12
+ isolateComponents: false,
8
13
  customProperties: {
9
14
  files: [],
10
15
  prefixes: {
@@ -0,0 +1,94 @@
1
+ import path from "node:path";
2
+ import * as v from "valibot";
3
+
4
+ const DrupalBlockSchema = v.object({
5
+ libraries: v.string(),
6
+ ignorePrefixes: v.optional(v.array(v.string())),
7
+ mapping: v.optional(v.record(v.string(), v.string())),
8
+ autoDiscoveryPrefixes: v.optional(v.nullable(v.array(v.string()))),
9
+ components: v.optional(v.nullable(v.array(v.string()))),
10
+ });
11
+
12
+ const ConfigSchema = v.object({
13
+ engine: v.picklist(["drupal"]),
14
+ drupal: DrupalBlockSchema,
15
+ });
16
+
17
+ export const NormalizedConfigSchema = v.object({
18
+ engine: v.string(),
19
+ libraries: v.optional(v.string()),
20
+ ignorePrefixes: v.array(v.string()),
21
+ mapping: v.record(v.string(), v.string()),
22
+ autoDiscoveryPrefixes: v.nullable(v.array(v.string())),
23
+ components: v.nullable(v.array(v.string())),
24
+ dryRun: v.boolean(),
25
+ });
26
+
27
+ /** @typedef {v.InferOutput<typeof NormalizedConfigSchema>} NormalizedConfig */
28
+
29
+ /**
30
+ * Loads the .miyagi-assets.js config and merges CLI overrides.
31
+ * @param {object} cliArgs
32
+ * @param {string} [cliArgs.engine] - engine to use (default: "drupal")
33
+ * @param {string} [cliArgs.config] - path to config file
34
+ * @param {string} [cliArgs.libraries] - CLI override for libraries path
35
+ * @param {string[]} [cliArgs.components] - CLI override for components list
36
+ * @param {string[]} [cliArgs.ignorePrefixes] - CLI override for ignore prefixes
37
+ * @param {boolean} [cliArgs.dryRun] - dry-run mode
38
+ * @returns {Promise<NormalizedConfig>} normalized config
39
+ */
40
+ export async function loadAssetsConfig(cliArgs) {
41
+ let fileConfig = null;
42
+
43
+ const configPath = cliArgs.config || ".miyagi-assets.js";
44
+
45
+ try {
46
+ const resolved = path.resolve(configPath);
47
+ const mod = await import(resolved);
48
+ fileConfig = mod.default || mod;
49
+ } catch {
50
+ if (!cliArgs.libraries) {
51
+ throw new Error(
52
+ `Could not load config file "${configPath}" and no --libraries flag provided.`,
53
+ );
54
+ }
55
+ }
56
+
57
+ if (fileConfig) {
58
+ const result = v.safeParse(ConfigSchema, fileConfig);
59
+
60
+ if (!result.success) {
61
+ const messages = result.issues.map((issue) => {
62
+ const issuePath =
63
+ issue.path?.map((path) => path.key).join(".") || "root";
64
+ return `${issuePath}: ${issue.message}`;
65
+ });
66
+ throw new Error(`Invalid config: ${messages.join("; ")}`);
67
+ }
68
+
69
+ const { engine } = result.output;
70
+ const engineBlock = result.output[engine];
71
+
72
+ return {
73
+ engine,
74
+ libraries: cliArgs.libraries || engineBlock.libraries,
75
+ ignorePrefixes:
76
+ cliArgs.ignorePrefixes || engineBlock.ignorePrefixes || [],
77
+ mapping: engineBlock.mapping || {},
78
+ autoDiscoveryPrefixes: engineBlock.autoDiscoveryPrefixes ?? null,
79
+ components:
80
+ cliArgs.components || engineBlock.components || null,
81
+ dryRun: cliArgs.dryRun || false,
82
+ };
83
+ }
84
+
85
+ return {
86
+ engine: cliArgs.engine || "drupal",
87
+ libraries: cliArgs.libraries,
88
+ ignorePrefixes: cliArgs.ignorePrefixes || [],
89
+ mapping: {},
90
+ autoDiscoveryPrefixes: null,
91
+ components: cliArgs.components || null,
92
+ dryRun: cliArgs.dryRun || false,
93
+ };
94
+ }
@@ -0,0 +1,189 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+
7
+ /**
8
+ * @typedef {{src: string, type?: string}} JsEntry
9
+ * @typedef {{prefix: string, name: string}} DepEntry
10
+ * @typedef {{css: string[], js: JsEntry[], dependencies: DepEntry[]}} LibraryEntry
11
+ */
12
+
13
+ /**
14
+ * Parses a Drupal *.libraries.yml string into a normalized map.
15
+ * @param {string} yamlContent - raw YAML string (not a file path)
16
+ * @returns {Record<string, LibraryEntry>}
17
+ */
18
+ export function parseLibrariesYaml(yamlContent) {
19
+ /**
20
+ * @type {Record<string, {
21
+ * css?: Record<string, Record<string, object>>,
22
+ * js?: Record<string, {attributes?: {type?: string}}>,
23
+ * dependencies?: string[]
24
+ * }>}
25
+ */
26
+ const raw = /** @type {never} */ (yaml.load(yamlContent));
27
+ /** @type {Record<string, LibraryEntry>} */
28
+ const map = {};
29
+
30
+ for (const [name, entry] of Object.entries(raw)) {
31
+ const css = entry.css
32
+ ? Object.values(entry.css).flatMap(Object.keys)
33
+ : [];
34
+
35
+ const js = entry.js
36
+ ? Object.entries(entry.js).map(([src, opts]) =>
37
+ opts?.attributes?.type
38
+ ? { src, type: opts.attributes.type }
39
+ : { src },
40
+ )
41
+ : [];
42
+
43
+ const dependencies = (entry.dependencies || [])
44
+ .filter((dep) => dep.includes("/"))
45
+ .map((dep) => ({
46
+ prefix: dep.slice(0, dep.indexOf("/")),
47
+ name: dep.slice(dep.indexOf("/") + 1),
48
+ }));
49
+
50
+ map[name] = { css, js, dependencies };
51
+ }
52
+
53
+ return map;
54
+ }
55
+
56
+ /**
57
+ * Recursively resolves all CSS and JS assets for a library, depth-first.
58
+ * Dependencies are collected before the component's own assets to preserve
59
+ * the CSS cascade (base styles before component styles) and ensure JS
60
+ * dependencies are available before scripts that rely on them.
61
+ * @param {string} libraryName
62
+ * @param {Record<string, LibraryEntry>} librariesMap - output of parseLibrariesYaml
63
+ * @param {string[]} ignorePrefixes - dependency prefixes to skip
64
+ * @returns {{css: string[], js: JsEntry[]}}
65
+ */
66
+ export function resolveComponentAssets(
67
+ libraryName,
68
+ librariesMap,
69
+ ignorePrefixes,
70
+ ) {
71
+ const cssSet = [];
72
+ const jsSet = [];
73
+ const resolved = new Set();
74
+
75
+ /**
76
+ * @param {string} name
77
+ * @param {Set<string>} ancestors
78
+ */
79
+ function walk(name, ancestors) {
80
+ if (ancestors.has(name)) {
81
+ console.warn(
82
+ `Circular dependency detected: "${name}" already in chain.`,
83
+ );
84
+ return;
85
+ }
86
+
87
+ if (resolved.has(name)) {
88
+ return;
89
+ }
90
+
91
+ const lib = librariesMap[name];
92
+ if (!lib) {
93
+ console.warn(
94
+ `Dependency "${name}" not found in libraries — skipping.`,
95
+ );
96
+ return;
97
+ }
98
+
99
+ const chain = new Set(ancestors);
100
+ chain.add(name);
101
+
102
+ for (const dep of lib.dependencies) {
103
+ if (ignorePrefixes.includes(dep.prefix)) {
104
+ continue;
105
+ }
106
+ walk(dep.name, chain);
107
+ }
108
+
109
+ for (const cssFile of lib.css) {
110
+ if (!cssSet.includes(cssFile)) {
111
+ cssSet.push(cssFile);
112
+ }
113
+ }
114
+
115
+ for (const jsEntry of lib.js) {
116
+ if (!jsSet.some((entry) => entry.src === jsEntry.src)) {
117
+ jsSet.push(jsEntry);
118
+ }
119
+ }
120
+
121
+ resolved.add(name);
122
+ }
123
+
124
+ walk(libraryName, new Set());
125
+ return { css: cssSet, js: jsSet };
126
+ }
127
+
128
+ const DEFAULT_PREFIXES = ["element-", "pattern-", "template-", "component-"];
129
+
130
+ /**
131
+ * Maps a Drupal library name to a miyagi component folder path.
132
+ * Uses explicit mapping first, falls back to auto-discovery.
133
+ * @param {string} libraryName
134
+ * @param {Record<string, string>} mapping - explicit library-to-folder mapping
135
+ * @param {string} componentsFolder - root components folder path
136
+ * @param {string[]} [autoDiscoveryPrefixes] - prefixes to strip when matching folder names
137
+ * @returns {string|null} component folder relative path, or null if not found
138
+ */
139
+ export function mapLibraryToComponent(
140
+ libraryName,
141
+ mapping,
142
+ componentsFolder,
143
+ autoDiscoveryPrefixes = DEFAULT_PREFIXES,
144
+ ) {
145
+ if (mapping[libraryName]) {
146
+ return mapping[libraryName];
147
+ }
148
+
149
+ const candidates = [libraryName];
150
+
151
+ for (const prefix of autoDiscoveryPrefixes) {
152
+ if (libraryName.startsWith(prefix)) {
153
+ candidates.push(libraryName.slice(prefix.length));
154
+ }
155
+ }
156
+
157
+ try {
158
+ /** @type {string[]} */
159
+ const searchDirs = [componentsFolder];
160
+ while (searchDirs.length > 0) {
161
+ const dir = /** @type {string} */ (searchDirs.pop());
162
+
163
+ if (!fs.existsSync(dir)) {
164
+ continue;
165
+ }
166
+
167
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
168
+
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory()) {
171
+ continue;
172
+ }
173
+
174
+ if (candidates.includes(entry.name)) {
175
+ return path.relative(
176
+ componentsFolder,
177
+ path.join(dir, entry.name),
178
+ );
179
+ }
180
+ searchDirs.push(path.join(dir, entry.name));
181
+ }
182
+ }
183
+ } catch {
184
+ // Swallow filesystem errors (missing/unreadable dirs) intentionally.
185
+ // Caller handles null return with a user-facing warning.
186
+ }
187
+
188
+ return null;
189
+ }
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * The miyagi module
3
5
  * @module index
@@ -9,7 +11,11 @@ import log from "./logger.js";
9
11
  import yargs from "./init/args.js";
10
12
  import mockGenerator from "./generator/mocks.js";
11
13
  import getConfig from "./config.js";
12
- import { lint, component as createComponentViaCli } from "./cli/index.js";
14
+ import {
15
+ lint,
16
+ component as createComponentViaCli,
17
+ drupalAssets,
18
+ } from "./cli/index.js";
13
19
  import apiApp from "../api/app.js";
14
20
 
15
21
  /**
@@ -57,6 +63,14 @@ function argsIncludeLint(args) {
57
63
  return args._.includes("lint");
58
64
  }
59
65
 
66
+ /**
67
+ * @param {object} args
68
+ * @returns {boolean}
69
+ */
70
+ function argsIncludeDrupalAssets(args) {
71
+ return args._.includes("drupal-assets");
72
+ }
73
+
60
74
  /**
61
75
  * Runs the mock generator
62
76
  * @param {object} config - the user configuration object
@@ -97,26 +111,35 @@ export default async function Miyagi(cmd, { isBuild: isApiBuild } = {}) {
97
111
  let isComponentGenerator;
98
112
  let isMockGenerator;
99
113
  let isLinter;
114
+ let isDrupalAssets;
100
115
 
101
116
  if (cmd) {
102
117
  isBuild = cmd === "build";
103
118
  } else {
104
- args = yargs.argv;
119
+ args = await yargs.argv;
105
120
  isServer = argsIncludeServer(args);
106
121
  isBuild = argsIncludeBuild(args);
107
122
  isComponentGenerator = argsIncludeComponentGenerator(args);
108
123
  isMockGenerator = argsIncludeMockGenerator(args);
109
124
  isLinter = argsIncludeLint(args);
125
+ isDrupalAssets = argsIncludeDrupalAssets(args);
110
126
  }
111
127
 
112
- if (args.verbose) {
113
- process.env.VERBOSE = args.verbose;
128
+ if (args && args.verbose) {
129
+ process.env.VERBOSE = String(args.verbose);
114
130
  }
115
131
 
116
132
  if (isLinter) {
117
133
  return lint(args);
118
134
  }
119
135
 
136
+ if (isDrupalAssets) {
137
+ process.env.NODE_ENV = "development";
138
+ global.config = await getConfig(args);
139
+ await drupalAssets(args);
140
+ process.exit();
141
+ }
142
+
120
143
  if (isBuild || isComponentGenerator || isServer || isMockGenerator) {
121
144
  if (isBuild) {
122
145
  process.env.NODE_ENV = "production";
package/lib/init/args.js CHANGED
@@ -45,6 +45,44 @@ export default yargs(hideBin(process.argv))
45
45
  "lint",
46
46
  "Validates if the component's mock data matches its JSON schema",
47
47
  )
48
+ .command(
49
+ "drupal-assets",
50
+ "Resolves Drupal *.libraries.yml dependencies and updates component $assets in mock files",
51
+ {
52
+ engine: {
53
+ alias: "e",
54
+ description: "Engine to use for asset resolution",
55
+ type: "string",
56
+ choices: ["drupal"],
57
+ default: "drupal",
58
+ },
59
+ config: {
60
+ description: "Path to .miyagi-assets.js config file",
61
+ type: "string",
62
+ default: ".miyagi-assets.js",
63
+ },
64
+ libraries: {
65
+ alias: "l",
66
+ description: "Path to *.libraries.yml (overrides config)",
67
+ type: "string",
68
+ },
69
+ components: {
70
+ alias: "c",
71
+ description: "Library names to process (space-separated)",
72
+ type: "array",
73
+ },
74
+ "ignore-prefixes": {
75
+ description:
76
+ 'Dependency prefixes to skip (e.g. "core" to ignore core/jquery)',
77
+ type: "array",
78
+ },
79
+ "dry-run": {
80
+ description: "Print resolved $assets without writing files",
81
+ type: "boolean",
82
+ default: false,
83
+ },
84
+ },
85
+ )
48
86
  .help()
49
87
  .version(pkgJson.version)
50
88
  .alias("help", "h")
@@ -216,6 +216,23 @@ export default (userConfig = {}) => {
216
216
  );
217
217
  }
218
218
 
219
+ if (config.assets.shared) {
220
+ if (config.assets.shared.css) {
221
+ config.assets.shared.css = getCssFilesArray(
222
+ config.assets.shared.css,
223
+ manifest,
224
+ config.assets.root,
225
+ );
226
+ }
227
+ if (config.assets.shared.js) {
228
+ config.assets.shared.js = getJsFilesArray(
229
+ config.assets.shared.js,
230
+ manifest,
231
+ config.assets.root,
232
+ );
233
+ }
234
+ }
235
+
219
236
  if (!config.assets.customProperties) {
220
237
  config.assets.customProperties = {};
221
238
  }
@@ -222,6 +222,7 @@ export default function Router() {
222
222
  res,
223
223
  component: routesEntry,
224
224
  componentData: data?.resolved ?? {},
225
+ componentDeclaredAssets: data?.$assets || null,
225
226
  cookies: req.cookies,
226
227
  });
227
228
  }
@@ -75,6 +75,35 @@ function registerUserFiles(files) {
75
75
  }
76
76
  }
77
77
 
78
+ /**
79
+ * Registers static routes for assets.shared CSS and JS files.
80
+ * @returns {void}
81
+ */
82
+ function registerSharedFiles() {
83
+ const { assets } = global.config;
84
+
85
+ if (!assets?.shared) return;
86
+
87
+ for (const file of assets.shared.css || []) {
88
+ if (file.startsWith("https://")) continue;
89
+
90
+ global.app.use(
91
+ path.join("/", path.dirname(file)),
92
+ express.static(path.join(assets.root, path.dirname(file))),
93
+ );
94
+ }
95
+
96
+ for (const file of assets.shared.js || []) {
97
+ const src = file.src || file;
98
+ if (src.startsWith("https://")) continue;
99
+
100
+ global.app.use(
101
+ path.join("/", path.dirname(src)),
102
+ express.static(path.join(assets.root, path.dirname(src))),
103
+ );
104
+ }
105
+ }
106
+
78
107
  /**
79
108
  * @returns {void}
80
109
  */
@@ -128,6 +157,7 @@ export default function initStatic() {
128
157
  registerUserComponentAssets();
129
158
  registerUserFiles("css");
130
159
  registerUserFiles("js");
160
+ registerSharedFiles();
131
161
  registerCustomPropertyFiles();
132
162
  registerAssetFolder();
133
163
  }
@@ -292,6 +292,25 @@ export default function Watcher(server) {
292
292
 
293
293
  const { components, docs, assets, extensions } = global.config;
294
294
 
295
+ const sharedCssToWatch = (assets.shared?.css || [])
296
+ .filter(
297
+ (f) =>
298
+ !f.startsWith("http://") &&
299
+ !f.startsWith("https://") &&
300
+ !f.startsWith("://"),
301
+ )
302
+ .map((f) => path.join(global.config.assets.root, f));
303
+
304
+ const sharedJsToWatch = (assets.shared?.js || [])
305
+ .map((file) => file.src || file)
306
+ .filter(
307
+ (f) =>
308
+ !f.startsWith("http://") &&
309
+ !f.startsWith("https://") &&
310
+ !f.startsWith("://"),
311
+ )
312
+ .map((f) => path.join(global.config.assets.root, f));
313
+
295
314
  const foldersToWatch = [
296
315
  ...assets.folder.map((f) => path.join(global.config.assets.root, f)),
297
316
  ...assets.css
@@ -311,6 +330,8 @@ export default function Watcher(server) {
311
330
  !f.startsWith("://"),
312
331
  )
313
332
  .map((f) => path.join(global.config.assets.root, f)),
333
+ ...sharedCssToWatch,
334
+ ...sharedJsToWatch,
314
335
  ];
315
336
 
316
337
  if (components.folder) {
package/lib/mocks/get.js CHANGED
@@ -22,6 +22,7 @@ export const getComponentData = async function getComponentData(component) {
22
22
 
23
23
  if (componentJson) {
24
24
  context = [];
25
+ const componentDeclaredAssets = componentJson.$assets || null;
25
26
  let componentData = helpers.removeInternalKeys(componentJson);
26
27
  const rootData = helpers.cloneDeep(componentData);
27
28
  const componentVariations = componentJson.$variants;
@@ -55,6 +56,7 @@ export const getComponentData = async function getComponentData(component) {
55
56
  resolved: resolved,
56
57
  raw: merged,
57
58
  name: variationJson.$name,
59
+ $assets: componentDeclaredAssets,
58
60
  };
59
61
  }
60
62
  }
@@ -67,6 +69,7 @@ export const getComponentData = async function getComponentData(component) {
67
69
  resolved: data.resolved,
68
70
  raw: data.merged,
69
71
  name: componentJson.$name || config.defaultVariationName,
72
+ $assets: componentDeclaredAssets,
70
73
  });
71
74
  }
72
75
  }
@@ -85,6 +88,7 @@ export const getComponentData = async function getComponentData(component) {
85
88
  resolved: componentJson.$hidden ? {} : resolved,
86
89
  raw: componentJson.$hidden ? {} : merged,
87
90
  name: componentJson.$name || config.defaultVariationName,
91
+ $assets: componentDeclaredAssets,
88
92
  });
89
93
  }
90
94
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resolves the final CSS and JS asset lists for a component render.
3
+ *
4
+ * Resolution logic:
5
+ * - If componentAssets ($assets from mocks) is provided:
6
+ * shared + componentAssets
7
+ * - Else if isolateComponents is true:
8
+ * shared only
9
+ * - Else (legacy fallback):
10
+ * global.config.assets.css / .js
11
+ * @param {object|null|undefined} componentAssets - the $assets declaration from mocks, or null/undefined
12
+ * @returns {{ cssFiles: string[], jsFilesHead: object[], jsFilesBody: object[] }}
13
+ */
14
+ export default function resolveAssets(componentAssets) {
15
+ const { shared, isolateComponents, css, js } = global.config.assets;
16
+
17
+ if (componentAssets) {
18
+ const mergedCss = [
19
+ ...shared.css,
20
+ ...(componentAssets.css || []),
21
+ ];
22
+ const mergedJs = [
23
+ ...shared.js,
24
+ ...(componentAssets.js || []),
25
+ ];
26
+
27
+ return {
28
+ cssFiles: mergedCss,
29
+ jsFilesHead: mergedJs.filter(
30
+ (entry) => entry.position === "head" || !entry.position,
31
+ ),
32
+ jsFilesBody: mergedJs.filter(
33
+ (entry) => entry.position === "body",
34
+ ),
35
+ };
36
+ }
37
+
38
+ if (isolateComponents) {
39
+ return {
40
+ cssFiles: [...shared.css],
41
+ jsFilesHead: shared.js.filter(
42
+ (entry) => entry.position === "head" || !entry.position,
43
+ ),
44
+ jsFilesBody: shared.js.filter(
45
+ (entry) => entry.position === "body",
46
+ ),
47
+ };
48
+ }
49
+
50
+ // Legacy fallback: return all global assets
51
+ return {
52
+ cssFiles: css,
53
+ jsFilesHead: js.filter(
54
+ (entry) => entry.position === "head" || !entry.position,
55
+ ),
56
+ jsFilesBody: js.filter((entry) => entry.position === "body"),
57
+ };
58
+ }
@@ -6,6 +6,7 @@ import * as helpers from "../../../helpers.js";
6
6
  import validateMocks from "../../../validator/mocks.js";
7
7
  import { getComponentData } from "../../../mocks/index.js";
8
8
  import { getUserUiConfig, getThemeMode } from "../../helpers.js";
9
+ import resolveAssets from "../../helpers/resolve-assets.js";
9
10
  import log from "../../../logger.js";
10
11
 
11
12
  /**
@@ -216,18 +217,19 @@ async function renderVariations({
216
217
  ({ shortPath }) => shortPath === component.paths.dir.short,
217
218
  );
218
219
 
220
+ const componentDeclaredAssets =
221
+ context.length > 0 ? context[0].$assets || null : null;
222
+ const { cssFiles, jsFilesHead, jsFilesBody } =
223
+ resolveAssets(componentDeclaredAssets);
224
+
219
225
  await res.render(
220
226
  "iframe_component.twig.miyagi",
221
227
  {
222
228
  lang: global.config.ui.lang,
223
229
  variations,
224
- cssFiles: global.config.assets.css,
225
- jsFilesHead: global.config.assets.js.filter(
226
- (entry) => entry.position === "head" || !entry.position,
227
- ),
228
- jsFilesBody: global.config.assets.js.filter(
229
- (entry) => entry.position === "body",
230
- ),
230
+ cssFiles,
231
+ jsFilesHead,
232
+ jsFilesBody,
231
233
  assets: {
232
234
  css: componentsEntry
233
235
  ? componentsEntry.assets.css
@@ -1,12 +1,14 @@
1
1
  import path from "path";
2
2
  import config from "../../../default-config.js";
3
3
  import { getUserUiConfig } from "../../helpers.js";
4
+ import resolveAssets from "../../helpers/resolve-assets.js";
4
5
 
5
6
  /**
6
7
  * @param {object} object - parameter object
7
8
  * @param {object} [object.res] - the express response object
8
9
  * @param {object} object.component
9
10
  * @param {object} object.componentData
11
+ * @param {object|null} [object.componentDeclaredAssets] - $assets from mocks
10
12
  * @param {Function} [object.cb] - callback function
11
13
  * @param {object} [object.cookies]
12
14
  * @returns {Promise} gets resolved when the variation has been rendered
@@ -15,6 +17,7 @@ export default async function renderIframeVariationStandalone({
15
17
  res,
16
18
  component,
17
19
  componentData,
20
+ componentDeclaredAssets = null,
18
21
  cb,
19
22
  cookies,
20
23
  }) {
@@ -38,17 +41,16 @@ export default async function renderIframeVariationStandalone({
38
41
  ({ shortPath }) => shortPath === directoryPath,
39
42
  );
40
43
 
44
+ const { cssFiles, jsFilesHead, jsFilesBody } =
45
+ resolveAssets(componentDeclaredAssets);
46
+
41
47
  await res.render(
42
48
  "component_variation.twig.miyagi",
43
49
  {
44
50
  html: result,
45
- cssFiles: global.config.assets.css,
46
- jsFilesHead: global.config.assets.js.filter(
47
- (entry) => entry.position === "head" || !entry.position,
48
- ),
49
- jsFilesBody: global.config.assets.js.filter(
50
- (entry) => entry.position === "body",
51
- ),
51
+ cssFiles,
52
+ jsFilesHead,
53
+ jsFilesBody,
52
54
  assets: {
53
55
  css: componentsEntry
54
56
  ? componentsEntry.assets.css
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schalkneethling/miyagi-core",
3
- "version": "4.0.2",
3
+ "version": "4.1.1",
4
4
  "description": "miyagi is a component development tool for JavaScript template engines.",
5
5
  "main": "index.js",
6
6
  "author": "Schalk Neethling <schalkneethling@duck.com>, Michael Großklaus <mail@mgrossklaus.de> (https://www.mgrossklaus.de)",
@@ -9,7 +9,7 @@
9
9
  "bugs": "https://github.com/miyagi-dev/miyagi/issues",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git@github.com:miyagi-dev/miyagi.git"
12
+ "url": "https://github.com/schalkneethling/miyagi.git"
13
13
  },
14
14
  "type": "module",
15
15
  "keywords": [
@@ -20,7 +20,7 @@
20
20
  "frontend"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=20.11.0"
23
+ "node": ">=24"
24
24
  },
25
25
  "files": [
26
26
  "api",
@@ -43,9 +43,10 @@
43
43
  "directory-tree": "^3.5.2",
44
44
  "express": "^5.1.0",
45
45
  "js-yaml": "^4.1.0",
46
- "marked": "^16.4.1",
46
+ "marked": "^17.0.2",
47
47
  "node-watch": "^0.7.4",
48
- "twing": "7.2.2",
48
+ "twing": "7.3.1",
49
+ "valibot": "^1.2.0",
49
50
  "ws": "^8.18.3",
50
51
  "yargs": "^18.0.0"
51
52
  },
@@ -53,11 +54,13 @@
53
54
  "@eslint/js": "^9.39.2",
54
55
  "@rollup/plugin-node-resolve": "^16.0.3",
55
56
  "@rollup/plugin-terser": "^0.4.4",
57
+ "@types/js-yaml": "^4.0.9",
56
58
  "@types/node": "^24.10.0",
59
+ "@types/yargs": "^17.0.35",
57
60
  "@vitest/coverage-v8": "^4.0.6",
58
61
  "cssnano": "^7.1.2",
59
62
  "eslint": "^9.39.0",
60
- "eslint-plugin-jsdoc": "^61.1.11",
63
+ "eslint-plugin-jsdoc": "^62.5.4",
61
64
  "globals": "^15.15.0",
62
65
  "gulp": "^5.0.1",
63
66
  "gulp-postcss": "^10.0.0",
@@ -75,8 +78,7 @@
75
78
  "build": "gulp build",
76
79
  "test": "vitest run --coverage --coverage.include=api --coverage.include=lib",
77
80
  "lint": "stylelint frontend/assets/css/ && eslint lib/ && eslint frontend/assets/js/",
78
- "fix": "eslint lib/ --fix && eslint frontend/assets/js/ --fix",
79
- "release": "standard-version"
81
+ "fix": "eslint lib/ --fix && eslint frontend/assets/js/ --fix"
80
82
  },
81
83
  "browserslist": [
82
84
  "last 2 versions",