@tinvar/renderer 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Asking PHP to sign a fragment token.
|
|
2
|
+
//
|
|
3
|
+
// The renderer holds no signing key -- the brief's security rules say it gets
|
|
4
|
+
// no secrets, and the trust boundary puts it on the "runs third-party npm
|
|
5
|
+
// code" side. But it is the only side that knows which <Deferred> boundaries
|
|
6
|
+
// a tree contains and what props they were given, because those facts exist
|
|
7
|
+
// only while the tree renders.
|
|
8
|
+
//
|
|
9
|
+
// So it asks. It sends the component and the props; PHP chooses the route,
|
|
10
|
+
// params, panel, build, session hash and expiry, and signs. A compromised
|
|
11
|
+
// renderer can therefore ask for a token it should not have, and PHP will
|
|
12
|
+
// sign it -- bounded on purpose: the token is not a capability (the fragment
|
|
13
|
+
// endpoint re-runs the originating route's #[Authorize] chain on use), the
|
|
14
|
+
// binding fields it cannot influence are the ones that matter, and PHP caps
|
|
15
|
+
// the requests one render may make (RenderChannel; Frame::MAX_MINTS_PER_RENDER):
|
|
16
|
+
// nothing here counts them, since nothing here is to be trusted to.
|
|
17
|
+
//
|
|
18
|
+
// Shaped like QueryClient deliberately: same batching, same settle-by-id, so
|
|
19
|
+
// there is one pattern on this socket rather than two.
|
|
20
|
+
|
|
21
|
+
export class MintFailed extends Error {}
|
|
22
|
+
|
|
23
|
+
export class MintClient {
|
|
24
|
+
#send;
|
|
25
|
+
#pending = new Map();
|
|
26
|
+
#queued = [];
|
|
27
|
+
// The ids of each batch sent and not yet answered, oldest first. PHP
|
|
28
|
+
// answers every mint frame with one minted frame, in order, so a reply
|
|
29
|
+
// belongs to the oldest batch here.
|
|
30
|
+
#sent = [];
|
|
31
|
+
#nextId = 0;
|
|
32
|
+
#scheduled = false;
|
|
33
|
+
#closed = null;
|
|
34
|
+
|
|
35
|
+
/** @param {(requests: Array<{id: number, component: string, props: object}>) => void} send */
|
|
36
|
+
constructor(send) {
|
|
37
|
+
this.#send = send;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} component
|
|
42
|
+
* @param {Record<string, unknown>} props
|
|
43
|
+
* @returns {Promise<string>} the encoded token
|
|
44
|
+
*/
|
|
45
|
+
mint(component, props = {}) {
|
|
46
|
+
if (this.#closed) return Promise.reject(new MintFailed(this.#closed));
|
|
47
|
+
|
|
48
|
+
const id = this.#nextId++;
|
|
49
|
+
|
|
50
|
+
const promise = new Promise((resolve, reject) => {
|
|
51
|
+
this.#pending.set(id, { resolve, reject });
|
|
52
|
+
this.#queued.push({ id, component, props });
|
|
53
|
+
this.#schedule();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// A subtree that started a mint and was then abandoned -- its render
|
|
57
|
+
// threw, or the boundary was discarded -- leaves nobody attached to this
|
|
58
|
+
// promise. close() rejects it, and Node treats an unhandled rejection as
|
|
59
|
+
// fatal, so tearing down a failed render killed the process. The caller's
|
|
60
|
+
// own rejection path is unaffected: this handler is attached to an
|
|
61
|
+
// internal reference, not to what is returned.
|
|
62
|
+
promise.catch(() => {});
|
|
63
|
+
|
|
64
|
+
return promise;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One frame per tick, as queries do: a page with twelve <Deferred>
|
|
68
|
+
// boundaries costs one round trip, not twelve.
|
|
69
|
+
#schedule() {
|
|
70
|
+
if (this.#scheduled) return;
|
|
71
|
+
this.#scheduled = true;
|
|
72
|
+
|
|
73
|
+
queueMicrotask(() => {
|
|
74
|
+
this.#scheduled = false;
|
|
75
|
+
if (this.#queued.length === 0) return;
|
|
76
|
+
|
|
77
|
+
const batch = this.#queued;
|
|
78
|
+
this.#queued = [];
|
|
79
|
+
|
|
80
|
+
// Encoded here, in a microtask with nobody above it: props a frame
|
|
81
|
+
// cannot carry threw out of it and took the whole renderer down. They
|
|
82
|
+
// fail this batch's boundaries and nothing else -- and a batch that
|
|
83
|
+
// never left expects no reply.
|
|
84
|
+
try {
|
|
85
|
+
this.#send(batch);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
for (const { id } of batch) {
|
|
88
|
+
this.#pending
|
|
89
|
+
.get(id)
|
|
90
|
+
?.reject(new MintFailed(`The token request could not be sent: ${error?.message ?? error}`));
|
|
91
|
+
this.#pending.delete(id);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.#sent.push(batch.map(({ id }) => id));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The reply to the oldest batch still waiting.
|
|
101
|
+
*
|
|
102
|
+
* A null token is PHP refusing that one boundary. A boundary the reply
|
|
103
|
+
* leaves out is refused too: nothing else will ever answer it, and
|
|
104
|
+
* waiting for an answer held the whole render until PHP's deadline. Both
|
|
105
|
+
* reject only that boundary's promise, so <Deferred> reports it and the
|
|
106
|
+
* page completes.
|
|
107
|
+
*
|
|
108
|
+
* @param {Array<{id: number, token: string | null}>} tokens
|
|
109
|
+
*/
|
|
110
|
+
settle(tokens) {
|
|
111
|
+
const batch = this.#sent.shift() ?? [];
|
|
112
|
+
|
|
113
|
+
for (const { id, token } of tokens) {
|
|
114
|
+
const waiting = this.#pending.get(id);
|
|
115
|
+
if (!waiting) continue;
|
|
116
|
+
this.#pending.delete(id);
|
|
117
|
+
|
|
118
|
+
if (typeof token === 'string' && token !== '') {
|
|
119
|
+
waiting.resolve(token);
|
|
120
|
+
} else {
|
|
121
|
+
waiting.reject(new MintFailed('PHP returned no token for this boundary.'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const id of batch) {
|
|
126
|
+
const waiting = this.#pending.get(id);
|
|
127
|
+
if (!waiting) continue;
|
|
128
|
+
this.#pending.delete(id);
|
|
129
|
+
waiting.reject(new MintFailed('PHP returned no token for this boundary.'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {string} [reason] */
|
|
134
|
+
close(reason = 'render_finished') {
|
|
135
|
+
this.#closed = reason;
|
|
136
|
+
for (const { reject } of this.#pending.values()) {
|
|
137
|
+
reject(new MintFailed(`Fragment token never arrived: ${reason}.`));
|
|
138
|
+
}
|
|
139
|
+
this.#pending.clear();
|
|
140
|
+
this.#queued = [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// A panel's styles, in a stylesheet of its own.
|
|
2
|
+
//
|
|
3
|
+
// panelChunks() keeps a panel's client code out of every other page's
|
|
4
|
+
// downloads; its styles went everywhere still. StyleX compiles every
|
|
5
|
+
// stylex.create() the build reaches -- the admin panel's pages' and their
|
|
6
|
+
// components' as well as the shop's -- into one stylesheet, which the
|
|
7
|
+
// document links on every page. Nothing in it runs, but it maps out what
|
|
8
|
+
// staff see, it grows every customer's download, and a panel is meant to be
|
|
9
|
+
// a boundary (ADR 0109).
|
|
10
|
+
//
|
|
11
|
+
// So, given the StyleX plugin, panelChunks() splits StyleX's rules once the
|
|
12
|
+
// client build has the rest of the graph. The stylesheet the document links
|
|
13
|
+
// on every page keeps the app's own CSS and the shared rules alone, and each
|
|
14
|
+
// panel with rules of its own gets a sheet holding the shared rules and its
|
|
15
|
+
// own, whole, which its pages link after it: the panel's rules in StyleX's
|
|
16
|
+
// own order, where they meet the shared ones repeated after themselves, so
|
|
17
|
+
// two rules never meet out of it. A rule goes to a panel's sheet alone when
|
|
18
|
+
// every view that reaches the module declaring it -- through server imports,
|
|
19
|
+
// then a client component's own -- is in that panel, and nothing reaches it
|
|
20
|
+
// outside the views: the document, a layout kept outside the pages, the
|
|
21
|
+
// client entry. Anything the build cannot trace stays shared.
|
|
22
|
+
//
|
|
23
|
+
// A page outside every panel links one stylesheet, as before: a second, even
|
|
24
|
+
// one of nothing, put a page's queries in two frames instead of one, React
|
|
25
|
+
// scheduling its render around the stylesheet. A panel's page links its
|
|
26
|
+
// sheet through `virtual:tinvar/styles`, read from the RSC plugin's assets
|
|
27
|
+
// manifest, which the client build fills in; in dev StyleX hands the browser
|
|
28
|
+
// its CSS itself, and the component renders nothing.
|
|
29
|
+
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
/** The module the document imports its stylesheet component from. */
|
|
34
|
+
export const STYLES_MODULE = 'virtual:tinvar/styles';
|
|
35
|
+
|
|
36
|
+
/** Where the client build records the sheets, in the RSC plugin's assets manifest. */
|
|
37
|
+
export const MANIFEST_STYLES = 'tinvarStyles';
|
|
38
|
+
|
|
39
|
+
/** A client-reference group's virtual module, as @vitejs/plugin-rsc names it. */
|
|
40
|
+
const GROUP_PREFIX = '\0virtual:vite-rsc/client-references/group/';
|
|
41
|
+
|
|
42
|
+
/** @param {string} id */
|
|
43
|
+
function fileOf(id) {
|
|
44
|
+
return id.replace(/[?#].*$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The client graph's id for a client reference the rsc graph names: its
|
|
49
|
+
* import id, which a package's proxy names by URL.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} importId
|
|
52
|
+
*/
|
|
53
|
+
export function clientIdOf(importId) {
|
|
54
|
+
return importId.startsWith('/@id/__x00__') ? `\0${importId.slice('/@id/__x00__'.length)}` : importId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which panel owns each module the build reached, by file: a module is a
|
|
59
|
+
* panel's when every view reaching it is that panel's and nothing reaches
|
|
60
|
+
* it outside the views.
|
|
61
|
+
*
|
|
62
|
+
* @param {{
|
|
63
|
+
* rsc: Map<string, string[]>,
|
|
64
|
+
* client: Map<string, string[]>,
|
|
65
|
+
* references: Map<string, string>,
|
|
66
|
+
* views: Map<string, string>,
|
|
67
|
+
* rscEntries: string[],
|
|
68
|
+
* clientEntries: string[],
|
|
69
|
+
* panelOf: (view: string) => string | null,
|
|
70
|
+
* }} graphs `references` maps a client reference's rsc id to its import id;
|
|
71
|
+
* `views` each view's name to its rsc id
|
|
72
|
+
* @returns {Map<string, string>} file => the panel that owns it
|
|
73
|
+
*/
|
|
74
|
+
export function moduleOwnership({ rsc, client, references, views, rscEntries, clientEntries, panelOf }) {
|
|
75
|
+
const viewIds = new Set(views.values());
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every file reached from $starts: server imports, and at a client
|
|
79
|
+
* reference, its client module's imports -- never into another view (from
|
|
80
|
+
* an entry), nor into the client-reference groups the client entry loads
|
|
81
|
+
* on demand.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} starts rsc ids
|
|
84
|
+
* @param {string[]} clientStarts client ids
|
|
85
|
+
* @param {boolean} stopAtViews
|
|
86
|
+
*/
|
|
87
|
+
const reach = (starts, clientStarts, stopAtViews) => {
|
|
88
|
+
const files = new Set();
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
/** @type {Array<[string, 'rsc' | 'client']>} */
|
|
91
|
+
const queue = [
|
|
92
|
+
...starts.map((id) => /** @type {[string, 'rsc']} */ ([id, 'rsc'])),
|
|
93
|
+
...clientStarts.map((id) => /** @type {[string, 'client']} */ ([id, 'client'])),
|
|
94
|
+
];
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const [id, side] = /** @type {[string, 'rsc' | 'client']} */ (queue.shift());
|
|
97
|
+
const key = `${side}:${id}`;
|
|
98
|
+
if (seen.has(key)) continue;
|
|
99
|
+
seen.add(key);
|
|
100
|
+
// Another view is that view's own: the entry imports every page, and
|
|
101
|
+
// reaches none of them for that.
|
|
102
|
+
if (side === 'rsc' && stopAtViews && viewIds.has(id) && !starts.includes(id)) continue;
|
|
103
|
+
files.add(fileOf(id));
|
|
104
|
+
|
|
105
|
+
if (side === 'rsc') {
|
|
106
|
+
const reference = references.get(id);
|
|
107
|
+
if (reference !== undefined) {
|
|
108
|
+
const clientId = clientIdOf(reference);
|
|
109
|
+
const found = client.has(clientId) ? clientId : client.has(id) ? id : null;
|
|
110
|
+
// None in the client build: a reference the server bundle shook
|
|
111
|
+
// out, whose code never ships -- nothing past it to follow.
|
|
112
|
+
if (found === null) continue;
|
|
113
|
+
queue.push([found, 'client']);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const next of rsc.get(id) ?? []) queue.push([next, 'rsc']);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const next of client.get(id) ?? []) {
|
|
120
|
+
if (!next.startsWith(GROUP_PREFIX)) queue.push([next, 'client']);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return files;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const shared = reach(rscEntries, clientEntries, true);
|
|
127
|
+
/** @type {Map<string, Set<string>>} panel => files */
|
|
128
|
+
const byPanel = new Map();
|
|
129
|
+
for (const [view, id] of views) {
|
|
130
|
+
const panel = panelOf(view);
|
|
131
|
+
const files = reach([id], [], true);
|
|
132
|
+
if (panel === null) {
|
|
133
|
+
for (const file of files) shared.add(file);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!byPanel.has(panel)) byPanel.set(panel, new Set());
|
|
137
|
+
const own = /** @type {Set<string>} */ (byPanel.get(panel));
|
|
138
|
+
for (const file of files) own.add(file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @type {Map<string, string>} */
|
|
142
|
+
const owned = new Map();
|
|
143
|
+
/** @type {Map<string, number>} */
|
|
144
|
+
const claims = new Map();
|
|
145
|
+
for (const files of byPanel.values()) {
|
|
146
|
+
for (const file of files) claims.set(file, (claims.get(file) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
for (const [panel, files] of byPanel) {
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
if (!shared.has(file) && claims.get(file) === 1) owned.set(file, panel);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return owned;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The StyleX rules each sheet holds: the shared sheet every rule no panel
|
|
159
|
+
* owns, and each panel's sheet the same and its own.
|
|
160
|
+
*
|
|
161
|
+
* @param {Map<string, unknown[]>} rulesById StyleX's rules, by the module that declared them
|
|
162
|
+
* @param {Map<string, string>} owned file => panel, from moduleOwnership()
|
|
163
|
+
* @param {string[]} panels
|
|
164
|
+
* @returns {{ shared: unknown[], panels: Record<string, unknown[]>, owned: Record<string, number> }}
|
|
165
|
+
* the rules, and how many each panel owns
|
|
166
|
+
*/
|
|
167
|
+
export function partitionRules(rulesById, owned, panels) {
|
|
168
|
+
const shared = [];
|
|
169
|
+
/** @type {Record<string, unknown[]>} */
|
|
170
|
+
const own = Object.fromEntries(panels.map((panel) => [panel, []]));
|
|
171
|
+
for (const [id, rules] of [...rulesById].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
172
|
+
const panel = owned.get(fileOf(id));
|
|
173
|
+
if (panel !== undefined && own[panel] !== undefined) own[panel].push(...rules);
|
|
174
|
+
else shared.push(...rules);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
shared,
|
|
179
|
+
panels: Object.fromEntries(panels.map((panel) => [panel, [...shared, ...(own[panel] ?? [])]])),
|
|
180
|
+
owned: Object.fromEntries(panels.map((panel) => [panel, own[panel]?.length ?? 0])),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Renders StyleX rules as the StyleX plugin does -- its babel plugin's
|
|
186
|
+
* ordering, then lightningcss for the browsers the app names -- with the
|
|
187
|
+
* plugin's own copies of both, found beside it.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} root the app's root, which resolves @stylexjs/unplugin
|
|
190
|
+
* @returns {(rules: unknown[]) => string}
|
|
191
|
+
*/
|
|
192
|
+
export function styleXRenderer(root) {
|
|
193
|
+
const fromApp = createRequire(join(root, 'package.json'));
|
|
194
|
+
const fromPlugin = createRequire(fromApp.resolve('@stylexjs/unplugin'));
|
|
195
|
+
const babel = fromPlugin('@stylexjs/babel-plugin');
|
|
196
|
+
const lightningcss = fromPlugin('lightningcss');
|
|
197
|
+
const browserslist = fromPlugin('browserslist');
|
|
198
|
+
const process = babel.processStylexRules ?? babel.default?.processStylexRules;
|
|
199
|
+
|
|
200
|
+
return (rules) => {
|
|
201
|
+
if (rules.length === 0) return '';
|
|
202
|
+
const css = process(rules, { useLayers: false });
|
|
203
|
+
const { code } = lightningcss.transform({
|
|
204
|
+
targets: lightningcss.browserslistToTargets(browserslist()),
|
|
205
|
+
filename: 'stylex.css',
|
|
206
|
+
code: Buffer.from(css),
|
|
207
|
+
});
|
|
208
|
+
return code.toString();
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The module the document imports `Stylesheets` from: a panel's view's
|
|
214
|
+
* sheet, read from the assets manifest, with its digest where the build has
|
|
215
|
+
* them -- and refused without one there. Any other view, and dev, nothing.
|
|
216
|
+
*
|
|
217
|
+
* @param {Record<string, string>} panels panel => directory under the pages
|
|
218
|
+
* @param {boolean} build
|
|
219
|
+
*/
|
|
220
|
+
export function stylesModule(panels, build) {
|
|
221
|
+
if (!build) return 'export function Stylesheets() {\n return null;\n}\n';
|
|
222
|
+
|
|
223
|
+
const directories = Object.entries(panels).map(([panel, directory]) => [
|
|
224
|
+
panel,
|
|
225
|
+
directory.replace(/\/+$/, ''),
|
|
226
|
+
]);
|
|
227
|
+
return `import __tinvar_manifest__ from 'virtual:vite-rsc/assets-manifest';
|
|
228
|
+
import { createElement } from 'react';
|
|
229
|
+
|
|
230
|
+
const sheets = __tinvar_manifest__[${JSON.stringify(MANIFEST_STYLES)}] ?? null;
|
|
231
|
+
const digests = __tinvar_manifest__.tinvarIntegrity ?? null;
|
|
232
|
+
const panels = ${JSON.stringify(directories)};
|
|
233
|
+
|
|
234
|
+
function panelOf(view) {
|
|
235
|
+
for (const [panel, directory] of panels) {
|
|
236
|
+
if (view === directory || view.startsWith(directory + '/')) return panel;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function Stylesheets({ view }) {
|
|
242
|
+
if (sheets === null) return null;
|
|
243
|
+
const panel = typeof view === 'string' ? panelOf(view) : null;
|
|
244
|
+
if (panel === null || !Object.hasOwn(sheets.panels, panel)) return null;
|
|
245
|
+
const href = sheets.panels[panel];
|
|
246
|
+
const props = { rel: 'stylesheet', href, precedence: 'tinvar/styles', crossOrigin: 'anonymous' };
|
|
247
|
+
if (digests !== null) {
|
|
248
|
+
if (!Object.hasOwn(digests, href)) throw new Error('[tinvar] No digest for the stylesheet ' + href + '; build again.');
|
|
249
|
+
props.integrity = digests[href];
|
|
250
|
+
}
|
|
251
|
+
return createElement('link', props);
|
|
252
|
+
}
|
|
253
|
+
`;
|
|
254
|
+
}
|