@ossy/app 1.11.7 → 1.11.9
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/cli/build-terminal.js +16 -117
- package/cli/build.js +155 -67
- package/cli/index.js +1 -6
- package/cli/prerender-react.task.js +20 -164
- package/cli/render-page.task.js +18 -61
- package/cli/server.js +1 -35
- package/package.json +10 -10
- package/cli/dev.js +0 -342
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
|
-
import url from 'url'
|
|
3
2
|
import fs from 'fs'
|
|
4
3
|
import { rollup } from 'rollup'
|
|
5
|
-
import { BuildPage, buildPrerenderAppConfig } from './render-page.task.js'
|
|
6
4
|
import { pageIdFromHydrateEntryName } from './build-terminal.js'
|
|
7
5
|
|
|
8
|
-
/**
|
|
9
|
-
* Maps an app route path to the file path express.static will serve for that URL
|
|
10
|
-
* (`/` → `public/index.html`, `/a/b` → `public/a/b/index.html`).
|
|
11
|
-
*/
|
|
12
|
-
export function staticHtmlPathForRoute (routePath, publicDir) {
|
|
13
|
-
const p = typeof routePath === 'string' ? routePath : '/'
|
|
14
|
-
if (p === '/' || p === '') {
|
|
15
|
-
return path.join(publicDir, 'index.html')
|
|
16
|
-
}
|
|
17
|
-
const segments = p.replace(/^\//, '').split('/').filter(Boolean)
|
|
18
|
-
return path.join(publicDir, ...segments, 'index.html')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function pathIsPrerenderable (routePath) {
|
|
22
|
-
if (typeof routePath !== 'string') return false
|
|
23
|
-
if (!routePath.startsWith('/') || routePath.includes(':')) return false
|
|
24
|
-
return true
|
|
25
|
-
}
|
|
26
|
-
|
|
27
6
|
function copyPublicToBuild ({ copyPublicFrom, buildPath }) {
|
|
28
7
|
if (!copyPublicFrom || !fs.existsSync(copyPublicFrom)) return
|
|
29
8
|
const dest = path.join(buildPath, 'public')
|
|
@@ -31,10 +10,6 @@ function copyPublicToBuild ({ copyPublicFrom, buildPath }) {
|
|
|
31
10
|
fs.cpSync(copyPublicFrom, dest, { recursive: true })
|
|
32
11
|
}
|
|
33
12
|
|
|
34
|
-
function countSettledFailures (results) {
|
|
35
|
-
return results.filter((r) => r.status === 'rejected').length
|
|
36
|
-
}
|
|
37
|
-
|
|
38
13
|
async function bundleOneHydratePage ({
|
|
39
14
|
entryName,
|
|
40
15
|
stubPath,
|
|
@@ -113,156 +88,37 @@ async function bundleHydratePagesParallel ({
|
|
|
113
88
|
})
|
|
114
89
|
)
|
|
115
90
|
|
|
116
|
-
const failures =
|
|
117
|
-
|
|
118
|
-
return { results, failures }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function prerenderOnePage ({
|
|
122
|
-
route,
|
|
123
|
-
buildTimeConfig,
|
|
124
|
-
pageList,
|
|
125
|
-
publicDir,
|
|
126
|
-
}) {
|
|
127
|
-
const appConfig = buildPrerenderAppConfig({
|
|
128
|
-
buildTimeConfig,
|
|
129
|
-
pageList,
|
|
130
|
-
activeRouteId: route.id,
|
|
131
|
-
urlPath: route.path,
|
|
132
|
-
})
|
|
133
|
-
const html = await BuildPage.handle({
|
|
134
|
-
route,
|
|
135
|
-
appConfig,
|
|
136
|
-
isDevReloadEnabled: false,
|
|
137
|
-
})
|
|
138
|
-
const outPath = staticHtmlPathForRoute(route.path, publicDir)
|
|
139
|
-
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
140
|
-
fs.writeFileSync(outPath, html, 'utf8')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function prerenderPagesParallel ({
|
|
144
|
-
pagesEntryPath,
|
|
145
|
-
configSourcePath,
|
|
146
|
-
publicDir,
|
|
147
|
-
reporter,
|
|
148
|
-
}) {
|
|
149
|
-
const cfgHref = url.pathToFileURL(path.resolve(configSourcePath)).href
|
|
150
|
-
const pagesHref = url.pathToFileURL(path.resolve(pagesEntryPath)).href
|
|
151
|
-
|
|
152
|
-
const configModule = await import(cfgHref)
|
|
153
|
-
const pagesModule = await import(pagesHref)
|
|
154
|
-
|
|
155
|
-
const buildTimeConfig = configModule?.default ?? configModule ?? {}
|
|
156
|
-
const pageList = pagesModule?.default ?? []
|
|
157
|
-
|
|
158
|
-
const routesToRender = []
|
|
159
|
-
for (const route of pageList) {
|
|
160
|
-
if (typeof route?.module !== 'string' || !route.module) continue
|
|
161
|
-
if (!pathIsPrerenderable(route.path)) {
|
|
162
|
-
reporter?.skipPrerender?.(
|
|
163
|
-
route.id,
|
|
164
|
-
`not static (${JSON.stringify(route.path)})`
|
|
165
|
-
)
|
|
166
|
-
console.warn(
|
|
167
|
-
`[@ossy/app][prerender] Skipping "${route.id}" (path not prerenderable: ${JSON.stringify(route.path)})`
|
|
168
|
-
)
|
|
169
|
-
continue
|
|
170
|
-
}
|
|
171
|
-
routesToRender.push(route)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const results = await Promise.allSettled(
|
|
175
|
-
routesToRender.map(async (route) => {
|
|
176
|
-
const t0 = Date.now()
|
|
177
|
-
reporter?.startPrerender?.(route.id)
|
|
178
|
-
try {
|
|
179
|
-
await prerenderOnePage({
|
|
180
|
-
route,
|
|
181
|
-
buildTimeConfig,
|
|
182
|
-
pageList,
|
|
183
|
-
publicDir,
|
|
184
|
-
})
|
|
185
|
-
reporter?.completePrerender?.(route.id, { ok: true, ms: Date.now() - t0 })
|
|
186
|
-
} catch (error) {
|
|
187
|
-
reporter?.completePrerender?.(route.id, {
|
|
188
|
-
ok: false,
|
|
189
|
-
ms: Date.now() - t0,
|
|
190
|
-
error,
|
|
191
|
-
})
|
|
192
|
-
console.error(
|
|
193
|
-
`[@ossy/app][prerender] "${route?.id}" (${route?.path}) failed:`,
|
|
194
|
-
error
|
|
195
|
-
)
|
|
196
|
-
throw error
|
|
197
|
-
}
|
|
198
|
-
})
|
|
199
|
-
)
|
|
200
|
-
const failures = countSettledFailures(results)
|
|
201
|
-
|
|
91
|
+
const failures = results.filter((r) => r.status === 'rejected').length
|
|
202
92
|
return { results, failures }
|
|
203
93
|
}
|
|
204
94
|
|
|
205
|
-
async function runProduction ({
|
|
206
|
-
clientHydrateInput,
|
|
207
|
-
pageFilesLength,
|
|
208
|
-
copyPublicFrom,
|
|
209
|
-
buildPath,
|
|
210
|
-
nodeEnv,
|
|
211
|
-
pagesEntryPath,
|
|
212
|
-
configSourcePath,
|
|
213
|
-
createClientRollupPlugins,
|
|
214
|
-
minifyBrowserStaticChunks,
|
|
215
|
-
reporter,
|
|
216
|
-
}) {
|
|
217
|
-
if (pageFilesLength === 0 || Object.keys(clientHydrateInput).length === 0) {
|
|
218
|
-
return { bundleFailures: 0, prerenderFailures: 0 }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const { failures: bundleFailures } = await bundleHydratePagesParallel({
|
|
222
|
-
clientHydrateInput,
|
|
223
|
-
buildPath,
|
|
224
|
-
copyPublicFrom,
|
|
225
|
-
nodeEnv,
|
|
226
|
-
buildPathForPlugins: buildPath,
|
|
227
|
-
createClientRollupPlugins,
|
|
228
|
-
minifyBrowserStaticChunks,
|
|
229
|
-
reporter,
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
const { failures: prerenderFailures } = await prerenderPagesParallel({
|
|
233
|
-
pagesEntryPath,
|
|
234
|
-
configSourcePath,
|
|
235
|
-
publicDir: path.join(buildPath, 'public'),
|
|
236
|
-
reporter,
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
if (bundleFailures + prerenderFailures > 0) {
|
|
240
|
-
console.warn(
|
|
241
|
-
`[@ossy/app][build] Finished with ${bundleFailures} client bundle error(s) and ${prerenderFailures} prerender error(s)`
|
|
242
|
-
)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return { bundleFailures, prerenderFailures }
|
|
246
|
-
}
|
|
247
|
-
|
|
248
95
|
export default {
|
|
249
96
|
type: '@ossy/app/prerender-react',
|
|
250
|
-
/** `input.op`: `runProduction` | `prerenderPagesParallel` (see callers in `build.js` / `dev.js`). */
|
|
251
97
|
async handler (input) {
|
|
252
98
|
const op = input?.op
|
|
253
99
|
if (op === 'runProduction') {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
100
|
+
const { clientHydrateInput, pageFilesLength, copyPublicFrom, buildPath, nodeEnv,
|
|
101
|
+
buildPathForPlugins, createClientRollupPlugins, minifyBrowserStaticChunks, reporter } = input
|
|
102
|
+
if (pageFilesLength === 0 || Object.keys(clientHydrateInput).length === 0) {
|
|
103
|
+
return { bundleFailures: 0 }
|
|
104
|
+
}
|
|
105
|
+
const { failures: bundleFailures } = await bundleHydratePagesParallel({
|
|
106
|
+
clientHydrateInput,
|
|
107
|
+
buildPath,
|
|
108
|
+
copyPublicFrom,
|
|
109
|
+
nodeEnv,
|
|
110
|
+
buildPathForPlugins: buildPathForPlugins ?? buildPath,
|
|
111
|
+
createClientRollupPlugins,
|
|
112
|
+
minifyBrowserStaticChunks,
|
|
113
|
+
reporter,
|
|
262
114
|
})
|
|
115
|
+
if (bundleFailures > 0) {
|
|
116
|
+
console.warn(`[@ossy/app][build] Finished with ${bundleFailures} client bundle error(s)`)
|
|
117
|
+
}
|
|
118
|
+
return { bundleFailures }
|
|
263
119
|
}
|
|
264
120
|
throw new Error(
|
|
265
|
-
`[@ossy/app][prerender-react] Unknown op: ${String(op)} (expected runProduction
|
|
121
|
+
`[@ossy/app][prerender-react] Unknown op: ${String(op)} (expected runProduction)`
|
|
266
122
|
)
|
|
267
123
|
},
|
|
268
124
|
}
|
package/cli/render-page.task.js
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
3
|
-
import React, { createElement } from 'react'
|
|
4
|
-
import { prerenderToNodeStream } from 'react-dom/static'
|
|
5
3
|
|
|
6
4
|
const __ossyDir = path.dirname(fileURLToPath(import.meta.url))
|
|
7
5
|
|
|
8
|
-
async function
|
|
9
|
-
if (typeof route?.
|
|
10
|
-
throw new Error(
|
|
11
|
-
`[@ossy/app][BuildPage] Route "${route?.id ?? '?'}" has no compiled module path (re-run build so pages.generated.json includes "module").`
|
|
12
|
-
)
|
|
6
|
+
async function loadSsrBundle (route) {
|
|
7
|
+
if (typeof route?.id !== 'string' || !route.id) {
|
|
8
|
+
throw new Error(`[@ossy/app][BuildPage] Route has no id`)
|
|
13
9
|
}
|
|
14
|
-
const
|
|
15
|
-
const mod = await import(pathToFileURL(
|
|
16
|
-
|
|
17
|
-
if (typeof def !== 'function') {
|
|
10
|
+
const bundlePath = path.join(__ossyDir, '..', 'ssr', `${route.id}.mjs`)
|
|
11
|
+
const mod = await import(pathToFileURL(bundlePath).href)
|
|
12
|
+
if (typeof mod?.renderPage !== 'function') {
|
|
18
13
|
throw new Error(
|
|
19
|
-
`[@ossy/app][BuildPage]
|
|
14
|
+
`[@ossy/app][BuildPage] SSR bundle for "${route.id}" must export renderPage (got ${typeof mod?.renderPage}).`
|
|
20
15
|
)
|
|
21
16
|
}
|
|
22
|
-
return
|
|
17
|
+
return mod
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
/**
|
|
26
|
-
* App shell config for SSR / prerender (mirrors client: theme, pages metadata, props for the active page).
|
|
27
|
-
*/
|
|
28
20
|
export function buildPrerenderAppConfig ({
|
|
29
21
|
buildTimeConfig,
|
|
30
22
|
pageList,
|
|
@@ -32,11 +24,9 @@ export function buildPrerenderAppConfig ({
|
|
|
32
24
|
urlPath,
|
|
33
25
|
isAuthenticated = false,
|
|
34
26
|
}) {
|
|
35
|
-
/** `module` is the compiled page path under `.ossy/` (Node `import()` only; not loaded as raw ESM in the browser). */
|
|
36
27
|
const pages = pageList.map((page) => ({
|
|
37
28
|
id: page?.id,
|
|
38
29
|
path: page?.path,
|
|
39
|
-
...(typeof page?.module === 'string' ? { module: page.module } : {}),
|
|
40
30
|
}))
|
|
41
31
|
return {
|
|
42
32
|
...buildTimeConfig,
|
|
@@ -50,23 +40,8 @@ export function buildPrerenderAppConfig ({
|
|
|
50
40
|
}
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
/** Strips non-JSON content for the bootstrap script; keeps serializable route fields including `module`. */
|
|
54
|
-
export function appConfigForBootstrap (appConfig) {
|
|
55
|
-
if (!appConfig || typeof appConfig !== 'object') return appConfig
|
|
56
|
-
const pages = Array.isArray(appConfig.pages)
|
|
57
|
-
? appConfig.pages.map(({ id, path, module }) => ({
|
|
58
|
-
id,
|
|
59
|
-
path,
|
|
60
|
-
...(typeof module === 'string' ? { module } : {}),
|
|
61
|
-
}))
|
|
62
|
-
: appConfig.pages
|
|
63
|
-
return { ...appConfig, pages }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Plain data clone so server render props match `JSON.parse(JSON.stringify(...))` on the client. */
|
|
67
43
|
function jsonSafeClone (value) {
|
|
68
44
|
if (value == null || typeof value !== 'object') return value
|
|
69
|
-
if (typeof value === 'function' || React.isValidElement(value)) return value
|
|
70
45
|
try {
|
|
71
46
|
return JSON.parse(JSON.stringify(value))
|
|
72
47
|
} catch {
|
|
@@ -74,42 +49,24 @@ function jsonSafeClone (value) {
|
|
|
74
49
|
}
|
|
75
50
|
}
|
|
76
51
|
|
|
77
|
-
/**
|
|
78
|
-
* Props passed to `<App>` for SSR and the exact object embedded in `window.__INITIAL_APP_CONFIG__`.
|
|
79
|
-
* Keeps server and hydrate trees aligned (fixes React #418 hydration mismatches).
|
|
80
|
-
*/
|
|
81
52
|
export function buildHydrationAppConfig (appConfig) {
|
|
82
|
-
|
|
53
|
+
if (!appConfig || typeof appConfig !== 'object') return appConfig
|
|
83
54
|
return {
|
|
84
|
-
...
|
|
85
|
-
theme: jsonSafeClone(
|
|
86
|
-
themes: jsonSafeClone(
|
|
87
|
-
resourceTemplates: jsonSafeClone(
|
|
55
|
+
...appConfig,
|
|
56
|
+
theme: jsonSafeClone(appConfig.theme),
|
|
57
|
+
themes: jsonSafeClone(appConfig.themes),
|
|
58
|
+
resourceTemplates: jsonSafeClone(appConfig.resourceTemplates),
|
|
88
59
|
}
|
|
89
60
|
}
|
|
90
61
|
|
|
91
62
|
export const BuildPage = {
|
|
92
|
-
async handle ({ route, appConfig
|
|
63
|
+
async handle ({ route, appConfig }) {
|
|
93
64
|
const hydrationConfig = buildHydrationAppConfig(appConfig)
|
|
94
|
-
const
|
|
95
|
-
const rootElement = createElement(Page, hydrationConfig)
|
|
96
|
-
const devReloadScript = isDevReloadEnabled
|
|
97
|
-
? `(function(){try{var es=new EventSource('/__ossy_reload');es.addEventListener('reload',function(){location.reload();});}catch(e){}})();`
|
|
98
|
-
: ``
|
|
99
|
-
|
|
100
|
-
const hydrateUrl = `/static/${route.id}.js`
|
|
101
|
-
const { prelude } = await prerenderToNodeStream(rootElement, {
|
|
102
|
-
bootstrapScriptContent: `window.__INITIAL_APP_CONFIG__ = ${JSON.stringify(hydrationConfig)};${devReloadScript}`,
|
|
103
|
-
bootstrapModules: [hydrateUrl],
|
|
104
|
-
})
|
|
65
|
+
const { renderPage } = await loadSsrBundle(route)
|
|
105
66
|
|
|
106
|
-
return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
data += chunk
|
|
110
|
-
})
|
|
111
|
-
prelude.on('end', () => resolve(data))
|
|
112
|
-
prelude.on('error', reject)
|
|
67
|
+
return renderPage(hydrationConfig, {
|
|
68
|
+
bootstrapScriptContent: `window.__INITIAL_APP_CONFIG__=${JSON.stringify(hydrationConfig)}`,
|
|
69
|
+
bootstrapModules: [`/static/${route.id}.js`],
|
|
113
70
|
})
|
|
114
71
|
},
|
|
115
72
|
}
|
package/cli/server.js
CHANGED
|
@@ -39,9 +39,6 @@ const app = express();
|
|
|
39
39
|
const currentDir = path.dirname(url.fileURLToPath(import.meta.url))
|
|
40
40
|
const ROOT_PATH = path.resolve(currentDir, 'public')
|
|
41
41
|
|
|
42
|
-
const isDevReloadEnabled = process.env.OSSY_DEV_RELOAD === '1'
|
|
43
|
-
const reloadClients = new Set()
|
|
44
|
-
|
|
45
42
|
function parsePortFromArgv(argv) {
|
|
46
43
|
// Supports: --port 4000, --port=4000, -p 4000
|
|
47
44
|
const idx = argv.findIndex(a => a === '--port' || a === '-p')
|
|
@@ -67,33 +64,6 @@ if (Middleware !== undefined) {
|
|
|
67
64
|
console.log(`[@ossy/app][server] ${Middleware?.length || 0} custom middleware loaded`)
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
if (isDevReloadEnabled) {
|
|
71
|
-
app.get('/__ossy_reload', (req, res) => {
|
|
72
|
-
res.status(200)
|
|
73
|
-
res.setHeader('Content-Type', 'text/event-stream')
|
|
74
|
-
res.setHeader('Cache-Control', 'no-cache')
|
|
75
|
-
res.setHeader('Connection', 'keep-alive')
|
|
76
|
-
res.flushHeaders?.()
|
|
77
|
-
|
|
78
|
-
res.write('event: connected\ndata: ok\n\n')
|
|
79
|
-
reloadClients.add(res)
|
|
80
|
-
|
|
81
|
-
req.on('close', () => {
|
|
82
|
-
reloadClients.delete(res)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
app.post('/__ossy_reload', (req, res) => {
|
|
87
|
-
for (const client of reloadClients) {
|
|
88
|
-
try {
|
|
89
|
-
client.write('event: reload\ndata: now\n\n')
|
|
90
|
-
} catch {
|
|
91
|
-
// ignore broken connections
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
res.status(204).end()
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
67
|
|
|
98
68
|
const middleware = [
|
|
99
69
|
morgan('tiny'),
|
|
@@ -163,11 +133,7 @@ app.all('*all', async (req, res) => {
|
|
|
163
133
|
urlPath: requestUrl,
|
|
164
134
|
isAuthenticated: !!req.isAuthenticated,
|
|
165
135
|
})
|
|
166
|
-
const html = await BuildPage.handle({
|
|
167
|
-
route: pageRoute,
|
|
168
|
-
appConfig,
|
|
169
|
-
isDevReloadEnabled,
|
|
170
|
-
})
|
|
136
|
+
const html = await BuildPage.handle({ route: pageRoute, appConfig })
|
|
171
137
|
res.status(200).type('html').send(html)
|
|
172
138
|
return
|
|
173
139
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/app",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"source": "./src/index.js",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -27,14 +27,14 @@
|
|
|
27
27
|
"@babel/eslint-parser": "^7.15.8",
|
|
28
28
|
"@babel/preset-react": "^7.26.3",
|
|
29
29
|
"@babel/register": "^7.25.9",
|
|
30
|
-
"@ossy/connected-components": "^1.11.
|
|
31
|
-
"@ossy/design-system": "^1.11.
|
|
32
|
-
"@ossy/pages": "^1.11.
|
|
33
|
-
"@ossy/router": "^1.11.
|
|
34
|
-
"@ossy/router-react": "^1.11.
|
|
35
|
-
"@ossy/sdk": "^1.11.
|
|
36
|
-
"@ossy/sdk-react": "^1.11.
|
|
37
|
-
"@ossy/themes": "^1.11.
|
|
30
|
+
"@ossy/connected-components": "^1.11.9",
|
|
31
|
+
"@ossy/design-system": "^1.11.9",
|
|
32
|
+
"@ossy/pages": "^1.11.9",
|
|
33
|
+
"@ossy/router": "^1.11.9",
|
|
34
|
+
"@ossy/router-react": "^1.11.9",
|
|
35
|
+
"@ossy/sdk": "^1.11.9",
|
|
36
|
+
"@ossy/sdk-react": "^1.11.9",
|
|
37
|
+
"@ossy/themes": "^1.11.9",
|
|
38
38
|
"@rollup/plugin-alias": "^6.0.0",
|
|
39
39
|
"@rollup/plugin-babel": "6.1.0",
|
|
40
40
|
"@rollup/plugin-commonjs": "^29.0.0",
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"README.md",
|
|
68
68
|
"tsconfig.json"
|
|
69
69
|
],
|
|
70
|
-
"gitHead": "
|
|
70
|
+
"gitHead": "6644afdbe9d5eb8ed1f79657d14b1268ad283bcb"
|
|
71
71
|
}
|