@workos/oagen-emitters 0.2.0 → 0.2.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.
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +633 -85
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +21 -4
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/node/client.ts +94 -12
- package/src/node/common.ts +1 -1
- package/src/node/enums.ts +4 -4
- package/src/node/errors.ts +5 -1
- package/src/node/fixtures.ts +6 -4
- package/src/node/index.ts +65 -9
- package/src/node/models.ts +86 -75
- package/src/node/naming.ts +91 -2
- package/src/node/resources.ts +462 -23
- package/src/node/serializers.ts +3 -1
- package/src/node/tests.ts +39 -15
- package/src/node/utils.ts +52 -2
- package/test/node/client.test.ts +181 -82
- package/test/node/enums.test.ts +73 -3
- package/test/node/models.test.ts +107 -20
- package/test/node/naming.test.ts +14 -4
- package/test/node/resources.test.ts +627 -25
- package/test/node/serializers.test.ts +33 -6
package/src/node/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
2
13
|
|
|
3
14
|
import { generateModels } from './models.js';
|
|
4
15
|
import { generateEnums } from './enums.js';
|
|
@@ -11,31 +22,41 @@ import { generateCommon } from './common.js';
|
|
|
11
22
|
import { generateTests } from './tests.js';
|
|
12
23
|
import { generateManifest } from './manifest.js';
|
|
13
24
|
|
|
25
|
+
/** Ensure every generated file's content ends with a trailing newline. */
|
|
26
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
29
|
+
f.content += '\n';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
export const nodeEmitter: Emitter = {
|
|
15
36
|
language: 'node',
|
|
16
37
|
|
|
17
38
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
18
|
-
return [...generateModels(models, ctx), ...generateSerializers(models, ctx)];
|
|
39
|
+
return ensureTrailingNewlines([...generateModels(models, ctx), ...generateSerializers(models, ctx)]);
|
|
19
40
|
},
|
|
20
41
|
|
|
21
42
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
22
|
-
return generateEnums(enums, ctx);
|
|
43
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
23
44
|
},
|
|
24
45
|
|
|
25
46
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
26
|
-
return generateResources(services, ctx);
|
|
47
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
27
48
|
},
|
|
28
49
|
|
|
29
50
|
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
30
|
-
return generateClient(spec, ctx);
|
|
51
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
31
52
|
},
|
|
32
53
|
|
|
33
54
|
generateErrors(ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
-
return generateErrors(ctx);
|
|
55
|
+
return ensureTrailingNewlines(generateErrors(ctx));
|
|
35
56
|
},
|
|
36
57
|
|
|
37
58
|
generateConfig(_ctx: EmitterContext): GeneratedFile[] {
|
|
38
|
-
return [...generateConfig(), ...generateCommon()];
|
|
59
|
+
return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
|
|
39
60
|
},
|
|
40
61
|
|
|
41
62
|
generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -44,14 +65,49 @@ export const nodeEmitter: Emitter = {
|
|
|
44
65
|
},
|
|
45
66
|
|
|
46
67
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
47
|
-
return generateTests(spec, ctx);
|
|
68
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
48
69
|
},
|
|
49
70
|
|
|
50
71
|
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
51
|
-
return generateManifest(spec, ctx);
|
|
72
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
52
73
|
},
|
|
53
74
|
|
|
54
75
|
fileHeader(): string {
|
|
55
76
|
return '// This file is auto-generated by oagen. Do not edit.';
|
|
56
77
|
},
|
|
78
|
+
|
|
79
|
+
formatCommand(targetDir: string): FormatCommand | null {
|
|
80
|
+
const hasPrettier = fs.existsSync(path.join(targetDir, '.prettierrc'));
|
|
81
|
+
const hasEslint =
|
|
82
|
+
fs.existsSync(path.join(targetDir, 'eslint.config.mjs')) ||
|
|
83
|
+
fs.existsSync(path.join(targetDir, 'eslint.config.js')) ||
|
|
84
|
+
fs.existsSync(path.join(targetDir, '.eslintrc.json')) ||
|
|
85
|
+
fs.existsSync(path.join(targetDir, '.eslintrc.js'));
|
|
86
|
+
|
|
87
|
+
if (hasPrettier && hasEslint) {
|
|
88
|
+
// Chain ESLint autofix (e.g. unused-import removal) then prettier.
|
|
89
|
+
// ESLint errors are suppressed so formatting still runs on lint failure.
|
|
90
|
+
return {
|
|
91
|
+
cmd: 'bash',
|
|
92
|
+
args: [
|
|
93
|
+
'-c',
|
|
94
|
+
'npx eslint --fix --no-error-on-unmatched-pattern "$@" 2>/dev/null; npx prettier --write --log-level silent "$@"',
|
|
95
|
+
'--',
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (hasPrettier) {
|
|
100
|
+
return {
|
|
101
|
+
cmd: 'npx',
|
|
102
|
+
args: ['prettier', '--write', '--log-level', 'silent'],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (hasEslint) {
|
|
106
|
+
return {
|
|
107
|
+
cmd: 'npx',
|
|
108
|
+
args: ['eslint', '--fix', '--no-error-on-unmatched-pattern'],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
},
|
|
57
113
|
};
|
package/src/node/models.ts
CHANGED
|
@@ -252,87 +252,98 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
252
252
|
if (model.description) {
|
|
253
253
|
lines.push(...docComment(model.description));
|
|
254
254
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (field.readOnly
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
255
|
+
if (model.fields.length === 0) {
|
|
256
|
+
lines.push(`export type ${domainName}${typeParams} = object;`);
|
|
257
|
+
} else {
|
|
258
|
+
lines.push(`export interface ${domainName}${typeParams} {`);
|
|
259
|
+
for (const field of model.fields) {
|
|
260
|
+
const domainFieldName = fieldName(field.name);
|
|
261
|
+
if (seenDomainFields.has(domainFieldName)) continue;
|
|
262
|
+
seenDomainFields.add(domainFieldName);
|
|
263
|
+
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
|
|
264
|
+
const parts: string[] = [];
|
|
265
|
+
if (field.description) parts.push(field.description);
|
|
266
|
+
if (field.readOnly) parts.push('@readonly');
|
|
267
|
+
if (field.writeOnly) parts.push('@writeonly');
|
|
268
|
+
if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
|
|
269
|
+
if (field.deprecated) parts.push('@deprecated');
|
|
270
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
271
|
+
}
|
|
272
|
+
const baselineField = baselineDomain?.fields?.[domainFieldName];
|
|
273
|
+
// For the domain interface, also check that the response baseline's optionality
|
|
274
|
+
// is compatible — the serializer reads from the response type and assigns to the domain type.
|
|
275
|
+
// If the domain baseline says required but the response baseline says optional,
|
|
276
|
+
// the serializer would produce T | undefined for a field expecting T.
|
|
277
|
+
const domainWireField = wireFieldName(field.name);
|
|
278
|
+
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
279
|
+
const domainResponseOptionalMismatch =
|
|
280
|
+
baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
281
|
+
const readonlyPrefix = field.readOnly ? 'readonly ' : '';
|
|
282
|
+
if (
|
|
283
|
+
baselineField &&
|
|
284
|
+
!domainResponseOptionalMismatch &&
|
|
285
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
286
|
+
baselineFieldCompatible(baselineField, field)
|
|
287
|
+
) {
|
|
288
|
+
const opt = baselineField.optional ? '?' : '';
|
|
289
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
290
|
+
} else {
|
|
291
|
+
// When a baseline exists for this model, new fields (not present in the
|
|
292
|
+
// baseline) are generated as optional. The merger can deep-merge new
|
|
293
|
+
// fields into existing interfaces, but it cannot update existing
|
|
294
|
+
// deserializer function bodies. Making the field optional prevents a
|
|
295
|
+
// type error where the interface requires a field that the preserved
|
|
296
|
+
// deserializer never populates.
|
|
297
|
+
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
298
|
+
// Also make the field optional when the response baseline has it as optional
|
|
299
|
+
// but the domain baseline has it as required — the deserializer reads from
|
|
300
|
+
// the response type, so if the response field is optional, the domain value
|
|
301
|
+
// may be undefined.
|
|
302
|
+
// Additionally, when a baseline exists for the RESPONSE interface but NOT the
|
|
303
|
+
// domain interface, fields that are new on the response baseline become optional
|
|
304
|
+
// in the wire type. The domain type must also be optional to match, otherwise
|
|
305
|
+
// the deserializer produces T | undefined for a field typed as T.
|
|
306
|
+
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
307
|
+
const opt =
|
|
308
|
+
!field.required ||
|
|
309
|
+
isNewFieldOnExistingModel ||
|
|
310
|
+
domainResponseOptionalMismatch ||
|
|
311
|
+
isNewFieldOnExistingResponse
|
|
312
|
+
? '?'
|
|
313
|
+
: '';
|
|
314
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
|
|
315
|
+
}
|
|
309
316
|
}
|
|
310
|
-
|
|
311
|
-
|
|
317
|
+
lines.push('}');
|
|
318
|
+
} // close else for non-empty domain interface
|
|
312
319
|
lines.push('');
|
|
313
320
|
|
|
314
321
|
// Wire/response interface (snake_case fields) — deduplicate by snake_case name
|
|
315
322
|
const seenWireFields = new Set<string>();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
323
|
+
if (model.fields.length === 0) {
|
|
324
|
+
lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
325
|
+
} else {
|
|
326
|
+
lines.push(`export interface ${responseName}${typeParams} {`);
|
|
327
|
+
for (const field of model.fields) {
|
|
328
|
+
const wireField = wireFieldName(field.name);
|
|
329
|
+
if (seenWireFields.has(wireField)) continue;
|
|
330
|
+
seenWireFields.add(wireField);
|
|
331
|
+
const baselineField = baselineResponse?.fields?.[wireField];
|
|
332
|
+
if (
|
|
333
|
+
baselineField &&
|
|
334
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
335
|
+
baselineFieldCompatible(baselineField, field)
|
|
336
|
+
) {
|
|
337
|
+
const opt = baselineField.optional ? '?' : '';
|
|
338
|
+
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
339
|
+
} else {
|
|
340
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
341
|
+
const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
342
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
343
|
+
}
|
|
333
344
|
}
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
lines.push('}');
|
|
346
|
+
} // close else for non-empty wire interface
|
|
336
347
|
|
|
337
348
|
files.push({
|
|
338
349
|
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
package/src/node/naming.ts
CHANGED
|
@@ -66,9 +66,93 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
|
|
|
66
66
|
return map;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Explicit method name overrides for operations where the spec's operationId
|
|
71
|
+
* does not match the desired SDK method name and the spec cannot be changed.
|
|
72
|
+
* Key: "HTTP_METHOD /path", Value: camelCase method name.
|
|
73
|
+
*/
|
|
74
|
+
const METHOD_NAME_OVERRIDES: Record<string, string> = {
|
|
75
|
+
'POST /portal/generate_link': 'generatePortalLink',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Explicit service directory overrides. Maps a resolved PascalCase service name
|
|
80
|
+
* to a target directory (kebab-case). Use this when the spec's tag grouping
|
|
81
|
+
* does not match the desired SDK directory layout and the spec cannot be changed.
|
|
82
|
+
*/
|
|
83
|
+
const SERVICE_DIR_OVERRIDES: Record<string, string> = {
|
|
84
|
+
ApplicationClientSecrets: 'workos-connect',
|
|
85
|
+
Applications: 'workos-connect',
|
|
86
|
+
Connections: 'sso',
|
|
87
|
+
Directories: 'directory-sync',
|
|
88
|
+
DirectoryGroups: 'directory-sync',
|
|
89
|
+
DirectoryUsers: 'directory-sync',
|
|
90
|
+
FeatureFlagsTargets: 'feature-flags',
|
|
91
|
+
MultiFactorAuth: 'mfa',
|
|
92
|
+
MultiFactorAuthChallenges: 'mfa',
|
|
93
|
+
OrganizationsApiKeys: 'organizations',
|
|
94
|
+
WebhooksEndpoints: 'webhooks',
|
|
95
|
+
UserManagementAuthentication: 'user-management',
|
|
96
|
+
UserManagementCorsOrigins: 'user-management',
|
|
97
|
+
UserManagementDataProviders: 'user-management',
|
|
98
|
+
UserManagementInvitations: 'user-management',
|
|
99
|
+
UserManagementJWTTemplate: 'user-management',
|
|
100
|
+
UserManagementMagicAuth: 'user-management',
|
|
101
|
+
UserManagementMultiFactorAuthentication: 'user-management',
|
|
102
|
+
UserManagementOrganizationMembership: 'user-management',
|
|
103
|
+
UserManagementRedirectUris: 'user-management',
|
|
104
|
+
UserManagementSessionTokens: 'user-management',
|
|
105
|
+
UserManagementUsers: 'user-management',
|
|
106
|
+
UserManagementUsersAuthorizedApplications: 'user-management',
|
|
107
|
+
WorkOSConnect: 'workos-connect',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Maps a service (by PascalCase name) to the existing hand-written class that
|
|
112
|
+
* already covers its endpoints. When a service appears here:
|
|
113
|
+
* - `resolveClassName` returns the target class (so generated code merges in)
|
|
114
|
+
* - `isServiceCoveredByExisting` returns true
|
|
115
|
+
* - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
|
|
116
|
+
* so new endpoints are added to the existing class rather than silently dropped
|
|
117
|
+
*/
|
|
118
|
+
export const SERVICE_COVERED_BY: Record<string, string> = {
|
|
119
|
+
Connections: 'SSO',
|
|
120
|
+
Directories: 'DirectorySync',
|
|
121
|
+
DirectoryGroups: 'DirectorySync',
|
|
122
|
+
DirectoryUsers: 'DirectorySync',
|
|
123
|
+
FeatureFlagsTargets: 'FeatureFlags',
|
|
124
|
+
MultiFactorAuth: 'Mfa',
|
|
125
|
+
MultiFactorAuthChallenges: 'Mfa',
|
|
126
|
+
OrganizationsApiKeys: 'Organizations',
|
|
127
|
+
UserManagementAuthentication: 'UserManagement',
|
|
128
|
+
UserManagementInvitations: 'UserManagement',
|
|
129
|
+
UserManagementMagicAuth: 'UserManagement',
|
|
130
|
+
UserManagementMultiFactorAuthentication: 'UserManagement',
|
|
131
|
+
UserManagementOrganizationMembership: 'UserManagement',
|
|
132
|
+
UserManagementUsers: 'UserManagement',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Explicit class name overrides. Maps the default PascalCase service name
|
|
137
|
+
* to the desired SDK class name when toPascalCase produces the wrong casing.
|
|
138
|
+
*/
|
|
139
|
+
const CLASS_NAME_OVERRIDES: Record<string, string> = {
|
|
140
|
+
WorkosConnect: 'WorkOSConnect',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the output directory for a service, checking overrides first.
|
|
145
|
+
* Falls back to the standard kebab-case conversion.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveServiceDir(resolvedServiceName: string): string {
|
|
148
|
+
return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
|
|
149
|
+
}
|
|
150
|
+
|
|
69
151
|
/** Resolve the SDK method name for an operation, checking overlay first. */
|
|
70
152
|
export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
71
153
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
154
|
+
const override = METHOD_NAME_OVERRIDES[httpKey];
|
|
155
|
+
if (override) return override;
|
|
72
156
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
73
157
|
if (existing) {
|
|
74
158
|
// Fix: when the path ends with a path parameter (single-resource operation)
|
|
@@ -91,16 +175,21 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
|
|
|
91
175
|
|
|
92
176
|
/** Resolve the SDK class name for a service, checking overlay for existing names. */
|
|
93
177
|
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
178
|
+
// Explicit coverage: this service's endpoints belong to an existing class
|
|
179
|
+
const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
|
|
180
|
+
if (coveredBy) return coveredBy;
|
|
181
|
+
|
|
94
182
|
// Check overlay's methodByOperation for any operation in this service
|
|
95
183
|
// to find the existing class name
|
|
96
184
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
97
185
|
for (const op of service.operations) {
|
|
98
186
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
99
187
|
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
100
|
-
if (existing) return existing.className;
|
|
188
|
+
if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
|
|
101
189
|
}
|
|
102
190
|
}
|
|
103
|
-
|
|
191
|
+
const defaultName = toPascalCase(service.name);
|
|
192
|
+
return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
|
|
104
193
|
}
|
|
105
194
|
|
|
106
195
|
/** Resolve the interface name for a model, checking overlay first. */
|