@c15t/backend 1.2.1 → 1.2.2-canary-20250514183211
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/.turbo/turbo-build.log +54 -53
- package/.turbo/turbo-fmt.log +7 -0
- package/CHANGELOG.md +8 -0
- package/package.json +3 -2
- package/src/contracts/consent/show-banner.contract.test.ts +37 -0
- package/src/contracts/consent/show-banner.contract.ts +27 -1
- package/src/handlers/consent/show-banner.handler.test.ts +46 -0
- package/src/handlers/consent/show-banner.handler.ts +37 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,53 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[2mdist/pkgs/
|
|
17
|
-
[2mdist/pkgs/
|
|
18
|
-
[2mdist/[22m[
|
|
19
|
-
[2mdist
|
|
20
|
-
[2mdist/[22m[
|
|
21
|
-
[2mdist/pkgs/db-adapters/adapters/prisma-adapter/[22m[36mindex.
|
|
22
|
-
[2mdist
|
|
23
|
-
[2mdist/pkgs/
|
|
24
|
-
[2mdist/pkgs/
|
|
25
|
-
[2mdist/pkgs/
|
|
26
|
-
[2mdist/schema/[22m[36mindex.
|
|
27
|
-
[2mdist/
|
|
28
|
-
[2mdist/pkgs/
|
|
29
|
-
[2mdist/[22m[
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
[2mdist/pkgs/
|
|
38
|
-
[2mdist/pkgs/
|
|
39
|
-
[2mdist/[22m[
|
|
40
|
-
[2mdist
|
|
41
|
-
[2mdist/pkgs/db-adapters/adapters/
|
|
42
|
-
[2mdist/[22m[36mrouter.
|
|
43
|
-
[2mdist/pkgs/db-adapters/adapters/
|
|
44
|
-
[2mdist/pkgs/
|
|
45
|
-
[2mdist/pkgs/
|
|
46
|
-
[2mdist/pkgs/
|
|
47
|
-
[2mdist/schema/[22m[36mindex.
|
|
48
|
-
[2mdist/
|
|
49
|
-
[2mdist/pkgs/
|
|
50
|
-
[2mdist/[22m[
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @c15t/backend@1.2.1 build /Users/christopherburns/glados/k/c15t/packages/backend
|
|
4
|
+
> rslib build
|
|
5
|
+
|
|
6
|
+
[1m[38;2;189;255;243m [39m[38;2;189;255;243m [39m[38;2;179;249;235mR[39m[38;2;168;244;227ms[39m[38;2;158;238;219ml[39m[38;2;147;233;211mi[39m[38;2;137;227;203mb[39m[38;2;137;227;203m [39m[38;2;126;222;194mv[39m[38;2;116;216;186m0[39m[38;2;105;211;178m.[39m[38;2;95;205;170m6[39m[38;2;84;200;162m.[39m[38;2;74;194;154m2[39m[38;2;74;194;154m
|
|
7
|
+
[39m[22m
|
|
8
|
+
[1m[32mready [39m[22m built in [1m0.23[22m s[90m (esm)[39m
|
|
9
|
+
[1m[32mready [39m[22m built in [1m0.23[22m s[90m (cjs)[39m
|
|
10
|
+
[1m[36mstart [39m[22m generating declaration files... [90m(esm)[39m
|
|
11
|
+
[1m[36mstart [39m[22m generating declaration files... [90m(cjs)[39m
|
|
12
|
+
[1m[32mready [39m[22m declaration files generated in [1m3.49[22m s [90m(cjs)[39m
|
|
13
|
+
[1m[32mready [39m[22m declaration files generated in [1m3.51[22m s [90m(esm)[39m
|
|
14
|
+
|
|
15
|
+
[34m File (esm) Size [39m
|
|
16
|
+
[2mdist/pkgs/types/[22m[36mindex.js[39m 0.00 kB
|
|
17
|
+
[2mdist/pkgs/data-model/fields/[22m[36mindex.js[39m 5.6 kB
|
|
18
|
+
[2mdist/pkgs/results/[22m[36mindex.js[39m 16.4 kB
|
|
19
|
+
[2mdist/[22m[36mcontracts.js[39m 30.2 kB
|
|
20
|
+
[2mdist/pkgs/db-adapters/adapters/memory-adapter/[22m[36mindex.js[39m 44.3 kB
|
|
21
|
+
[2mdist/pkgs/db-adapters/adapters/prisma-adapter/[22m[36mindex.js[39m 47.5 kB
|
|
22
|
+
[2mdist/[22m[36mrouter.js[39m 48.4 kB
|
|
23
|
+
[2mdist/pkgs/db-adapters/adapters/drizzle-adapter/[22m[36mindex.js[39m 50.0 kB
|
|
24
|
+
[2mdist/pkgs/migrations/[22m[36mindex.js[39m 50.7 kB
|
|
25
|
+
[2mdist/pkgs/db-adapters/adapters/kysely-adapter/[22m[36mindex.js[39m 55.3 kB
|
|
26
|
+
[2mdist/pkgs/data-model/schema/[22m[36mindex.js[39m 60.9 kB
|
|
27
|
+
[2mdist/schema/[22m[36mindex.js[39m 61.2 kB
|
|
28
|
+
[2mdist/pkgs/data-model/[22m[36mindex.js[39m 61.8 kB
|
|
29
|
+
[2mdist/pkgs/db-adapters/[22m[36mindex.js[39m 89.6 kB
|
|
30
|
+
[2mdist/[22m[36mcore.js[39m 153.1 kB
|
|
31
|
+
|
|
32
|
+
[34mTotal:[39m 774.9 kB
|
|
33
|
+
|
|
34
|
+
[2m -----[22m
|
|
35
|
+
|
|
36
|
+
[34m File (cjs) Size [39m
|
|
37
|
+
[2mdist/pkgs/types/[22m[36mindex.cjs[39m 0.60 kB
|
|
38
|
+
[2mdist/pkgs/data-model/fields/[22m[36mindex.cjs[39m 7.3 kB
|
|
39
|
+
[2mdist/pkgs/results/[22m[36mindex.cjs[39m 18.5 kB
|
|
40
|
+
[2mdist/[22m[36mcontracts.cjs[39m 30.6 kB
|
|
41
|
+
[2mdist/pkgs/db-adapters/adapters/memory-adapter/[22m[36mindex.cjs[39m 45.3 kB
|
|
42
|
+
[2mdist/[22m[36mrouter.cjs[39m 48.3 kB
|
|
43
|
+
[2mdist/pkgs/db-adapters/adapters/prisma-adapter/[22m[36mindex.cjs[39m 48.4 kB
|
|
44
|
+
[2mdist/pkgs/db-adapters/adapters/drizzle-adapter/[22m[36mindex.cjs[39m 50.7 kB
|
|
45
|
+
[2mdist/pkgs/migrations/[22m[36mindex.cjs[39m 52.3 kB
|
|
46
|
+
[2mdist/pkgs/db-adapters/adapters/kysely-adapter/[22m[36mindex.cjs[39m 56.3 kB
|
|
47
|
+
[2mdist/pkgs/data-model/schema/[22m[36mindex.cjs[39m 64.0 kB
|
|
48
|
+
[2mdist/schema/[22m[36mindex.cjs[39m 64.5 kB
|
|
49
|
+
[2mdist/pkgs/data-model/[22m[36mindex.cjs[39m 66.0 kB
|
|
50
|
+
[2mdist/pkgs/db-adapters/[22m[36mindex.cjs[39m 90.8 kB
|
|
51
|
+
[2mdist/[22m[36mcore.cjs[39m 152.1 kB
|
|
52
|
+
|
|
53
|
+
[34mTotal:[39m 795.7 kB
|
|
54
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
> @c15t/backend@1.0.5 fmt /Users/christopherburns/glados/k/c15t/packages/backend
|
|
4
|
+
> pnpm biome format --write . && pnpm biome check --formatter-enabled=false --linter-enabled=false --organize-imports-enabled=true --write
|
|
5
|
+
|
|
6
|
+
[0m[34mFormatted [0m[0m[34m163[0m[0m[34m [0m[0m[34mfiles[0m[0m[34m in [0m[0m[34m31[0m[0m[2m[34mms[0m[0m[34m.[0m[0m[34m No fixes applied.[0m
|
|
7
|
+
[0m[34mChecked [0m[0m[34m163[0m[0m[34m [0m[0m[34mfiles[0m[0m[34m in [0m[0m[34m13[0m[0m[2m[34mms[0m[0m[34m.[0m[0m[34m No fixes applied.[0m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @c15t/backend
|
|
2
2
|
|
|
3
|
+
## 1.2.2-canary-20250514183211
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f64f000: feat: added @c15t/nextjs, @c15t/translations for better integration, server-side translations & fetching of consent banner
|
|
8
|
+
- Updated dependencies [f64f000]
|
|
9
|
+
- @c15t/translations@1.2.2-canary-20250514183211
|
|
10
|
+
|
|
3
11
|
## 1.2.1
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c15t/backend",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2-canary-20250514183211",
|
|
4
4
|
"license": "GPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -99,7 +99,8 @@
|
|
|
99
99
|
"kysely": "^0.27.6",
|
|
100
100
|
"neverthrow": "^8.2.0",
|
|
101
101
|
"superjson": "^2.2.2",
|
|
102
|
-
"zod": "^3.24.2"
|
|
102
|
+
"zod": "^3.24.2",
|
|
103
|
+
"@c15t/translations": "1.2.2-canary-20250514183211"
|
|
103
104
|
},
|
|
104
105
|
"devDependencies": {
|
|
105
106
|
"@electric-sql/pglite": "0.2.17",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { baseTranslations } from '@c15t/translations';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
|
|
3
4
|
// import { createContractTests } from '~/testing/contract-testing';
|
|
@@ -34,6 +35,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
34
35
|
countryCode: 'DE',
|
|
35
36
|
regionCode: null,
|
|
36
37
|
},
|
|
38
|
+
translations: {
|
|
39
|
+
translations: baseTranslations.en,
|
|
40
|
+
language: 'en',
|
|
41
|
+
},
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
const result = validateOutput(validOutput);
|
|
@@ -75,6 +80,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
75
80
|
countryCode: 'US',
|
|
76
81
|
regionCode: null,
|
|
77
82
|
},
|
|
83
|
+
translations: {
|
|
84
|
+
translations: baseTranslations.en,
|
|
85
|
+
language: 'en',
|
|
86
|
+
},
|
|
78
87
|
};
|
|
79
88
|
|
|
80
89
|
const result = validateOutput(output);
|
|
@@ -93,6 +102,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
93
102
|
countryCode: 'US',
|
|
94
103
|
regionCode: null,
|
|
95
104
|
},
|
|
105
|
+
translations: {
|
|
106
|
+
translations: baseTranslations.en,
|
|
107
|
+
language: 'en',
|
|
108
|
+
},
|
|
96
109
|
};
|
|
97
110
|
|
|
98
111
|
const result = validateOutput(output);
|
|
@@ -112,6 +125,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
112
125
|
countryCode: null,
|
|
113
126
|
regionCode: null,
|
|
114
127
|
},
|
|
128
|
+
translations: {
|
|
129
|
+
translations: baseTranslations.en,
|
|
130
|
+
language: 'en',
|
|
131
|
+
},
|
|
115
132
|
};
|
|
116
133
|
|
|
117
134
|
const result = validateOutput(output);
|
|
@@ -129,6 +146,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
129
146
|
countryCode: 'DE',
|
|
130
147
|
regionCode: 'BY',
|
|
131
148
|
},
|
|
149
|
+
translations: {
|
|
150
|
+
translations: baseTranslations.en,
|
|
151
|
+
language: 'en',
|
|
152
|
+
},
|
|
132
153
|
};
|
|
133
154
|
|
|
134
155
|
const result = validateOutput(output);
|
|
@@ -146,6 +167,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
146
167
|
countryCode: 123, // Invalid type
|
|
147
168
|
regionCode: null,
|
|
148
169
|
},
|
|
170
|
+
translations: {
|
|
171
|
+
translations: baseTranslations.en,
|
|
172
|
+
language: 'en',
|
|
173
|
+
},
|
|
149
174
|
};
|
|
150
175
|
|
|
151
176
|
// Need to use type assertion to bypass TypeScript
|
|
@@ -166,6 +191,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
166
191
|
countryCode: 'DE',
|
|
167
192
|
regionCode: null,
|
|
168
193
|
},
|
|
194
|
+
translations: {
|
|
195
|
+
translations: baseTranslations.en,
|
|
196
|
+
language: 'en',
|
|
197
|
+
},
|
|
169
198
|
};
|
|
170
199
|
|
|
171
200
|
const falseOutput = {
|
|
@@ -178,6 +207,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
178
207
|
countryCode: 'US',
|
|
179
208
|
regionCode: null,
|
|
180
209
|
},
|
|
210
|
+
translations: {
|
|
211
|
+
translations: baseTranslations.en,
|
|
212
|
+
language: 'en',
|
|
213
|
+
},
|
|
181
214
|
};
|
|
182
215
|
|
|
183
216
|
expect(validateOutput(trueOutput)?.success).toBe(true);
|
|
@@ -195,6 +228,10 @@ describe('Show Consent Banner Contract Custom Tests', () => {
|
|
|
195
228
|
countryCode: 'DE',
|
|
196
229
|
regionCode: null,
|
|
197
230
|
},
|
|
231
|
+
translations: {
|
|
232
|
+
translations: baseTranslations.en,
|
|
233
|
+
language: 'en',
|
|
234
|
+
},
|
|
198
235
|
};
|
|
199
236
|
|
|
200
237
|
// Need to use type assertion to bypass TypeScript
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import { oc } from '@orpc/contract';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
|
|
4
3
|
import { JurisdictionInfoSchema } from '../shared/jurisdiction.schema';
|
|
5
4
|
|
|
5
|
+
const TitleDescriptionSchema = z.object({
|
|
6
|
+
title: z.string(),
|
|
7
|
+
description: z.string(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const TranslationsSchema = z.object({
|
|
11
|
+
common: z.object({
|
|
12
|
+
acceptAll: z.string(),
|
|
13
|
+
rejectAll: z.string(),
|
|
14
|
+
customize: z.string(),
|
|
15
|
+
save: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
cookieBanner: TitleDescriptionSchema,
|
|
18
|
+
consentManagerDialog: TitleDescriptionSchema,
|
|
19
|
+
consentTypes: z.object({
|
|
20
|
+
experience: TitleDescriptionSchema,
|
|
21
|
+
functionality: TitleDescriptionSchema,
|
|
22
|
+
marketing: TitleDescriptionSchema,
|
|
23
|
+
measurement: TitleDescriptionSchema,
|
|
24
|
+
necessary: TitleDescriptionSchema,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
6
28
|
export const showConsentBannerContract = oc
|
|
7
29
|
.route({
|
|
8
30
|
method: 'GET',
|
|
@@ -41,5 +63,9 @@ Use this endpoint to implement geo-targeted consent banners and ensure complianc
|
|
|
41
63
|
countryCode: z.string().nullable(),
|
|
42
64
|
regionCode: z.string().nullable(),
|
|
43
65
|
}),
|
|
66
|
+
translations: z.object({
|
|
67
|
+
language: z.string(),
|
|
68
|
+
translations: TranslationsSchema,
|
|
69
|
+
}),
|
|
44
70
|
})
|
|
45
71
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { baseTranslations } from '@c15t/translations';
|
|
1
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
3
|
import { JurisdictionMessages } from '~/contracts/shared/jurisdiction.schema';
|
|
3
4
|
import { showConsentBanner } from './show-banner.handler';
|
|
@@ -125,6 +126,47 @@ describe('Show Consent Banner Handler', () => {
|
|
|
125
126
|
});
|
|
126
127
|
});
|
|
127
128
|
|
|
129
|
+
describe('Translation handling', () => {
|
|
130
|
+
it('returns default translations when no language is detected', async () => {
|
|
131
|
+
//@ts-expect-error
|
|
132
|
+
const result = await showConsentBanner(
|
|
133
|
+
createMockContext({ 'cf-ipcountry': 'DE' })
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result.translations).toEqual({
|
|
137
|
+
translations: baseTranslations.en,
|
|
138
|
+
language: 'en',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns translations for detected language', async () => {
|
|
143
|
+
//@ts-expect-error
|
|
144
|
+
const result = await showConsentBanner(
|
|
145
|
+
createMockContext({ 'cf-ipcountry': 'DE', 'accept-language': 'de-DE' })
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(result.translations).toEqual({
|
|
149
|
+
translations: baseTranslations.de,
|
|
150
|
+
language: 'de',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns default translations when language is not supported', async () => {
|
|
155
|
+
//@ts-expect-error
|
|
156
|
+
const result = await showConsentBanner(
|
|
157
|
+
createMockContext({
|
|
158
|
+
'cf-ipcountry': 'DE',
|
|
159
|
+
'accept-language': 'foobar',
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(result.translations).toEqual({
|
|
164
|
+
translations: baseTranslations.en,
|
|
165
|
+
language: 'en',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
128
170
|
describe('Response format', () => {
|
|
129
171
|
it('returns properly structured response', async () => {
|
|
130
172
|
//@ts-expect-error
|
|
@@ -142,6 +184,10 @@ describe('Show Consent Banner Handler', () => {
|
|
|
142
184
|
countryCode: 'DE',
|
|
143
185
|
regionCode: null,
|
|
144
186
|
},
|
|
187
|
+
translations: {
|
|
188
|
+
translations: baseTranslations.en,
|
|
189
|
+
language: 'en',
|
|
190
|
+
},
|
|
145
191
|
});
|
|
146
192
|
});
|
|
147
193
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { baseTranslations } from '@c15t/translations';
|
|
1
2
|
import { ORPCError } from '@orpc/server';
|
|
2
3
|
import { os } from '~/contracts';
|
|
3
4
|
import {
|
|
@@ -6,6 +7,34 @@ import {
|
|
|
6
7
|
} from '~/contracts/shared/jurisdiction.schema';
|
|
7
8
|
import type { C15TContext } from '~/types';
|
|
8
9
|
|
|
10
|
+
type SupportedLanguage = keyof typeof baseTranslations;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts the preferred language from Accept-Language header
|
|
14
|
+
* Falls back to 'en' if no supported language is found
|
|
15
|
+
*/
|
|
16
|
+
function getPreferredLanguage(
|
|
17
|
+
acceptLanguage: string | null
|
|
18
|
+
): SupportedLanguage {
|
|
19
|
+
if (!acceptLanguage) {
|
|
20
|
+
return 'en';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Get the primary language code
|
|
24
|
+
const primaryLang = acceptLanguage
|
|
25
|
+
.split(',')[0]
|
|
26
|
+
?.split(';')[0]
|
|
27
|
+
?.split('-')[0]
|
|
28
|
+
?.toLowerCase();
|
|
29
|
+
|
|
30
|
+
// Check if it's a supported language
|
|
31
|
+
if (primaryLang && primaryLang in baseTranslations) {
|
|
32
|
+
return primaryLang as SupportedLanguage;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return 'en';
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
/**
|
|
10
39
|
* Handler for the show consent banner endpoint
|
|
11
40
|
* Determines if a user should see a consent banner based on their location
|
|
@@ -45,13 +74,15 @@ export const showConsentBanner = os.consent.showBanner.handler(
|
|
|
45
74
|
normalizeHeader(headers.get('x-vercel-ip-country-region')) ??
|
|
46
75
|
normalizeHeader(headers.get('x-region-code'));
|
|
47
76
|
|
|
48
|
-
//
|
|
77
|
+
// Get preferred language from Accept-Language header
|
|
78
|
+
const acceptLanguage = normalizeHeader(headers.get('accept-language'));
|
|
79
|
+
const preferredLanguage = getPreferredLanguage(acceptLanguage);
|
|
49
80
|
|
|
50
81
|
// Determine jurisdiction based on country
|
|
51
82
|
const { showConsentBanner, jurisdictionCode, message } =
|
|
52
83
|
checkJurisdiction(countryCode);
|
|
53
84
|
|
|
54
|
-
// Return properly structured response
|
|
85
|
+
// Return properly structured response with translations
|
|
55
86
|
return {
|
|
56
87
|
showConsentBanner,
|
|
57
88
|
jurisdiction: {
|
|
@@ -59,6 +90,10 @@ export const showConsentBanner = os.consent.showBanner.handler(
|
|
|
59
90
|
message,
|
|
60
91
|
},
|
|
61
92
|
location: { countryCode, regionCode },
|
|
93
|
+
translations: {
|
|
94
|
+
translations: baseTranslations[preferredLanguage],
|
|
95
|
+
language: preferredLanguage,
|
|
96
|
+
},
|
|
62
97
|
};
|
|
63
98
|
}
|
|
64
99
|
);
|