@emulsify/core 4.3.1 → 4.4.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.
Files changed (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +96 -25
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -5,33 +5,37 @@
5
5
  * structure using the same routing rules as compiled JS and CSS entries.
6
6
  */
7
7
 
8
- import { copyFileSync, mkdirSync } from 'fs';
9
- import { dirname, join } from 'path';
8
+ import { copyFileSync, mkdirSync, statSync } from 'fs';
9
+ import { dirname, isAbsolute, join, resolve } from 'path';
10
10
 
11
11
  import {
12
12
  copiedComponentOutputPath,
13
13
  copiedGlobalOutputPath,
14
14
  } from '../../project-structure.js';
15
+ import {
16
+ filesHaveSameBytes,
17
+ removeDestinationSymlink,
18
+ resolveFinalPath,
19
+ } from './output-freshness.js';
15
20
  import {
16
21
  createSourceFileIndex,
17
22
  isComponentMetadataFile,
18
23
  } from './source-file-index.js';
19
24
 
20
- /** Determine whether a Twig file is a partial (filename starts with `_`). */
21
- const isPartial = (filePath) =>
22
- (filePath.split('/')?.pop() || '').trim().startsWith('_');
23
-
24
25
  /**
25
26
  * Copy Twig templates and component metadata to `dist/`.
26
27
  *
27
- * @param {{ structure: object, sourceFileIndex?: object }} opts - Plugin options.
28
+ * @param {{ structure: object, sourceFileIndex?: object, diagnostics?: object, outputChanges?: Map<string, {kind: 'written'|'removed', bytes?: number}> }} opts - Plugin options.
28
29
  * @returns {import('vite').PluginOption} Copy plugin.
29
30
  */
30
31
  export function copyTwigFilesPlugin({
31
32
  structure,
32
33
  sourceFileIndex = createSourceFileIndex(structure),
34
+ diagnostics,
35
+ outputChanges,
33
36
  }) {
34
37
  let outDir = 'dist';
38
+ let projectDir = process.cwd();
35
39
  let watching = false;
36
40
  /** @type {Array<{absPath: string, relDest: string}>|undefined} */
37
41
  let plan;
@@ -39,10 +43,12 @@ export function copyTwigFilesPlugin({
39
43
  /**
40
44
  * Resolve every file this plugin copies, paired with where it lands.
41
45
  *
42
- * Built once and reused, because the source index is resolved at config time
43
- * and does not change across watch cycles. Both hooks below read this same
44
- * list, which is what keeps "gets copied to dist" and "a save triggers the
45
- * copy" from drifting apart a file cannot be added to one without the other.
46
+ * Shared by both hooks, which keeps "gets copied to dist" and "a save
47
+ * triggers the copy" from drifting apart. Structural events for an
48
+ * individually watched file reset the plan, so a single-file rename can be
49
+ * copied on the next cycle. New files and component-directory changes sit
50
+ * outside that watch set and require a watcher restart. Previous destinations
51
+ * are not pruned; content-only edits keep the cached filesystem walk.
46
52
  *
47
53
  * @returns {Array<{absPath: string, relDest: string}>} Copy plan.
48
54
  */
@@ -51,11 +57,18 @@ export function copyTwigFilesPlugin({
51
57
 
52
58
  plan = [];
53
59
 
60
+ // A leading underscore excludes a stylesheet from compilation, because a Sass
61
+ // partial is inlined into whatever imports it and has no output of its own.
62
+ // Twig has no equivalent: `{% include %}` and `{% embed %}` resolve at render
63
+ // time against the emitted tree, so an underscored template is a file the site
64
+ // still has to find. Skipping those left the include unresolvable at runtime.
54
65
  for (const file of sourceFileIndex.componentFiles()) {
55
- const isTwig = file.absPath.endsWith('.twig');
56
-
57
- if (!isTwig && !isComponentMetadataFile(file.absPath)) continue;
58
- if (isTwig && isPartial(file.relPath)) continue;
66
+ if (
67
+ !file.absPath.endsWith('.twig') &&
68
+ !isComponentMetadataFile(file.absPath)
69
+ ) {
70
+ continue;
71
+ }
59
72
 
60
73
  plan.push({
61
74
  absPath: file.absPath,
@@ -65,7 +78,6 @@ export function copyTwigFilesPlugin({
65
78
 
66
79
  for (const file of sourceFileIndex.globalFiles()) {
67
80
  if (!file.absPath.endsWith('.twig')) continue;
68
- if (isPartial(file.relPath)) continue;
69
81
 
70
82
  plan.push({
71
83
  absPath: file.absPath,
@@ -84,9 +96,16 @@ export function copyTwigFilesPlugin({
84
96
  /** Capture the final outDir. */
85
97
  configResolved(cfg) {
86
98
  outDir = cfg.build?.outDir || 'dist';
99
+ projectDir = cfg.root || process.cwd();
87
100
  watching = Boolean(cfg.build?.watch);
88
101
  },
89
102
 
103
+ watchChange(_id, { event } = {}) {
104
+ if (!watching || (event !== 'create' && event !== 'delete')) return;
105
+ sourceFileIndex.refresh?.();
106
+ plan = undefined;
107
+ },
108
+
90
109
  // Twig is copied rather than compiled, so none of it reaches Rollup's module
91
110
  // graph, and Rollup only watches what is in that graph. Without this, saving
92
111
  // a template produced no rebuild at all: `dist/` kept the previous version
@@ -101,30 +120,82 @@ export function copyTwigFilesPlugin({
101
120
  /** Copy before the mirror plugin moves dist/components to the project root. */
102
121
  writeBundle() {
103
122
  for (const { absPath, relDest } of copyPlan()) {
104
- copyToOutDir(absPath, relDest);
123
+ const copyResult = copyToOutDir(absPath, relDest);
124
+ if (watching && copyResult.status === 'written') {
125
+ outputChanges?.set(relDest, {
126
+ kind: 'written',
127
+ bytes: copyResult.bytes,
128
+ });
129
+ }
130
+ if (copyResult.status === 'failed' && copyResult.error) {
131
+ const errno = copyResult.error.code ?? 'unknown error';
132
+ const message = `Unable to copy ${absPath} to ${join(outDir, relDest)} (${errno}): ${copyResult.error.message}`;
133
+ diagnostics?.recordError?.({
134
+ message,
135
+ file: absPath,
136
+ outputState: 'incomplete',
137
+ });
138
+ this.warn?.(message);
139
+ }
105
140
  }
106
141
  },
107
142
  };
108
143
 
144
+ /**
145
+ * Resolve the output directory to an absolute path.
146
+ *
147
+ * @returns {string} Absolute output directory.
148
+ */
149
+ function absoluteOutDir() {
150
+ return isAbsolute(outDir) ? outDir : resolve(projectDir, outDir);
151
+ }
152
+
109
153
  /**
110
154
  * Copy one file into the output directory.
111
155
  *
112
156
  * @param {string} absPath - Absolute source path.
113
157
  * @param {string} relDest - Destination relative to `outDir`.
114
- * @returns {void}
158
+ * @returns {{status: 'written'|'skipped'|'failed', bytes?: number, error?: Error}} Copy result.
115
159
  */
116
160
  function copyToOutDir(absPath, relDest) {
117
- if (!relDest) return;
161
+ if (!relDest) return { status: 'failed' };
118
162
 
119
- // Copied unconditionally, because `build.emptyOutDir` clears the output
120
- // directory on every watch cycle and not just the first — a freshness check
121
- // against the destination can never find anything to skip.
122
163
  const destPath = join(outDir, relDest);
123
- mkdirSync(dirname(destPath), { recursive: true });
124
164
  try {
165
+ // A rewritten template in the output tree is a full preview reload rather
166
+ // than a style swap, so byte-identical templates are skipped during watch.
167
+ // One-shot builds continue to copy unconditionally as before.
168
+ if (
169
+ watching &&
170
+ filesHaveSameBytes(
171
+ absPath,
172
+ resolveFinalPath(relDest, {
173
+ outDir: absoluteOutDir(),
174
+ projectDir,
175
+ mirrored: structure?.mirrorComponentOutput,
176
+ }),
177
+ )
178
+ ) {
179
+ return { status: 'skipped' };
180
+ }
181
+
182
+ mkdirSync(dirname(destPath), { recursive: true });
183
+ removeDestinationSymlink(destPath);
125
184
  copyFileSync(absPath, destPath);
126
- } catch {
127
- /* noop */
185
+ let bytes;
186
+ if (watching && outputChanges) {
187
+ try {
188
+ bytes = statSync(destPath).size;
189
+ } catch {
190
+ // The write still succeeded; size is optional reporting metadata.
191
+ }
192
+ }
193
+ return { status: 'written', bytes };
194
+ } catch (error) {
195
+ return {
196
+ status: 'failed',
197
+ error,
198
+ };
128
199
  }
129
200
  }
130
201
  }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @file CSS asset URL rebase plugin.
3
+ *
4
+ * Repairs CSS `url()` references to project assets that Vite could not resolve.
5
+ * By default, repaired assets are emitted into the self-contained build output;
6
+ * projects that deploy the whole theme can opt into lean output that references
7
+ * the source asset tree instead. See `asset-url-rebase.js` for the repair rules;
8
+ * this module is the Vite wiring.
9
+ *
10
+ * ## Why this runs in a normal-order `transform`
11
+ *
12
+ * The rewrite has to happen where the importing stylesheet is known, which
13
+ * rules out `generateBundle`. It also has to see Sass partials, which rules out
14
+ * `enforce: 'pre'`: `@use`d partials are loaded inside Dart Sass through Vite's
15
+ * own importer, never enter the module graph, and reach no plugin hook. A
16
+ * normal-order `transform` runs after `vite:css` has compiled Sass and
17
+ * attempted URL resolution, so it sees compiled CSS with every partial inlined
18
+ * and every interpolation expanded.
19
+ *
20
+ * That ordering also keeps ordinary URLs non-destructive: a resolved URL is
21
+ * already a `__VITE_ASSET__` placeholder by this point, so the only literals
22
+ * left are ones Vite gave up on. The reserved `@assets/...` namespace is the
23
+ * deliberate exception; the resolver bridge below prevents consumer aliases,
24
+ * packages, and same-named directories from claiming it first.
25
+ *
26
+ * ## Self-contained and lean output
27
+ *
28
+ * The default keeps `dist/` deployable on its own. Vite already copies assets it
29
+ * resolves, while this plugin explicitly emits assets for the URL forms Vite
30
+ * could not resolve. In both cases the relativizer points CSS at the output copy.
31
+ *
32
+ * With `assets.selfContainedOutput: false`, each source path is instead recorded
33
+ * in `publishedAssetSources`, keyed by the path the output copy would have had.
34
+ * `css-asset-relativizer.js` points CSS URLs at the source tree and removes a
35
+ * Vite copy only after that rewrite actually happens. Copies still referenced
36
+ * by JavaScript or another emitted file remain in the output. This matters for
37
+ * configured `assets.roots`, whose real location is not necessarily `assets/`.
38
+ */
39
+
40
+ import { readFileSync } from 'fs';
41
+ import { relative } from 'path';
42
+
43
+ import { resolveAssetRoots } from '../../utils/asset-roots.js';
44
+ import { toPosixPath } from '../../utils/paths.js';
45
+ import {
46
+ ASSET_ALIAS_PREFIX,
47
+ rewriteStylesheetUrls,
48
+ } from './asset-url-rebase.js';
49
+ import { isStorybookOutput } from './storybook-output.js';
50
+
51
+ /** Stylesheet requests this plugin inspects. */
52
+ const STYLE_REQUEST_RE = /\.(css|p?css|sss|styl|stylus|less|sass|scss)(?:$|\?)/;
53
+
54
+ /** Query suffixes that are not stylesheet content. */
55
+ const NON_STYLE_QUERY_RE = /[?&](raw|url)(?:&|$)/;
56
+
57
+ /** Case-insensitive CSS URL function marker. */
58
+ const URL_FUNCTION_RE = /url\(/i;
59
+
60
+ /**
61
+ * Strip the Vite request query from a module id.
62
+ *
63
+ * @param {string} id - Module id.
64
+ * @returns {string} Filesystem path.
65
+ */
66
+ function stripRequestQuery(id) {
67
+ const index = id.indexOf('?');
68
+ return index === -1 ? id : id.slice(0, index);
69
+ }
70
+
71
+ /**
72
+ * Determine whether an emitted asset is a copy of a project asset root file.
73
+ *
74
+ * Rollup records the source path an asset came from in `originalFileNames`.
75
+ * Anything the build generated — the SVG sprite, a JS chunk — has none, so this
76
+ * never mistakes generated output for a copy.
77
+ *
78
+ * @param {object} chunk - Emitted bundle entry.
79
+ * @param {string[]} assetRootPrefixes - Project-relative asset root prefixes.
80
+ * @returns {string} Project-relative source path, or an empty string.
81
+ */
82
+ function copiedAssetSource(chunk, assetRootPrefixes) {
83
+ const original = Array.isArray(chunk.originalFileNames)
84
+ ? chunk.originalFileNames[0]
85
+ : chunk.originalFileName;
86
+ if (!original) return '';
87
+
88
+ const source = toPosixPath(original).replace(/^\.?\//, '');
89
+
90
+ return assetRootPrefixes.some((prefix) => source.startsWith(prefix))
91
+ ? source
92
+ : '';
93
+ }
94
+
95
+ /**
96
+ * Match the reserved alias at the start of a URL or after the relative path
97
+ * Sass inserts when rebasing an imported partial. The captured prefix is put
98
+ * back unchanged by the alias entry below.
99
+ *
100
+ * @type {RegExp}
101
+ */
102
+ const ASSET_ALIAS_RESOLUTION_RE = new RegExp(
103
+ `^(?!/)((?:[^?#]*/)?)(?=${ASSET_ALIAS_PREFIX}/)`,
104
+ );
105
+
106
+ /**
107
+ * Stop Vite's private CSS resolver after it recognizes the Core alias.
108
+ *
109
+ * Vite's CSS resolver does not call user `resolveId` hooks. A truthy result
110
+ * with an empty id makes its isolated alias container stop without resolving a
111
+ * project alias, package, or same-named directory. Vite then leaves the URL for
112
+ * this plugin's normal-order transform, which preserves Core's configured-root
113
+ * resolution, diagnostics, and deterministic output paths.
114
+ *
115
+ * @returns {{id: string}} Empty resolution veto.
116
+ */
117
+ const reserveAssetAlias = () => ({ id: '' });
118
+
119
+ /**
120
+ * Install the resolver veto after Vite has normalized config.
121
+ *
122
+ * Late installation is intentional: Vite warns about alias custom resolvers
123
+ * while normalizing config, but creates its private CSS resolver lazily on the
124
+ * first stylesheet transform. Its normal module alias plugin has already
125
+ * captured consumer entries, so ordinary JavaScript imports keep their
126
+ * existing behavior. A custom resolver created after this hook sees `@assets`
127
+ * as reserved too; projects must not use that stylesheet namespace as a
128
+ * package alias.
129
+ *
130
+ * This bridge intentionally targets Vite 8, which Core pins in package.json.
131
+ * Vite 9 removes alias custom resolvers, so a Vite-major upgrade must replace
132
+ * this bridge; the conflicting-alias release fixture is the fail-loud contract
133
+ * test for that upgrade.
134
+ *
135
+ * @param {import('vite').ResolvedConfig|object} config - Resolved Vite config.
136
+ * @returns {void}
137
+ */
138
+ function reserveAssetAliasForCss(config) {
139
+ const aliasLists = new Set([
140
+ config?.resolve?.alias,
141
+ ...Object.values(config?.environments || {}).map(
142
+ (environment) => environment?.resolve?.alias,
143
+ ),
144
+ ]);
145
+
146
+ for (const aliases of aliasLists) {
147
+ if (!Array.isArray(aliases)) continue;
148
+ if (aliases.some((entry) => entry?.customResolver === reserveAssetAlias)) {
149
+ continue;
150
+ }
151
+
152
+ aliases.unshift({
153
+ find: ASSET_ALIAS_RESOLUTION_RE,
154
+ replacement: '$1',
155
+ customResolver: reserveAssetAlias,
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Rebase unresolvable CSS asset URLs and manage their output target.
162
+ *
163
+ * @param {{env?: object, diagnostics?: object, publishedAssetSources?: Map<string, string>, removablePublishedAssets?: Set<string>}} [opts={}] - Plugin options.
164
+ * @returns {import('vite').PluginOption} Rebase plugin.
165
+ */
166
+ export function cssAssetRebasePlugin({
167
+ env = {},
168
+ diagnostics,
169
+ publishedAssetSources = new Map(),
170
+ removablePublishedAssets = new Set(),
171
+ } = {}) {
172
+ const enabled = env?.projectStructure?.assetRebase !== false;
173
+ const selfContainedOutput =
174
+ env?.projectStructure?.selfContainedOutput !== false;
175
+ const projectDir = env?.projectDir || process.cwd();
176
+ /** @type {Map<string, string>} Published path -> absolute source file. */
177
+ const pendingAssetEmissions = new Map();
178
+
179
+ /** @type {string[]} */
180
+ let roots = [];
181
+ /** @type {string[]} */
182
+ let assetRootPrefixes = [];
183
+ let ownsOutput = true;
184
+
185
+ return {
186
+ name: 'emulsify-css-asset-rebase',
187
+
188
+ configResolved(config) {
189
+ roots = resolveAssetRoots(env);
190
+ assetRootPrefixes = roots
191
+ .map((root) => `${toPosixPath(relative(projectDir, root))}/`)
192
+ .filter((prefix) => prefix !== '/' && !prefix.startsWith('..'));
193
+
194
+ // Storybook serves every asset root at `/assets` through staticDirs and
195
+ // copies them into its own output, so this plugin never owns that output.
196
+ ownsOutput = !isStorybookOutput(config);
197
+
198
+ if (enabled) reserveAssetAliasForCss(config);
199
+ },
200
+
201
+ // Watch rebuilds must not inherit stale publication or emission state.
202
+ buildStart() {
203
+ publishedAssetSources.clear();
204
+ removablePublishedAssets.clear();
205
+ pendingAssetEmissions.clear();
206
+ },
207
+
208
+ transform(code, id) {
209
+ if (!enabled || !roots.length) return null;
210
+ if (!STYLE_REQUEST_RE.test(id) || NON_STYLE_QUERY_RE.test(id)) {
211
+ return null;
212
+ }
213
+ if (!URL_FUNCTION_RE.test(code)) return null;
214
+
215
+ const importer = stripRequestQuery(id);
216
+
217
+ const { code: next, changed } = rewriteStylesheetUrls(
218
+ code,
219
+ importer,
220
+ roots,
221
+ (plan) => {
222
+ if (
223
+ plan.status === 'aliased' ||
224
+ plan.status === 'rebased' ||
225
+ plan.status === 'publish'
226
+ ) {
227
+ if (ownsOutput) {
228
+ if (selfContainedOutput) {
229
+ const fileName = plan.emitAs.replace(/^\/+/, '');
230
+ pendingAssetEmissions.set(fileName, plan.file);
231
+ } else {
232
+ publishedAssetSources.set(
233
+ plan.emitAs,
234
+ toPosixPath(relative(projectDir, plan.file)),
235
+ );
236
+ }
237
+ }
238
+ // Static assets are outside Rollup's module graph, so a swapped
239
+ // image would otherwise go unnoticed until an unrelated rebuild.
240
+ this.addWatchFile(plan.file);
241
+ }
242
+
243
+ // `missing` is deliberately not recorded: Vite already warned about
244
+ // that exact URL and the reporter's logger captures it. Recording it
245
+ // again would double the occurrence count.
246
+ if (
247
+ plan.status === 'aliased' ||
248
+ plan.status === 'rebased' ||
249
+ plan.status === 'ambiguous'
250
+ ) {
251
+ diagnostics?.recordAssetRebase?.({
252
+ status: plan.status,
253
+ url: plan.originalUrl,
254
+ rewritten: plan.url,
255
+ importer,
256
+ resolvedAsset: plan.file,
257
+ candidates: plan.candidates,
258
+ });
259
+ }
260
+ },
261
+ );
262
+
263
+ if (!changed) return null;
264
+
265
+ // Core has already captured Vite's combined Sass/PostCSS map for its
266
+ // development emitter. This empty transform map is what Vite itself uses
267
+ // when CSS maps are off and keeps Rollup from warning about this rewrite.
268
+ return { code: next, map: { mappings: '' } };
269
+ },
270
+
271
+ // Lean output records every Vite copy the relativizer may redirect. The
272
+ // relativizer owns deletion because only an actual CSS rewrite proves the
273
+ // copy is redundant; JS-only and generated assets must survive.
274
+ generateBundle(_, bundle) {
275
+ if (!enabled || !ownsOutput) return;
276
+
277
+ if (selfContainedOutput) {
278
+ // Vite may already have emitted this exact published path for an
279
+ // equivalent `/assets/...` CSS reference or a JavaScript import. Wait
280
+ // until the bundle is known so Core can fill only the missing paths;
281
+ // emitting eagerly from `transform` produces FILE_NAME_CONFLICT noise
282
+ // for the same file under the two accepted stylesheet spellings.
283
+ for (const [fileName, file] of pendingAssetEmissions) {
284
+ if (Object.hasOwn(bundle, fileName)) continue;
285
+
286
+ this.emitFile({
287
+ type: 'asset',
288
+ fileName,
289
+ source: readFileSync(file),
290
+ });
291
+ }
292
+ return;
293
+ }
294
+
295
+ for (const [fileName, chunk] of Object.entries(bundle)) {
296
+ if (chunk.type !== 'asset' || fileName.endsWith('.css')) continue;
297
+
298
+ const source = copiedAssetSource(chunk, assetRootPrefixes);
299
+ if (!source) continue;
300
+
301
+ publishedAssetSources.set(fileName, source);
302
+ removablePublishedAssets.add(fileName);
303
+ }
304
+ },
305
+ };
306
+ }