@iqual/nuxt-icms 1.6.0-rc.4 → 1.6.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.
@@ -8,10 +8,10 @@
8
8
  @error="console.log"
9
9
  noValidate
10
10
  >
11
-
12
- <DesignSystemAtomsButton
13
- v-if="debug"
14
- @click="console.log(schema.safeParse(state))""
11
+
12
+ <DesignSystemAtomsButton
13
+ v-if="debug"
14
+ @click="console.log(schema.safeParse(state))"
15
15
  label="Debug Schema"
16
16
  />
17
17
 
@@ -24,7 +24,7 @@
24
24
  v-model="token"
25
25
  v-if="formElement?.metadata?.key === 'actions'"
26
26
  /> -->
27
-
27
+
28
28
  <component
29
29
  v-if="formElement?.metadata?.type"
30
30
  :is="getComponent(formElement?.metadata?.type)"
@@ -34,8 +34,8 @@
34
34
  :debug="debug"
35
35
  />
36
36
  </div>
37
- <DesignSystemAtomsButton
38
- v-if="debug"
37
+ <DesignSystemAtomsButton
38
+ v-if="debug"
39
39
  @click="console.log(schema.safeParse(state))"
40
40
  label="Debug Schema"
41
41
  :buttonStyle="'focus'"
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createEvent, getResponseHeaders } from 'h3'
3
+ import { IncomingMessage, ServerResponse } from 'node-mock-http'
4
+ import { NuxtMultiCacheCDNHelper } from 'nuxt-multi-cache/dist/runtime/helpers/CDNHelper.js'
5
+ import multiCache from '../multiCache'
6
+
7
+ type CDNHelper = InstanceType<typeof NuxtMultiCacheCDNHelper>
8
+
9
+ /**
10
+ * Guards the CDN caching contract between the Nuxt SSR responses and
11
+ * Varnish (ICMS-86, regressed in ICMS-329, fixed in ICMS-689).
12
+ *
13
+ * Varnish (open-source) only understands Cache-Control — not the RFC 9213
14
+ * CDN-Cache-Control header. The VCL derives the TTL from s-maxage, detects
15
+ * uncacheable responses via `no-cache|no-store|private`, and maps Cache-Tag
16
+ * to xkey for tag-based purging. If these header names change, SSR pages
17
+ * silently degrade to Varnish's 120s default TTL.
18
+ */
19
+ describe('multiCache CDN configuration', () => {
20
+ it('emits caching directives in the Cache-Control header', () => {
21
+ expect(multiCache.cdn).toMatchObject({
22
+ enabled: true,
23
+ cacheControlHeader: 'Cache-Control',
24
+ cacheTagHeader: 'Cache-Tag',
25
+ })
26
+ })
27
+ })
28
+
29
+ describe('CDN helper with the configured header names', () => {
30
+ function applyToFreshEvent(configure: (helper: CDNHelper) => void) {
31
+ const event = createEvent(new IncomingMessage(), new ServerResponse())
32
+ const helper = new NuxtMultiCacheCDNHelper(
33
+ Math.floor(Date.now() / 1000),
34
+ multiCache.cdn!.cacheControlHeader,
35
+ multiCache.cdn!.cacheTagHeader,
36
+ )
37
+ configure(helper)
38
+ helper.applyToEvent(event)
39
+ return getResponseHeaders(event)
40
+ }
41
+
42
+ it('writes s-maxage and cache tags for a cacheable response', () => {
43
+ const headers = applyToFreshEvent((helper) => {
44
+ helper
45
+ .public()
46
+ .setNumeric('sharedMaxAge', 31536000)
47
+ .addTags(['nuxt', 'N:12'])
48
+ })
49
+
50
+ // The VCL derives the TTL from s-maxage; directive order is irrelevant.
51
+ expect(headers['cache-control']).toMatch(/\bs-maxage=31536000\b/)
52
+ expect(headers['cache-control']).toMatch(/\bpublic\b/)
53
+ expect(headers['cache-control']).not.toMatch(/private|no-cache|no-store/)
54
+ expect(headers['cache-tag']).toBe('nuxt N:12')
55
+ })
56
+
57
+ it('marks uncacheable responses as private', () => {
58
+ const headers = applyToFreshEvent((helper) => {
59
+ helper.private()
60
+ })
61
+
62
+ // Varnish's uncacheable-response detection matches
63
+ // `no-cache|no-store|private` on Cache-Control.
64
+ expect(headers['cache-control']).toMatch(/private/)
65
+ expect(headers['cache-tag']).toBeUndefined()
66
+ })
67
+ })
@@ -0,0 +1,6 @@
1
+ // Stub for the virtual module '#nuxt-multi-cache/config' that is normally
2
+ // generated at build time by nuxt-multi-cache. Tests pass header names
3
+ // explicitly to the CDN helper, so these defaults are never used.
4
+ export const cdnCacheControlHeader = ''
5
+ export const cdnCacheTagHeader = ''
6
+ export const cdnEnabled = true
package/config/blokkli.ts CHANGED
@@ -72,6 +72,7 @@ export default defineNuxtConfigProperty<'blokkli'>((ctx) => {
72
72
  },
73
73
  pattern: [
74
74
  './components/Paragraph/**/*.{js,ts,vue}',
75
+ './components/Blokkli/**/*.{js,ts,vue}',
75
76
  join(currentLayerDir, './components/Blokkli/**/*.{js,ts,vue}'),
76
77
  ],
77
78
  globalOptions: {
@@ -385,4 +386,4 @@ export default defineNuxtConfigProperty<'blokkli'>((ctx) => {
385
386
  // current page language.
386
387
  forceDefaultLanguage: false,
387
388
  }
388
- })
389
+ })
@@ -19,7 +19,7 @@ export default defineNuxtConfigProperty<'multiCache'>((ctx) => {
19
19
  },
20
20
  cdn: {
21
21
  enabled: true,
22
- cacheControlHeader: 'CDN-Cache-Control',
22
+ cacheControlHeader: 'Cache-Control',
23
23
  cacheTagHeader: 'Cache-Tag',
24
24
  },
25
25
  debug: ctx.DEV,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iqual/nuxt-icms",
3
- "version": "1.6.0-rc.4",
3
+ "version": "1.6.1",
4
4
  "description": "ICMS Base",
5
5
  "type": "module",
6
6
  "private": false,
@@ -32,7 +32,7 @@
32
32
  "nitropack": "~2.13.4",
33
33
  "nuxt": "~4.4.8",
34
34
  "nuxt-easy-texts": "~2.1.2",
35
- "nuxt-graphql-middleware": "~5.4.0",
35
+ "nuxt-graphql-middleware": "5.4.0",
36
36
  "nuxt-language-negotiation": "~2.0.2",
37
37
  "nuxt-maplibre": "~1.2.2",
38
38
  "nuxt-multi-cache": "~4.0.3",
@@ -0,0 +1,164 @@
1
+ diff --git a/node_modules/@blokkli/editor/dist/module.mjs b/node_modules/@blokkli/editor/dist/module.mjs
2
+ index 0ff0d3b..5329e9f 100644
3
+ --- a/node_modules/@blokkli/editor/dist/module.mjs
4
+ +++ b/node_modules/@blokkli/editor/dist/module.mjs
5
+ @@ -271,16 +271,31 @@ class CollectedBlockFile extends CollectedFile {
6
+ }
7
+ class BlockCollector extends Collector {
8
+ patterns;
9
+ + positivePatterns;
10
+ + negativePatterns;
11
+ + // Files that define the same block bundle (or fragment name) as a file in a
12
+ + // higher-priority Nuxt layer. They are kept here instead of in `files` so
13
+ + // they can be re-activated when the overriding file is removed.
14
+ + shadowed = /* @__PURE__ */ new Map();
15
+ constructor(helper) {
16
+ super(helper);
17
+ - this.patterns = (helper.options.pattern || []).map((pattern) => {
18
+ + this.patterns = (helper.options.pattern || []).map((raw) => {
19
+ + // Support negated glob patterns (e.g. "!**/components/Blokkli/Text/**")
20
+ + // to exclude components from collection.
21
+ + const isNegated = raw.startsWith("!");
22
+ + const pattern = isNegated ? raw.slice(1) : raw;
23
+ + let resolved = pattern;
24
+ if (pattern.startsWith("/")) {
25
+ - return pattern;
26
+ + resolved = pattern;
27
+ } else if (pattern.startsWith(".")) {
28
+ - return helper.resolvers.src.resolve(pattern);
29
+ + resolved = helper.resolvers.src.resolve(pattern);
30
+ + } else {
31
+ + resolved = resolveAlias(pattern);
32
+ }
33
+ - return resolveAlias(pattern);
34
+ + return isNegated ? "!" + resolved : resolved;
35
+ });
36
+ + this.positivePatterns = this.patterns.filter((v) => !v.startsWith("!"));
37
+ + this.negativePatterns = this.patterns.filter((v) => v.startsWith("!")).map((v) => v.slice(1));
38
+ }
39
+ async init() {
40
+ const files = await resolveFiles(
41
+ @@ -295,6 +310,90 @@ class BlockCollector extends Collector {
42
+ }
43
+ }
44
+ await Promise.all(promises);
45
+ + this.resolveLayerOverrides();
46
+ + }
47
+ + /**
48
+ + * Determine the Nuxt layer priority of a file.
49
+ + *
50
+ + * Lower value = higher priority. The app/project layer is index 0, extended
51
+ + * layers follow in the order they are defined. Files that can't be matched
52
+ + * to a layer get the lowest priority.
53
+ + */
54
+ + getLayerPriority(filePath) {
55
+ + const layers = this.helper.nuxt.options._layers || [];
56
+ + let priority = layers.length;
57
+ + let longestMatch = -1;
58
+ + for (let i = 0; i < layers.length; i++) {
59
+ + const config = layers[i].config || {};
60
+ + const dirs = [config.srcDir, config.rootDir, layers[i].cwd];
61
+ + for (const dir of dirs) {
62
+ + if (!dir) {
63
+ + continue;
64
+ + }
65
+ + const prefix = dir.endsWith("/") ? dir : dir + "/";
66
+ + if (filePath.startsWith(prefix) && dir.length > longestMatch) {
67
+ + priority = i;
68
+ + longestMatch = dir.length;
69
+ + }
70
+ + }
71
+ + }
72
+ + return priority;
73
+ + }
74
+ + /**
75
+ + * Resolve collisions where multiple collected files define the same block
76
+ + * bundle (or fragment name): the file from the highest-priority Nuxt layer
77
+ + * wins, the others are shadowed. Files in the same layer are left untouched
78
+ + * so genuine duplicates still surface as errors.
79
+ + *
80
+ + * @returns Whether the set of active files has changed.
81
+ + */
82
+ + resolveLayerOverrides() {
83
+ + const previouslyShadowed = this.shadowed;
84
+ + this.shadowed = /* @__PURE__ */ new Map();
85
+ + for (const [filePath, file] of previouslyShadowed) {
86
+ + if (!this.files.has(filePath)) {
87
+ + this.files.set(filePath, file);
88
+ + }
89
+ + }
90
+ + const byIdentifier = /* @__PURE__ */ new Map();
91
+ + for (const file of this.files.values()) {
92
+ + if (!file.definition || !file.identifier) {
93
+ + continue;
94
+ + }
95
+ + const group = byIdentifier.get(file.identifier);
96
+ + if (group) {
97
+ + group.push(file);
98
+ + } else {
99
+ + byIdentifier.set(file.identifier, [file]);
100
+ + }
101
+ + }
102
+ + for (const group of byIdentifier.values()) {
103
+ + if (group.length < 2) {
104
+ + continue;
105
+ + }
106
+ + const priorities = group.map((file) => this.getLayerPriority(file.filePath));
107
+ + const winningPriority = Math.min(...priorities);
108
+ + group.forEach((file, index) => {
109
+ + if (priorities[index] > winningPriority) {
110
+ + this.shadowed.set(file.filePath, file);
111
+ + this.files.delete(file.filePath);
112
+ + }
113
+ + });
114
+ + }
115
+ + if (previouslyShadowed.size !== this.shadowed.size) {
116
+ + return true;
117
+ + }
118
+ + for (const filePath of this.shadowed.keys()) {
119
+ + if (!previouslyShadowed.has(filePath)) {
120
+ + return true;
121
+ + }
122
+ + }
123
+ + return false;
124
+ + }
125
+ + async handleWatchEvent(event, filePath) {
126
+ + const result = await super.handleWatchEvent(event, filePath);
127
+ + const overridesChanged = this.resolveLayerOverrides();
128
+ + return { hasChanged: result.hasChanged || overridesChanged };
129
+ }
130
+ runHooks() {
131
+ return this.helper.nuxt.hooks.callHook("blokkli:alter-blocks", {
132
+ @@ -348,7 +447,10 @@ class BlockCollector extends Collector {
133
+ if (!filePath.endsWith(".vue")) {
134
+ return false;
135
+ }
136
+ - if (!micromatch.isMatch(filePath, this.patterns)) {
137
+ + if (!micromatch.isMatch(filePath, this.positivePatterns)) {
138
+ + return false;
139
+ + }
140
+ + if (this.negativePatterns.length && micromatch.isMatch(filePath, this.negativePatterns)) {
141
+ return false;
142
+ }
143
+ if (isEditComponent(filePath)) {
144
+ @@ -470,7 +572,19 @@ class IconCollector extends Collector {
145
+ this.helper.paths.srcDir,
146
+ "**/icon-blokkli-*.svg"
147
+ );
148
+ - const allFiles = [...filesModule, ...filesApp];
149
+ + // Also scan all Nuxt layer directories for icon SVGs.
150
+ + const layerFiles = [];
151
+ + const layers = this.helper.nuxt.options._layers || [];
152
+ + for (const layer of layers) {
153
+ + if (layer.config && layer.config.srcDir && layer.config.srcDir !== this.helper.paths.srcDir) {
154
+ + const files = await resolveFiles(
155
+ + layer.config.srcDir,
156
+ + "**/icon-blokkli-*.svg"
157
+ + );
158
+ + layerFiles.push(...files);
159
+ + }
160
+ + }
161
+ + const allFiles = [...filesModule, ...filesApp, ...layerFiles];
162
+ await Promise.all(allFiles.map((filePath) => this.addFile(filePath)));
163
+ }
164
+ runHooks() {
@@ -0,0 +1,232 @@
1
+ diff --git a/node_modules/nuxt-graphql-middleware/dist/module.mjs b/node_modules/nuxt-graphql-middleware/dist/module.mjs
2
+ index 8396abf..62f5068 100644
3
+ --- a/node_modules/nuxt-graphql-middleware/dist/module.mjs
4
+ +++ b/node_modules/nuxt-graphql-middleware/dist/module.mjs
5
+ @@ -215,12 +215,115 @@ class Collector {
6
+ */
7
+ fragments = [];
8
+ isInitialised = false;
9
+ + /**
10
+ + * The definition keys (kind:name) currently synced to the generator, per
11
+ + * file path.
12
+ + */
13
+ + syncedDefinitionKeys = /* @__PURE__ */ new Map();
14
+ + /**
15
+ + * File paths whose contents have changed since the last generator sync.
16
+ + */
17
+ + dirtyFiles = /* @__PURE__ */ new Set();
18
+ async reset() {
19
+ this.files.clear();
20
+ + this.syncedDefinitionKeys.clear();
21
+ + this.dirtyFiles.clear();
22
+ this.generator.reset();
23
+ this.operationTimestamps.clear();
24
+ this.rpcItems.clear();
25
+ }
26
+ + /**
27
+ + * Determine the Nuxt layer priority of a file.
28
+ + *
29
+ + * Lower value = higher priority. The app/project layer is index 0, extended
30
+ + * layers follow in the order they are defined. Documents provided via hooks
31
+ + * get the highest priority. Files that can't be matched to a layer get the
32
+ + * lowest priority.
33
+ + */
34
+ + getLayerPriority(filePath) {
35
+ + if (filePath.startsWith("hook:")) {
36
+ + return -1;
37
+ + }
38
+ + const layers = this.helper.nuxt.options._layers || [];
39
+ + let priority = layers.length;
40
+ + let longestMatch = -1;
41
+ + for (let i = 0; i < layers.length; i++) {
42
+ + const config = layers[i].config || {};
43
+ + const dirs = [config.srcDir, config.rootDir, layers[i].cwd];
44
+ + for (const dir of dirs) {
45
+ + if (!dir) {
46
+ + continue;
47
+ + }
48
+ + const prefix = dir.endsWith("/") ? dir : dir + "/";
49
+ + if (filePath.startsWith(prefix) && dir.length > longestMatch) {
50
+ + priority = i;
51
+ + longestMatch = dir.length;
52
+ + }
53
+ + }
54
+ + }
55
+ + return priority;
56
+ + }
57
+ + /**
58
+ + * Sync all collected files to the generator, resolving cross-layer
59
+ + * definition collisions: when multiple files define a fragment or operation
60
+ + * with the same name, the definition from the highest-priority Nuxt layer
61
+ + * (the app/project first, then extended layers in order) wins and the
62
+ + * shadowed definitions are excluded from the documents passed to the
63
+ + * generator. This makes it possible to override a fragment or operation
64
+ + * from a layer in the app or in a higher-priority layer. Definitions with
65
+ + * the same layer priority are left untouched (existing behaviour).
66
+ + */
67
+ + syncGenerator() {
68
+ + const owners = /* @__PURE__ */ new Map();
69
+ + for (const [filePath, file] of this.files) {
70
+ + const priority = this.getLayerPriority(filePath);
71
+ + for (const def of file.parsed.definitions) {
72
+ + if (def.kind !== "FragmentDefinition" && def.kind !== "OperationDefinition") {
73
+ + continue;
74
+ + }
75
+ + const name = def.name?.value;
76
+ + if (!name) {
77
+ + continue;
78
+ + }
79
+ + const key = def.kind + ":" + name;
80
+ + const owner = owners.get(key);
81
+ + if (!owner || priority < owner.priority) {
82
+ + owners.set(key, { priority });
83
+ + }
84
+ + }
85
+ + }
86
+ + for (const [filePath, file] of this.files) {
87
+ + const priority = this.getLayerPriority(filePath);
88
+ + const definitions = file.parsed.definitions.filter((def) => {
89
+ + if (def.kind !== "FragmentDefinition" && def.kind !== "OperationDefinition") {
90
+ + return true;
91
+ + }
92
+ + const name = def.name?.value;
93
+ + if (!name) {
94
+ + return true;
95
+ + }
96
+ + const owner = owners.get(def.kind + ":" + name);
97
+ + return !owner || owner.priority >= priority;
98
+ + });
99
+ + const keys = definitions.map((def) => def.kind + ":" + (def.name?.value || "")).join("|");
100
+ + const previousKeys = this.syncedDefinitionKeys.get(filePath);
101
+ + const documentNode = definitions.length === file.parsed.definitions.length ? file.parsed : { ...file.parsed, definitions };
102
+ + if (previousKeys === void 0) {
103
+ + this.generator.add({ filePath, documentNode });
104
+ + this.syncedDefinitionKeys.set(filePath, keys);
105
+ + } else if (previousKeys !== keys || this.dirtyFiles.has(filePath)) {
106
+ + this.generator.update({ filePath, documentNode });
107
+ + this.syncedDefinitionKeys.set(filePath, keys);
108
+ + }
109
+ + }
110
+ + for (const filePath of [...this.syncedDefinitionKeys.keys()]) {
111
+ + if (!this.files.has(filePath)) {
112
+ + this.generator.remove(filePath);
113
+ + this.syncedDefinitionKeys.delete(filePath);
114
+ + }
115
+ + }
116
+ + this.dirtyFiles.clear();
117
+ + }
118
+ async updateSchema(schema) {
119
+ this.schema = schema;
120
+ this.generator.updateSchema(schema);
121
+ @@ -436,10 +539,10 @@ class Collector {
122
+ const exists = this.hookDocuments.has(fullIdentifier);
123
+ this.hookDocuments.set(fullIdentifier, source);
124
+ if (exists && this.isInitialised) {
125
+ - this.generator.update({
126
+ - filePath: fullIdentifier,
127
+ - document: source
128
+ - });
129
+ + const file = new CollectedFile(fullIdentifier, source, false);
130
+ + this.files.set(fullIdentifier, file);
131
+ + this.dirtyFiles.add(fullIdentifier);
132
+ + this.syncGenerator();
133
+ await this.buildState();
134
+ }
135
+ }
136
+ @@ -460,23 +563,16 @@ class Collector {
137
+ const filePath = this.helper.paths.nuxtConfig;
138
+ const file = new CollectedFile(filePath, nuxtConfigDocuments, false);
139
+ this.files.set(filePath, file);
140
+ - this.generator.add({
141
+ - filePath,
142
+ - documentNode: file.parsed
143
+ - });
144
+ }
145
+ const hookDocuments = [...this.hookDocuments.entries()];
146
+ hookDocuments.forEach(([identifier, source]) => {
147
+ const file = new CollectedFile(identifier, source, false);
148
+ this.files.set(identifier, file);
149
+ - this.generator.add({
150
+ - filePath: identifier,
151
+ - documentNode: file.parsed
152
+ - });
153
+ });
154
+ for (const filePath of this.hookFiles) {
155
+ await this.addFile(filePath);
156
+ }
157
+ + this.syncGenerator();
158
+ await this.buildState();
159
+ if (!this.helper.isPrepare) {
160
+ logger.success("All GraphQL documents are valid.");
161
+ @@ -495,10 +591,6 @@ class Collector {
162
+ return null;
163
+ }
164
+ this.files.set(filePath, file);
165
+ - this.generator.add({
166
+ - filePath,
167
+ - documentNode: file.parsed
168
+ - });
169
+ return file;
170
+ }
171
+ matchesPatternOrExists(filePath) {
172
+ @@ -509,7 +601,11 @@ class Collector {
173
+ return false;
174
+ }
175
+ const result = await this.addFile(filePath);
176
+ - return !!result;
177
+ + if (!result) {
178
+ + return false;
179
+ + }
180
+ + this.syncGenerator();
181
+ + return true;
182
+ }
183
+ async handleChange(filePath) {
184
+ if (!this.matchesPatternOrExists(filePath)) {
185
+ @@ -524,10 +620,8 @@ class Collector {
186
+ if (!needsUpdate) {
187
+ return false;
188
+ }
189
+ - this.generator.update({
190
+ - filePath,
191
+ - documentNode: file.parsed
192
+ - });
193
+ + this.dirtyFiles.add(filePath);
194
+ + this.syncGenerator();
195
+ } catch {
196
+ return this.handleUnlink(filePath);
197
+ }
198
+ @@ -539,7 +633,7 @@ class Collector {
199
+ return false;
200
+ }
201
+ this.files.delete(filePath);
202
+ - this.generator.remove(filePath);
203
+ + this.syncGenerator();
204
+ return true;
205
+ }
206
+ handleUnlinkDir(folderPath) {
207
+ @@ -1128,7 +1222,24 @@ class ModuleHelper {
208
+ );
209
+ }
210
+ matchesImportPattern(filePath) {
211
+ - return micromatch.isMatch(filePath, this.options.autoImportPatterns) || this.options.autoImportPatterns.includes(filePath);
212
+ + // micromatch.isMatch with an array returns true if *any* pattern matches.
213
+ + // A negated pattern (e.g. "!node_modules") matches every path that does
214
+ + // not equal it, which would make every file match. Split the patterns and
215
+ + // apply proper include/exclude semantics instead.
216
+ + const positive = this.options.autoImportPatterns.filter(
217
+ + (v) => !v.startsWith("!")
218
+ + );
219
+ + const negative = this.options.autoImportPatterns.filter((v) => v.startsWith("!")).map((v) => v.slice(1));
220
+ + if (this.options.autoImportPatterns.includes(filePath)) {
221
+ + return true;
222
+ + }
223
+ + if (!micromatch.isMatch(filePath, positive)) {
224
+ + return false;
225
+ + }
226
+ + if (negative.length && micromatch.isMatch(filePath, negative)) {
227
+ + return false;
228
+ + }
229
+ + return true;
230
+ }
231
+ addAlias(name, path) {
232
+ this.nuxt.options.alias[name] = path;
@@ -3,7 +3,9 @@ import { readFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
5
  * Updates all installed @iqual packages to the given NPM dist-tag,
6
- * e.g. `rc` for testing ICMS release candidates.
6
+ * e.g. `rc` for testing ICMS release candidates. When `latest` points to a
7
+ * newer version than the given dist-tag (e.g. the stable release following
8
+ * the release candidates), the newer version wins.
7
9
  *
8
10
  * Usage (from the frontend root): bun run ./node_modules/@iqual/nuxt-icms/scripts/update-icms.ts <dist-tag>
9
11
  */
@@ -20,10 +22,30 @@ if (!distTag) {
20
22
 
21
23
  const pkg = JSON.parse(await readFile('./package.json', 'utf-8'))
22
24
 
25
+ const resolve = async (name: string): Promise<string> => {
26
+ const response = await fetch(`https://registry.npmjs.org/${name}`)
27
+ if (!response.ok) {
28
+ fail(`Failed to fetch dist-tags for ${name} (${response.status})`)
29
+ }
30
+ const tags: Record<string, string> = (await response.json())['dist-tags']
31
+ const tagged = tags[distTag]
32
+ if (!tagged) {
33
+ fail(`No \`${distTag}\` dist-tag found for ${name}`)
34
+ }
35
+ return tags.latest && Bun.semver.order(tags.latest, tagged) > 0
36
+ ? tags.latest
37
+ : tagged
38
+ }
39
+
23
40
  const pick = (dependencies?: Record<string, string>) =>
24
- Object.entries(dependencies ?? {})
25
- .filter(([name, constraint]) => name.startsWith('@iqual/') && !constraint.startsWith('workspace:'))
26
- .map(([name]) => `${name}@${distTag}`)
41
+ Promise.all(
42
+ Object.entries(dependencies ?? {})
43
+ .filter(
44
+ ([name, constraint]) =>
45
+ name.startsWith('@iqual/') && !constraint.startsWith('workspace:'),
46
+ )
47
+ .map(async ([name]) => `${name}@^${await resolve(name)}`),
48
+ )
27
49
 
28
50
  const add = (packages: string[], flags: string[] = []) => {
29
51
  if (packages.length === 0) return
@@ -34,5 +56,5 @@ const add = (packages: string[], flags: string[] = []) => {
34
56
  }
35
57
  }
36
58
 
37
- add(pick(pkg.dependencies))
38
- add(pick(pkg.devDependencies), ['--dev'])
59
+ add(await pick(pkg.dependencies))
60
+ add(await pick(pkg.devDependencies), ['--dev'])
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import {
3
+ createEvent,
4
+ getResponseHeaders,
5
+ appendResponseHeader,
6
+ setResponseStatus,
7
+ } from 'h3'
8
+ import type { H3Event } from 'h3'
9
+ import { IncomingMessage, ServerResponse } from 'node-mock-http'
10
+ import { NuxtMultiCacheCDNHelper } from 'nuxt-multi-cache/dist/runtime/helpers/CDNHelper.js'
11
+ import multiCache from '../../config/multiCache'
12
+
13
+ vi.mock('#nuxt-language-negotiation/config', () => ({
14
+ defaultLangcode: 'de',
15
+ langcodes: ['de', 'fr', 'it', 'en'],
16
+ }))
17
+
18
+ vi.mock('#shared/request', () => ({
19
+ buildProxyRequestHeaders: () => ({}),
20
+ }))
21
+
22
+ // Nitro auto-imports in the module under test, provided as globals: the h3
23
+ // helpers are the real ones, useCDNHeaders mirrors nuxt-multi-cache's server
24
+ // util using the real CDN helper and the real multiCache configuration — so
25
+ // these tests cover the full chain from config to emitted header values.
26
+ vi.stubGlobal('appendResponseHeader', appendResponseHeader)
27
+ vi.stubGlobal('setResponseStatus', setResponseStatus)
28
+ vi.stubGlobal(
29
+ 'useCDNHeaders',
30
+ (
31
+ cb: (helper: InstanceType<typeof NuxtMultiCacheCDNHelper>) => void,
32
+ event: H3Event,
33
+ ) => {
34
+ const helper = new NuxtMultiCacheCDNHelper(
35
+ Math.floor(Date.now() / 1000),
36
+ multiCache.cdn!.cacheControlHeader,
37
+ multiCache.cdn!.cacheTagHeader,
38
+ )
39
+ cb(helper)
40
+ helper.applyToEvent(event)
41
+ },
42
+ )
43
+
44
+ const serverOptions = (await import('../graphqlMiddleware.serverOptions'))
45
+ .default
46
+
47
+ function freshEvent() {
48
+ return createEvent(new IncomingMessage(), new ServerResponse())
49
+ }
50
+
51
+ function drupalResponse({
52
+ expires = '-1',
53
+ purgeCacheTags = 'graphql_response node:12 http_response',
54
+ data = { route: { label: 'Test' } } as Record<string, unknown>,
55
+ }: {
56
+ expires?: string | null
57
+ purgeCacheTags?: string
58
+ data?: Record<string, unknown>
59
+ } = {}) {
60
+ const headers = new Headers({
61
+ 'purge-cache-tags': purgeCacheTags,
62
+ 'x-nuxt-cache-tags': 'N:12 h5ej',
63
+ })
64
+ if (expires !== null) {
65
+ headers.set('x-nuxt-expires', expires)
66
+ }
67
+ return {
68
+ headers,
69
+ _data: { data, errors: [] },
70
+ } as never
71
+ }
72
+
73
+ const clientContext = { client: { env: 'client' } } as never
74
+
75
+ /**
76
+ * Guards the CDN headers on the /api/graphql/* server routes, used by
77
+ * SPA-style client navigation (ICMS-86; the pass-through was lost in the
78
+ * nuxt-graphql-middleware v5 upgrade and restored with ICMS-689). Without
79
+ * them, Varnish caches these responses for its 120s default TTL and cannot
80
+ * purge them by cache tag.
81
+ */
82
+ describe('onServerResponse', () => {
83
+ beforeEach(() => {
84
+ vi.restoreAllMocks()
85
+ })
86
+
87
+ it('sets Cache-Control and the raw Drupal cache tags for cacheable queries', () => {
88
+ const event = freshEvent()
89
+
90
+ serverOptions.onServerResponse!(
91
+ event,
92
+ drupalResponse(),
93
+ 'query',
94
+ 'route',
95
+ clientContext,
96
+ )
97
+
98
+ const headers = getResponseHeaders(event)
99
+ expect(headers['cache-control']).toMatch(/\bs-maxage=31536000\b/)
100
+ expect(headers['cache-control']).toMatch(/\bpublic\b/)
101
+ // The raw purge tags purge Varnish; the encoded nuxt tags must not
102
+ // appear — they only concern the internal data cache.
103
+ expect(headers['cache-tag']).toBe(
104
+ 'graphql_response node:12 http_response nuxt:api:graphql',
105
+ )
106
+ })
107
+
108
+ it('marks uncacheable responses (x-nuxt-expires: 0) as private', () => {
109
+ const event = freshEvent()
110
+
111
+ serverOptions.onServerResponse!(
112
+ event,
113
+ drupalResponse({ expires: '0' }),
114
+ 'query',
115
+ 'route',
116
+ clientContext,
117
+ )
118
+
119
+ const headers = getResponseHeaders(event)
120
+ expect(headers['cache-control']).toMatch(/\bprivate\b/)
121
+ expect(headers['cache-control']).not.toMatch(/s-maxage/)
122
+ })
123
+
124
+ it('marks mutations as private', () => {
125
+ const event = freshEvent()
126
+
127
+ serverOptions.onServerResponse!(
128
+ event,
129
+ drupalResponse(),
130
+ 'mutation',
131
+ 'submitForm',
132
+ clientContext,
133
+ )
134
+
135
+ expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
136
+ })
137
+
138
+ it('marks responses containing Drupal messages as private', () => {
139
+ const event = freshEvent()
140
+
141
+ serverOptions.onServerResponse!(
142
+ event,
143
+ drupalResponse({
144
+ data: { messengerMessages: [{ type: 'error', safe: 'Nope' }] },
145
+ }),
146
+ 'query',
147
+ 'route',
148
+ clientContext,
149
+ )
150
+
151
+ expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
152
+ })
153
+ })
154
+
155
+ describe('onServerError', () => {
156
+ it('marks error responses as private with status 500', () => {
157
+ vi.spyOn(console, 'log').mockImplementation(() => {})
158
+ const event = freshEvent()
159
+
160
+ serverOptions.onServerError!(
161
+ event,
162
+ new Error('backend down') as never,
163
+ 'query',
164
+ 'route',
165
+ )
166
+
167
+ expect(event.node.res.statusCode).toBe(500)
168
+ expect(getResponseHeaders(event)['cache-control']).toMatch(/\bprivate\b/)
169
+ })
170
+ })
@@ -1,7 +1,7 @@
1
1
  import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/server-options'
2
2
  import { extractCacheability } from '../server/utils/cacheability'
3
3
  import { defaultLangcode, langcodes } from '#nuxt-language-negotiation/config'
4
- import type { GraphqlCacheability } from '../server/helpers'
4
+ import { MAX_AGE, type GraphqlCacheability } from '../server/helpers'
5
5
  import { buildProxyRequestHeaders } from '#shared/request'
6
6
 
7
7
  export default defineGraphqlServerOptions<{
@@ -14,7 +14,7 @@ export default defineGraphqlServerOptions<{
14
14
  const clientContextLanguage = context?.client?.language
15
15
  if (
16
16
  clientContextLanguage &&
17
- langcodes.includes(clientContextLanguage as any)
17
+ langcodes.includes(clientContextLanguage as (typeof langcodes)[number])
18
18
  ) {
19
19
  return `${config.backendUrl}/${clientContextLanguage}/graphql`
20
20
  }
@@ -31,13 +31,7 @@ export default defineGraphqlServerOptions<{
31
31
  },
32
32
  }
33
33
  },
34
- onServerResponse(
35
- event,
36
- graphqlResponse,
37
- _operation,
38
- _operationName,
39
- context,
40
- ) {
34
+ onServerResponse(event, graphqlResponse, operation, operationName, context) {
41
35
  // Pass the set-cookie header from the GraphQL response to the client.
42
36
  const setCookie = graphqlResponse.headers.getSetCookie()
43
37
 
@@ -58,6 +52,27 @@ export default defineGraphqlServerOptions<{
58
52
  cacheability.isCacheable = false
59
53
  }
60
54
 
55
+ // Set the CDN headers (Cache-Control, Cache-Tag) on this API route's
56
+ // response, so that Varnish caches client-side navigation requests with
57
+ // the correct TTL and purges them by cache tag (ICMS-86/ICMS-689 —
58
+ // originally introduced in PR #71, lost in the nuxt-graphql-middleware
59
+ // v5 upgrade). The raw Drupal tags (tagsCdn) are used here; the encoded
60
+ // tags (tagsNuxt) only concern the internal data cache.
61
+ useCDNHeaders((cdn) => {
62
+ if (operation === 'query' && operationName && cacheability.isCacheable) {
63
+ cdn
64
+ .public()
65
+ .setNumeric('sharedMaxAge', cacheability.sharedMaxAge)
66
+ .set('staleIfError', MAX_AGE.ONE_DAY)
67
+ .set('staleWhileRevalidate', MAX_AGE.TWO_HOURS)
68
+ .set('mustRevalidate', true)
69
+ .addTags(cacheability.tagsCdn)
70
+ .addTags(['nuxt:api:graphql'])
71
+ } else {
72
+ cdn.private()
73
+ }
74
+ }, event)
75
+
61
76
  const isServerRequest = context?.client?.env === 'server'
62
77
 
63
78
  // This is only provided when the request originates from SSR.
@@ -77,10 +92,11 @@ export default defineGraphqlServerOptions<{
77
92
  },
78
93
  onServerError(event, error) {
79
94
  setResponseStatus(event, 500)
95
+ useCDNHeaders((cdn) => cdn.private(), event)
80
96
  console.log('onServerError', error)
81
97
  return {
82
98
  data: null,
83
99
  errors: [],
84
100
  }
85
101
  },
86
- })
102
+ })