fontdue-js 3.0.6 → 3.1.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.
- package/CHANGELOG.md +10 -0
- package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.d.ts +23 -0
- package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.js +62 -0
- package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.d.ts +25 -0
- package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.js +240 -0
- package/dist/__generated__/FeatureTestersIdQuery.graphql.d.ts +21 -0
- package/dist/__generated__/FeatureTestersIdQuery.graphql.js +344 -0
- package/dist/__generated__/FeatureTestersSlugQuery.graphql.d.ts +25 -0
- package/dist/__generated__/FeatureTestersSlugQuery.graphql.js +364 -0
- package/dist/__generated__/FeatureTesters_collection.graphql.d.ts +39 -0
- package/dist/__generated__/FeatureTesters_collection.graphql.js +145 -0
- package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.d.ts +2 -1
- package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.js +10 -4
- package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +21 -0
- package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +86 -0
- package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.d.ts +25 -0
- package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.js +108 -0
- package/dist/__tests__/createFontdueFetch.test.js +128 -4
- package/dist/__tests__/highlight.test.js +141 -0
- package/dist/__tests__/middleware.test.js +132 -0
- package/dist/components/FeatureTester/FeatureTesterCard.d.ts +30 -0
- package/dist/components/FeatureTester/FeatureTesterCard.js +167 -0
- package/dist/components/FeatureTester/FeatureTesterElement.d.ts +19 -0
- package/dist/components/FeatureTester/FeatureTesterElement.js +42 -0
- package/dist/components/FeatureTester/FeatureTesters.d.ts +43 -0
- package/dist/components/FeatureTester/FeatureTesters.js +156 -0
- package/dist/components/FeatureTester/FeatureTesters.server.d.ts +9 -0
- package/dist/components/FeatureTester/FeatureTesters.server.js +40 -0
- package/dist/components/FeatureTester/FeatureTestersElement.d.ts +10 -0
- package/dist/components/FeatureTester/FeatureTestersElement.js +27 -0
- package/dist/components/FeatureTester/highlight.d.ts +19 -0
- package/dist/components/FeatureTester/highlight.js +71 -0
- package/dist/components/FeatureTester/index.d.ts +26 -0
- package/dist/components/FeatureTester/index.js +66 -0
- package/dist/components/FeatureTester/index.server.d.ts +7 -0
- package/dist/components/FeatureTester/index.server.js +30 -0
- package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +3 -0
- package/dist/components/NodePasswordForm/NodePasswordFormElement.js +65 -0
- package/dist/components/NodePasswordForm/index.d.ts +16 -1
- package/dist/components/NodePasswordForm/index.js +30 -59
- package/dist/components/Root/index.js +2 -1
- package/dist/fontdue.css +145 -0
- package/dist/hooks/useFeatureTesterAutofit.d.ts +17 -0
- package/dist/hooks/useFeatureTesterAutofit.js +106 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +16 -1
- package/dist/next/registerSingleTenantResolver.js +13 -1
- package/dist/next/tenant.d.ts +1 -0
- package/dist/next/tenant.js +27 -2
- package/dist/nodeAccess.d.ts +18 -0
- package/dist/nodeAccess.js +84 -0
- package/dist/preview/server.d.ts +1 -20
- package/dist/preview/server.js +16 -85
- package/dist/relay/environment.js +8 -2
- package/dist/relay/serverConfig.d.ts +3 -0
- package/dist/relay/serverConfig.js +22 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +90 -7
- package/dist/server/middleware.d.ts +37 -0
- package/dist/server/middleware.js +144 -0
- package/package.json +8 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated SignedSource<<b5b872a45d8e38f1905efd817d33f729>>
|
|
3
|
+
* @lightSyntaxTransform
|
|
4
|
+
* @nogrep
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* tslint:disable */
|
|
8
|
+
/* eslint-disable */
|
|
9
|
+
// @ts-nocheck
|
|
10
|
+
|
|
11
|
+
const node = function () {
|
|
12
|
+
var v0 = [{
|
|
13
|
+
"defaultValue": null,
|
|
14
|
+
"kind": "LocalArgument",
|
|
15
|
+
"name": "collectionId"
|
|
16
|
+
}],
|
|
17
|
+
v1 = [{
|
|
18
|
+
"kind": "Variable",
|
|
19
|
+
"name": "id",
|
|
20
|
+
"variableName": "collectionId"
|
|
21
|
+
}],
|
|
22
|
+
v2 = {
|
|
23
|
+
"alias": null,
|
|
24
|
+
"args": null,
|
|
25
|
+
"kind": "ScalarField",
|
|
26
|
+
"name": "id",
|
|
27
|
+
"storageKey": null
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
"fragment": {
|
|
31
|
+
"argumentDefinitions": v0 /*: any*/,
|
|
32
|
+
"kind": "Fragment",
|
|
33
|
+
"metadata": null,
|
|
34
|
+
"name": "NodePasswordFormElementIDQuery",
|
|
35
|
+
"selections": [{
|
|
36
|
+
"kind": "CatchField",
|
|
37
|
+
"field": {
|
|
38
|
+
"alias": "collection",
|
|
39
|
+
"args": v1 /*: any*/,
|
|
40
|
+
"concreteType": null,
|
|
41
|
+
"kind": "LinkedField",
|
|
42
|
+
"name": "node",
|
|
43
|
+
"plural": false,
|
|
44
|
+
"selections": [v2 /*: any*/],
|
|
45
|
+
"storageKey": null
|
|
46
|
+
},
|
|
47
|
+
"to": "RESULT",
|
|
48
|
+
"path": "collection"
|
|
49
|
+
}],
|
|
50
|
+
"type": "RootQueryType",
|
|
51
|
+
"abstractKey": null
|
|
52
|
+
},
|
|
53
|
+
"kind": "Request",
|
|
54
|
+
"operation": {
|
|
55
|
+
"argumentDefinitions": v0 /*: any*/,
|
|
56
|
+
"kind": "Operation",
|
|
57
|
+
"name": "NodePasswordFormElementIDQuery",
|
|
58
|
+
"selections": [{
|
|
59
|
+
"alias": "collection",
|
|
60
|
+
"args": v1 /*: any*/,
|
|
61
|
+
"concreteType": null,
|
|
62
|
+
"kind": "LinkedField",
|
|
63
|
+
"name": "node",
|
|
64
|
+
"plural": false,
|
|
65
|
+
"selections": [{
|
|
66
|
+
"alias": null,
|
|
67
|
+
"args": null,
|
|
68
|
+
"kind": "ScalarField",
|
|
69
|
+
"name": "__typename",
|
|
70
|
+
"storageKey": null
|
|
71
|
+
}, v2 /*: any*/],
|
|
72
|
+
"storageKey": null
|
|
73
|
+
}]
|
|
74
|
+
},
|
|
75
|
+
"params": {
|
|
76
|
+
"cacheID": "c41065c50841d1bdce6230e2b450a07e",
|
|
77
|
+
"id": null,
|
|
78
|
+
"metadata": {},
|
|
79
|
+
"name": "NodePasswordFormElementIDQuery",
|
|
80
|
+
"operationKind": "query",
|
|
81
|
+
"text": "query NodePasswordFormElementIDQuery(\n $collectionId: ID!\n) {\n collection: node(id: $collectionId) {\n __typename\n id\n }\n}\n"
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}();
|
|
85
|
+
node.hash = "213a61ae079e4f57b7b197b0d2f5256a";
|
|
86
|
+
export default node;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated SignedSource<<ced3630de7c010a0f139c2788e5d6a86>>
|
|
3
|
+
* @lightSyntaxTransform
|
|
4
|
+
* @nogrep
|
|
5
|
+
*/
|
|
6
|
+
import { ConcreteRequest } from 'relay-runtime';
|
|
7
|
+
import { Result } from "relay-runtime";
|
|
8
|
+
export type NodePasswordFormElementSlugQuery$variables = {
|
|
9
|
+
collectionSlug: string;
|
|
10
|
+
};
|
|
11
|
+
export type NodePasswordFormElementSlugQuery$data = {
|
|
12
|
+
readonly viewer: {
|
|
13
|
+
readonly slug: {
|
|
14
|
+
readonly collection: Result<{
|
|
15
|
+
readonly id: string;
|
|
16
|
+
} | null, ReadonlyArray<unknown>>;
|
|
17
|
+
} | null;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export type NodePasswordFormElementSlugQuery = {
|
|
21
|
+
response: NodePasswordFormElementSlugQuery$data;
|
|
22
|
+
variables: NodePasswordFormElementSlugQuery$variables;
|
|
23
|
+
};
|
|
24
|
+
declare const node: ConcreteRequest;
|
|
25
|
+
export default node;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated SignedSource<<ced3630de7c010a0f139c2788e5d6a86>>
|
|
3
|
+
* @lightSyntaxTransform
|
|
4
|
+
* @nogrep
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* tslint:disable */
|
|
8
|
+
/* eslint-disable */
|
|
9
|
+
// @ts-nocheck
|
|
10
|
+
|
|
11
|
+
const node = function () {
|
|
12
|
+
var v0 = [{
|
|
13
|
+
"defaultValue": null,
|
|
14
|
+
"kind": "LocalArgument",
|
|
15
|
+
"name": "collectionSlug"
|
|
16
|
+
}],
|
|
17
|
+
v1 = [{
|
|
18
|
+
"kind": "Variable",
|
|
19
|
+
"name": "name",
|
|
20
|
+
"variableName": "collectionSlug"
|
|
21
|
+
}],
|
|
22
|
+
v2 = {
|
|
23
|
+
"alias": null,
|
|
24
|
+
"args": null,
|
|
25
|
+
"kind": "ScalarField",
|
|
26
|
+
"name": "id",
|
|
27
|
+
"storageKey": null
|
|
28
|
+
},
|
|
29
|
+
v3 = {
|
|
30
|
+
"alias": "collection",
|
|
31
|
+
"args": null,
|
|
32
|
+
"concreteType": "FontCollection",
|
|
33
|
+
"kind": "LinkedField",
|
|
34
|
+
"name": "fontCollection",
|
|
35
|
+
"plural": false,
|
|
36
|
+
"selections": [v2 /*: any*/],
|
|
37
|
+
"storageKey": null
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
"fragment": {
|
|
41
|
+
"argumentDefinitions": v0 /*: any*/,
|
|
42
|
+
"kind": "Fragment",
|
|
43
|
+
"metadata": null,
|
|
44
|
+
"name": "NodePasswordFormElementSlugQuery",
|
|
45
|
+
"selections": [{
|
|
46
|
+
"alias": null,
|
|
47
|
+
"args": null,
|
|
48
|
+
"concreteType": "Viewer",
|
|
49
|
+
"kind": "LinkedField",
|
|
50
|
+
"name": "viewer",
|
|
51
|
+
"plural": false,
|
|
52
|
+
"selections": [{
|
|
53
|
+
"alias": null,
|
|
54
|
+
"args": v1 /*: any*/,
|
|
55
|
+
"concreteType": "Slug",
|
|
56
|
+
"kind": "LinkedField",
|
|
57
|
+
"name": "slug",
|
|
58
|
+
"plural": false,
|
|
59
|
+
"selections": [{
|
|
60
|
+
"kind": "CatchField",
|
|
61
|
+
"field": v3 /*: any*/,
|
|
62
|
+
"to": "RESULT",
|
|
63
|
+
"path": "viewer.slug.collection"
|
|
64
|
+
}],
|
|
65
|
+
"storageKey": null
|
|
66
|
+
}],
|
|
67
|
+
"storageKey": null
|
|
68
|
+
}],
|
|
69
|
+
"type": "RootQueryType",
|
|
70
|
+
"abstractKey": null
|
|
71
|
+
},
|
|
72
|
+
"kind": "Request",
|
|
73
|
+
"operation": {
|
|
74
|
+
"argumentDefinitions": v0 /*: any*/,
|
|
75
|
+
"kind": "Operation",
|
|
76
|
+
"name": "NodePasswordFormElementSlugQuery",
|
|
77
|
+
"selections": [{
|
|
78
|
+
"alias": null,
|
|
79
|
+
"args": null,
|
|
80
|
+
"concreteType": "Viewer",
|
|
81
|
+
"kind": "LinkedField",
|
|
82
|
+
"name": "viewer",
|
|
83
|
+
"plural": false,
|
|
84
|
+
"selections": [{
|
|
85
|
+
"alias": null,
|
|
86
|
+
"args": v1 /*: any*/,
|
|
87
|
+
"concreteType": "Slug",
|
|
88
|
+
"kind": "LinkedField",
|
|
89
|
+
"name": "slug",
|
|
90
|
+
"plural": false,
|
|
91
|
+
"selections": [v3 /*: any*/, v2 /*: any*/],
|
|
92
|
+
"storageKey": null
|
|
93
|
+
}, v2 /*: any*/],
|
|
94
|
+
"storageKey": null
|
|
95
|
+
}]
|
|
96
|
+
},
|
|
97
|
+
"params": {
|
|
98
|
+
"cacheID": "a2d0f6a8f2a084cccbb21e430138963f",
|
|
99
|
+
"id": null,
|
|
100
|
+
"metadata": {},
|
|
101
|
+
"name": "NodePasswordFormElementSlugQuery",
|
|
102
|
+
"operationKind": "query",
|
|
103
|
+
"text": "query NodePasswordFormElementSlugQuery(\n $collectionSlug: String!\n) {\n viewer {\n slug(name: $collectionSlug) {\n collection: fontCollection {\n id\n }\n id\n }\n id\n }\n}\n"
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}();
|
|
107
|
+
node.hash = "200a33ca6a1f9e4e7e95b7100bbdd261";
|
|
108
|
+
export default node;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import { createFontdueFetch, FontdueNotFoundError } from '../server/index.js';
|
|
3
|
-
import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
|
|
2
|
+
import { createFontdueFetch, FontdueNotFoundError, FontduePasswordProtectedError } from '../server/index.js';
|
|
3
|
+
import { registerAmbientConfigResolver, registerNodeAccessRecovery } from '../relay/serverConfig.js';
|
|
4
|
+
import * as serverConfig from '../relay/serverConfig.js';
|
|
5
|
+
import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
|
|
4
6
|
beforeEach(() => {
|
|
5
7
|
vi.unstubAllEnvs();
|
|
6
8
|
vi.unstubAllGlobals();
|
|
7
9
|
});
|
|
8
10
|
|
|
9
11
|
// Several tests inject a per-render config through the ambient resolver seam
|
|
10
|
-
// (the same seam runWithPreview and the Next slot use)
|
|
12
|
+
// (the same seam runWithPreview and the Next slot use) or register a node-access
|
|
13
|
+
// recovery. The store (resolver + recovery) is anchored on globalThis, so wipe
|
|
14
|
+
// it — and restore any spies — so registrations can't leak between tests.
|
|
11
15
|
afterEach(() => {
|
|
12
|
-
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
delete globalThis.__fontdueServerConfigStore__;
|
|
13
18
|
});
|
|
14
19
|
function withConfig(config) {
|
|
15
20
|
registerAmbientConfigResolver(() => config);
|
|
@@ -273,4 +278,123 @@ describe('createFontdueFetch', () => {
|
|
|
273
278
|
expect(init.headers['fontdue-preview']).toBe('true');
|
|
274
279
|
});
|
|
275
280
|
});
|
|
281
|
+
|
|
282
|
+
// A password-protected collection comes back as a `password_protected` error.
|
|
283
|
+
// In a server render the Next adapter registers a recovery that reads the
|
|
284
|
+
// visitor's node-access cookie; createFontdueFetch folds the token into the
|
|
285
|
+
// per-render config (so the page fetch AND the embedded RSC components' own
|
|
286
|
+
// Suspense preloads resolve) and retries. No recovery / no token → the error
|
|
287
|
+
// propagates so the page can render the password form.
|
|
288
|
+
describe('node-access recovery (password-protected → Next adapter)', () => {
|
|
289
|
+
const locked = () => ({
|
|
290
|
+
status: 200,
|
|
291
|
+
json: async () => ({
|
|
292
|
+
errors: [{
|
|
293
|
+
message: 'locked',
|
|
294
|
+
code: 'password_protected'
|
|
295
|
+
}]
|
|
296
|
+
})
|
|
297
|
+
});
|
|
298
|
+
it('folds the unlock token into the per-render config and retries', async () => {
|
|
299
|
+
var _foldedConfig$headers;
|
|
300
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
301
|
+
// Stand-in for the Next adapter's cookie read.
|
|
302
|
+
registerNodeAccessRecovery(() => ({
|
|
303
|
+
[NODE_ACCESS_HEADER]: 'na-tok'
|
|
304
|
+
}));
|
|
305
|
+
// The per-render slot the embeds read (set by __prepareFontdueRender).
|
|
306
|
+
withConfig({
|
|
307
|
+
url: 'https://tenant.fontdue.com',
|
|
308
|
+
cacheTags: ['graphql:tenant.fontdue.com']
|
|
309
|
+
});
|
|
310
|
+
// The embeds (createNetworkFetch) read the slot, so the recovered token
|
|
311
|
+
// must be folded there — this is the assertion that proves they resolve.
|
|
312
|
+
const folded = vi.spyOn(serverConfig, 'setFontdueServerConfig');
|
|
313
|
+
let call = 0;
|
|
314
|
+
const fetchMock = mockFetch(() => {
|
|
315
|
+
call += 1;
|
|
316
|
+
return call === 1 ? locked() : {
|
|
317
|
+
status: 200,
|
|
318
|
+
json: async () => ({
|
|
319
|
+
data: {
|
|
320
|
+
ok: true
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
const data = await createFontdueFetch()('Font', 'query Font { ok }');
|
|
326
|
+
expect(data).toEqual({
|
|
327
|
+
ok: true
|
|
328
|
+
});
|
|
329
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
330
|
+
expect(folded).toHaveBeenCalledTimes(1);
|
|
331
|
+
const foldedConfig = folded.mock.calls[0][0];
|
|
332
|
+
expect((_foldedConfig$headers = foldedConfig.headers) === null || _foldedConfig$headers === void 0 ? void 0 : _foldedConfig$headers[NODE_ACCESS_HEADER]).toBe('na-tok');
|
|
333
|
+
// Dropped so the per-visitor unlocked render is never cached.
|
|
334
|
+
expect(foldedConfig.cacheTags).toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
it('rethrows FontduePasswordProtectedError when the visitor has no token', async () => {
|
|
337
|
+
registerNodeAccessRecovery(() => ({}));
|
|
338
|
+
mockFetch(locked);
|
|
339
|
+
await expect(createFontdueFetch({
|
|
340
|
+
url: 'https://acme.fontdue.com'
|
|
341
|
+
})('Font', 'query Font { ok }')).rejects.toBeInstanceOf(FontduePasswordProtectedError);
|
|
342
|
+
});
|
|
343
|
+
it('rethrows FontduePasswordProtectedError when no recovery is registered (non-Next)', async () => {
|
|
344
|
+
mockFetch(locked);
|
|
345
|
+
await expect(createFontdueFetch({
|
|
346
|
+
url: 'https://acme.fontdue.com'
|
|
347
|
+
})('Font', 'query Font { ok }')).rejects.toBeInstanceOf(FontduePasswordProtectedError);
|
|
348
|
+
});
|
|
349
|
+
it('detects password_protected even when it is not the first error', async () => {
|
|
350
|
+
const multiLocked = () => ({
|
|
351
|
+
status: 200,
|
|
352
|
+
json: async () => ({
|
|
353
|
+
errors: [{
|
|
354
|
+
message: 'other field failed',
|
|
355
|
+
code: 'something_else'
|
|
356
|
+
}, {
|
|
357
|
+
message: 'locked',
|
|
358
|
+
code: 'password_protected'
|
|
359
|
+
}]
|
|
360
|
+
})
|
|
361
|
+
});
|
|
362
|
+
registerNodeAccessRecovery(() => ({}));
|
|
363
|
+
mockFetch(multiLocked);
|
|
364
|
+
await expect(createFontdueFetch({
|
|
365
|
+
url: 'https://acme.fontdue.com'
|
|
366
|
+
})('Font', 'query Font { ok }')).rejects.toBeInstanceOf(FontduePasswordProtectedError);
|
|
367
|
+
});
|
|
368
|
+
it('does not force-cache the retried unlocked render even when the fetcher was given explicit cacheTags', async () => {
|
|
369
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
370
|
+
registerNodeAccessRecovery(() => ({
|
|
371
|
+
[NODE_ACCESS_HEADER]: 'na-tok'
|
|
372
|
+
}));
|
|
373
|
+
let call = 0;
|
|
374
|
+
const fetchMock = mockFetch(() => {
|
|
375
|
+
call += 1;
|
|
376
|
+
return call === 1 ? locked() : {
|
|
377
|
+
status: 200,
|
|
378
|
+
json: async () => ({
|
|
379
|
+
data: {
|
|
380
|
+
ok: true
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// The explicit option would otherwise win over the config's dropped tags.
|
|
387
|
+
const data = await createFontdueFetch({
|
|
388
|
+
url: 'https://tenant.fontdue.com',
|
|
389
|
+
cacheTags: ['graphql:tenant.fontdue.com']
|
|
390
|
+
})('Font', 'query Font { ok }');
|
|
391
|
+
expect(data).toEqual({
|
|
392
|
+
ok: true
|
|
393
|
+
});
|
|
394
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
395
|
+
const retryInit = fetchMock.mock.calls[1][1];
|
|
396
|
+
expect(retryInit.cache).not.toBe('force-cache');
|
|
397
|
+
expect(retryInit.next).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
276
400
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sensitiveStringsForFeature, computeSensitivityFlags, segmentByFlags } from "../components/FeatureTester/highlight.js";
|
|
3
|
+
describe('sensitiveStringsForFeature', () => {
|
|
4
|
+
const glyphs = [{
|
|
5
|
+
characters: 'a',
|
|
6
|
+
features: ['ss03']
|
|
7
|
+
}, {
|
|
8
|
+
characters: 'g',
|
|
9
|
+
features: ['ss03', 'ss05']
|
|
10
|
+
}, {
|
|
11
|
+
characters: 'a',
|
|
12
|
+
features: ['ss03']
|
|
13
|
+
},
|
|
14
|
+
// duplicate
|
|
15
|
+
{
|
|
16
|
+
characters: 'x',
|
|
17
|
+
features: null
|
|
18
|
+
},
|
|
19
|
+
// no features
|
|
20
|
+
{
|
|
21
|
+
characters: 'ffi',
|
|
22
|
+
features: ['dlig']
|
|
23
|
+
}];
|
|
24
|
+
it('returns the de-duplicated input strings a feature substitutes', () => {
|
|
25
|
+
expect(sensitiveStringsForFeature(glyphs, 'ss03').sort()).toEqual(['a', 'g']);
|
|
26
|
+
});
|
|
27
|
+
it('matches a feature that is one of several on a glyph', () => {
|
|
28
|
+
expect(sensitiveStringsForFeature(glyphs, 'ss05')).toEqual(['g']);
|
|
29
|
+
});
|
|
30
|
+
it('returns ligature (multi-char) inputs', () => {
|
|
31
|
+
expect(sensitiveStringsForFeature(glyphs, 'dlig')).toEqual(['ffi']);
|
|
32
|
+
});
|
|
33
|
+
it('returns [] for a feature no glyph carries (the contextual signal)', () => {
|
|
34
|
+
expect(sensitiveStringsForFeature(glyphs, 'frac')).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
it('is empty for null/undefined glyphNames', () => {
|
|
37
|
+
expect(sensitiveStringsForFeature(null, 'ss03')).toEqual([]);
|
|
38
|
+
expect(sensitiveStringsForFeature(undefined, 'ss03')).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('computeSensitivityFlags', () => {
|
|
42
|
+
it('flags every occurrence of an auto string (by code point)', () => {
|
|
43
|
+
expect(computeSensitivityFlags('abcabc', {
|
|
44
|
+
autoStrings: ['a']
|
|
45
|
+
})).toEqual([true, false, false, true, false, false]);
|
|
46
|
+
});
|
|
47
|
+
it('flags whole multi-char auto strings', () => {
|
|
48
|
+
expect(computeSensitivityFlags('abcabc', {
|
|
49
|
+
autoStrings: ['ab']
|
|
50
|
+
})).toEqual([true, true, false, true, true, false]);
|
|
51
|
+
});
|
|
52
|
+
it('flags shaped-diff ranges', () => {
|
|
53
|
+
expect(computeSensitivityFlags('abcabc', {
|
|
54
|
+
addMarks: [{
|
|
55
|
+
start: 2,
|
|
56
|
+
end: 4
|
|
57
|
+
}]
|
|
58
|
+
})).toEqual([false, false, true, true, true, false]);
|
|
59
|
+
});
|
|
60
|
+
it('composes ranges on top of auto strings', () => {
|
|
61
|
+
expect(computeSensitivityFlags('abcabc', {
|
|
62
|
+
autoStrings: ['a'],
|
|
63
|
+
addMarks: [{
|
|
64
|
+
start: 2,
|
|
65
|
+
end: 2
|
|
66
|
+
}]
|
|
67
|
+
})).toEqual([true, false, true, true, false, false]);
|
|
68
|
+
});
|
|
69
|
+
it('clamps ranges to the content and ignores negative indices', () => {
|
|
70
|
+
expect(computeSensitivityFlags('abc', {
|
|
71
|
+
addMarks: [{
|
|
72
|
+
start: -2,
|
|
73
|
+
end: 100
|
|
74
|
+
}]
|
|
75
|
+
})).toEqual([true, true, true]);
|
|
76
|
+
});
|
|
77
|
+
it('is code-point aware (astral characters count as one)', () => {
|
|
78
|
+
// '🎉' is one code point; 'a' should be flagged at index 1, not 2
|
|
79
|
+
expect(computeSensitivityFlags('🎉a', {
|
|
80
|
+
autoStrings: ['a']
|
|
81
|
+
})).toEqual([false, true]);
|
|
82
|
+
});
|
|
83
|
+
it('is all-false with no options', () => {
|
|
84
|
+
expect(computeSensitivityFlags('abc', {})).toEqual([false, false, false]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('segmentByFlags', () => {
|
|
88
|
+
it('collapses consecutive runs into segments', () => {
|
|
89
|
+
const flags = [false, false, true, true, false, false];
|
|
90
|
+
expect(segmentByFlags('abcabc', flags)).toEqual([{
|
|
91
|
+
text: 'ab',
|
|
92
|
+
sensitive: false
|
|
93
|
+
}, {
|
|
94
|
+
text: 'ca',
|
|
95
|
+
sensitive: true
|
|
96
|
+
}, {
|
|
97
|
+
text: 'bc',
|
|
98
|
+
sensitive: false
|
|
99
|
+
}]);
|
|
100
|
+
});
|
|
101
|
+
it('keeps a contiguous sensitive run (e.g. a ligature cluster) whole', () => {
|
|
102
|
+
const flags = [false, true, true, true, false, false];
|
|
103
|
+
expect(segmentByFlags('office', flags)).toEqual([{
|
|
104
|
+
text: 'o',
|
|
105
|
+
sensitive: false
|
|
106
|
+
}, {
|
|
107
|
+
text: 'ffi',
|
|
108
|
+
sensitive: true
|
|
109
|
+
}, {
|
|
110
|
+
text: 'ce',
|
|
111
|
+
sensitive: false
|
|
112
|
+
}]);
|
|
113
|
+
});
|
|
114
|
+
it('returns a single segment when nothing is sensitive', () => {
|
|
115
|
+
expect(segmentByFlags('abc', [false, false, false])).toEqual([{
|
|
116
|
+
text: 'abc',
|
|
117
|
+
sensitive: false
|
|
118
|
+
}]);
|
|
119
|
+
});
|
|
120
|
+
it('treats missing flags as not sensitive', () => {
|
|
121
|
+
expect(segmentByFlags('abc', [true])).toEqual([{
|
|
122
|
+
text: 'a',
|
|
123
|
+
sensitive: true
|
|
124
|
+
}, {
|
|
125
|
+
text: 'bc',
|
|
126
|
+
sensitive: false
|
|
127
|
+
}]);
|
|
128
|
+
});
|
|
129
|
+
it('is empty for empty content', () => {
|
|
130
|
+
expect(segmentByFlags('', [])).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
it('is code-point aware', () => {
|
|
133
|
+
expect(segmentByFlags('🎉a', [false, true])).toEqual([{
|
|
134
|
+
text: '🎉',
|
|
135
|
+
sensitive: false
|
|
136
|
+
}, {
|
|
137
|
+
text: 'a',
|
|
138
|
+
sensitive: true
|
|
139
|
+
}]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { runWithPreview, runWithNodeAccess, runWithFontdue, isPreviewing, ambientHeaders } from '../server/middleware.js';
|
|
3
|
+
import { createFontdueFetch } from '../server/index.js';
|
|
4
|
+
import { PREVIEW_TOKEN_COOKIE } from '../preview/constants.js';
|
|
5
|
+
import { NODE_ACCESS_COOKIE, NODE_ACCESS_HEADER } from '../nodeAccess.js';
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
function req() {
|
|
10
|
+
let cookies = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
11
|
+
const parts = [];
|
|
12
|
+
if (cookies.preview) parts.push(`${PREVIEW_TOKEN_COOKIE}=${cookies.preview}`);
|
|
13
|
+
if (cookies.nodeAccess) parts.push(`${NODE_ACCESS_COOKIE}=${cookies.nodeAccess}`);
|
|
14
|
+
return new Request('https://store.test/', {
|
|
15
|
+
headers: parts.length ? {
|
|
16
|
+
cookie: parts.join('; ')
|
|
17
|
+
} : {}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const cdnHeaders = {
|
|
21
|
+
'Netlify-CDN-Cache-Control': 'public, durable, s-maxage=31536000',
|
|
22
|
+
'CDN-Cache-Control': 'public, s-maxage=300'
|
|
23
|
+
};
|
|
24
|
+
describe('runWithNodeAccess', () => {
|
|
25
|
+
it('passes public (no cookie) requests through untouched and cacheable', async () => {
|
|
26
|
+
const response = await runWithNodeAccess(req(), async () => {
|
|
27
|
+
expect(ambientHeaders()).toEqual({});
|
|
28
|
+
return new Response('ok', {
|
|
29
|
+
headers: cdnHeaders
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
expect(response.headers.get('Cache-Control')).toBeNull();
|
|
33
|
+
expect(response.headers.get('Netlify-CDN-Cache-Control')).toBe('public, durable, s-maxage=31536000');
|
|
34
|
+
});
|
|
35
|
+
it('forwards the unlock token ambiently and forces the response out of shared caches', async () => {
|
|
36
|
+
const response = await runWithNodeAccess(req({
|
|
37
|
+
nodeAccess: 'na1'
|
|
38
|
+
}), async () => {
|
|
39
|
+
expect(ambientHeaders()).toEqual({
|
|
40
|
+
[NODE_ACCESS_HEADER]: 'na1'
|
|
41
|
+
});
|
|
42
|
+
// Carrying only a node-access token is not an admin preview.
|
|
43
|
+
expect(isPreviewing()).toBe(false);
|
|
44
|
+
return new Response('secret', {
|
|
45
|
+
headers: cdnHeaders
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
|
49
|
+
expect(response.headers.get('Netlify-CDN-Cache-Control')).toBeNull();
|
|
50
|
+
expect(response.headers.get('CDN-Cache-Control')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('runWithPreview is single-responsibility', () => {
|
|
54
|
+
it('does NOT forward the node-access token (only preview)', async () => {
|
|
55
|
+
const response = await runWithPreview(req({
|
|
56
|
+
nodeAccess: 'na1'
|
|
57
|
+
}), async () => {
|
|
58
|
+
// A node-access cookie alone is invisible to runWithPreview.
|
|
59
|
+
expect(ambientHeaders()).toEqual({});
|
|
60
|
+
expect(isPreviewing()).toBe(false);
|
|
61
|
+
return new Response('ok', {
|
|
62
|
+
headers: cdnHeaders
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
// No preview → public → left cacheable.
|
|
66
|
+
expect(response.headers.get('Cache-Control')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('runWithFontdue (composed)', () => {
|
|
70
|
+
it('forwards both the preview and node-access tokens together', async () => {
|
|
71
|
+
await runWithFontdue(req({
|
|
72
|
+
preview: 'tok',
|
|
73
|
+
nodeAccess: 'na1'
|
|
74
|
+
}), async () => {
|
|
75
|
+
expect(ambientHeaders()).toEqual({
|
|
76
|
+
authorization: 'Bearer tok',
|
|
77
|
+
[NODE_ACCESS_HEADER]: 'na1'
|
|
78
|
+
});
|
|
79
|
+
expect(isPreviewing()).toBe(true);
|
|
80
|
+
return new Response('ok');
|
|
81
|
+
});
|
|
82
|
+
// Context unwinds after the request.
|
|
83
|
+
expect(ambientHeaders()).toEqual({});
|
|
84
|
+
expect(isPreviewing()).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it('forwards node-access alone (no preview) and marks the response no-store', async () => {
|
|
87
|
+
const response = await runWithFontdue(req({
|
|
88
|
+
nodeAccess: 'na1'
|
|
89
|
+
}), async () => {
|
|
90
|
+
expect(ambientHeaders()).toEqual({
|
|
91
|
+
[NODE_ACCESS_HEADER]: 'na1'
|
|
92
|
+
});
|
|
93
|
+
expect(isPreviewing()).toBe(false);
|
|
94
|
+
return new Response('ok', {
|
|
95
|
+
headers: cdnHeaders
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
|
99
|
+
});
|
|
100
|
+
it('passes fully public requests through untouched and cacheable', async () => {
|
|
101
|
+
const response = await runWithFontdue(req(), async () => {
|
|
102
|
+
expect(ambientHeaders()).toEqual({});
|
|
103
|
+
return new Response('ok', {
|
|
104
|
+
headers: cdnHeaders
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
expect(response.headers.get('Cache-Control')).toBeNull();
|
|
108
|
+
expect(response.headers.get('Netlify-CDN-Cache-Control')).toBe('public, durable, s-maxage=31536000');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('createFontdueFetch + ambient node-access', () => {
|
|
112
|
+
it('a module-level fetcher forwards the ambient unlock token, no per-call plumbing', async () => {
|
|
113
|
+
const fetchMock = vi.fn(async () => ({
|
|
114
|
+
status: 200,
|
|
115
|
+
json: async () => ({
|
|
116
|
+
data: {}
|
|
117
|
+
})
|
|
118
|
+
}));
|
|
119
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
120
|
+
const fetchGraphql = createFontdueFetch({
|
|
121
|
+
url: 'https://acme.fontdue.com'
|
|
122
|
+
});
|
|
123
|
+
await runWithNodeAccess(req({
|
|
124
|
+
nodeAccess: 'na1'
|
|
125
|
+
}), async () => {
|
|
126
|
+
await fetchGraphql('Q', 'query Q { __typename }');
|
|
127
|
+
return new Response('ok');
|
|
128
|
+
});
|
|
129
|
+
const init = fetchMock.mock.calls[0][1];
|
|
130
|
+
expect(init.headers[NODE_ACCESS_HEADER]).toBe('na1');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FeatureTesterCard_fontStyle$key } from '../../__generated__/FeatureTesterCard_fontStyle.graphql.js';
|
|
3
|
+
export type ToggleMode = 'button' | 'hover';
|
|
4
|
+
export interface FeatureTesterCardOptions {
|
|
5
|
+
feature: string;
|
|
6
|
+
content: string;
|
|
7
|
+
label?: string | null;
|
|
8
|
+
fontSize?: number | null;
|
|
9
|
+
lineHeight?: number | null;
|
|
10
|
+
letterSpacing?: number | null;
|
|
11
|
+
alignment?: 'left' | 'center' | 'right' | null;
|
|
12
|
+
defaultOn?: boolean;
|
|
13
|
+
highlightColor?: string | null;
|
|
14
|
+
showLabel?: boolean;
|
|
15
|
+
showTag?: boolean;
|
|
16
|
+
toggle?: ToggleMode;
|
|
17
|
+
autofit?: boolean;
|
|
18
|
+
changedRanges?: ReadonlyArray<{
|
|
19
|
+
readonly start: number;
|
|
20
|
+
readonly length: number;
|
|
21
|
+
}> | null;
|
|
22
|
+
variableSettings?: ReadonlyArray<{
|
|
23
|
+
readonly axis: string;
|
|
24
|
+
readonly value: number;
|
|
25
|
+
}> | null;
|
|
26
|
+
}
|
|
27
|
+
export type FeatureTesterCardProps = FeatureTesterCardOptions & {
|
|
28
|
+
fontStyle: FeatureTesterCard_fontStyle$key;
|
|
29
|
+
};
|
|
30
|
+
export default function FeatureTesterCard({ fontStyle: fontStyleKey, feature, content, label, fontSize, lineHeight, letterSpacing, alignment, defaultOn, highlightColor, showLabel, showTag, toggle, autofit, changedRanges, variableSettings, }: FeatureTesterCardProps): React.JSX.Element;
|