engine-in-web-worker
Jérémy Rialland 2023-09-13 20:48:08 +02:00
parent 200e66b47d
commit a76a6e4fb1
23 changed files with 414 additions and 361 deletions

View File

@ -73,10 +73,7 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks:
'usePromise|useLazyPromise|usePromiseOnSituationChange',
},
{ additionalHooks: 'usePromise|useLazyPromise' },
],
'@typescript-eslint/no-unsafe-call': 'warn',

View File

@ -64,8 +64,8 @@
"rollup": "^3.10.0",
"@types/koa": "^2.13.8",
"@types/react": "^18.2.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "18.3.0-canary-e5205658f-20230913",
"react-dom": "18.3.0-canary-e5205658f-20230913",
"styled-components": "^6.0.7",
"@publicodes/api": "betagouv/publicodes#head=publicodes-in-worker&workspace=@publicodes/api&v0",
"publicodes": "betagouv/publicodes#head=publicodes-in-worker&workspace=publicodes&v0",

View File

@ -16,18 +16,14 @@ const headTagsEnd = '<!--app-helmet-tags:end-->'
const regexHTML = new RegExp(htmlBodyStart + '[\\s\\S]+' + htmlBodyEnd, 'm')
const regexHelmet = new RegExp(headTagsStart + '[\\s\\S]+' + headTagsEnd, 'm')
const script = `<script>window.PRERENDER = true;</script>`
interface Params {
site: string
url: string
lang: string
}
const script = `
<script>
window.PRERENDER = true;
</script>
`
export default async ({ site, url, lang }: Params) => {
const template =
cache[site] ??
@ -36,13 +32,20 @@ export default async ({ site, url, lang }: Params) => {
cache[site] ??= template
// // TODO: Add CI test to enforce meta tags on SSR pages
const { html, styleTags, helmet } = await render(url, lang)
const { html, styleTags, helmet } = (await render(url, lang)) as {
html: string
styleTags: string
helmet?: { title: string; meta: string }
}
const page = template
.replace(regexHTML, html)
.replace(regexHTML, html.trim())
.replace('<!--app-script-->', script)
.replace('<!--app-style-->', styleTags)
.replace(regexHelmet, helmet.title.toString() + helmet.meta.toString())
.replace(
regexHelmet,
(helmet?.title.toString() ?? '') + (helmet?.meta.toString() ?? '')
)
const dir = path.join(dirname, '../dist/prerender', site, decodeURI(url))

View File

@ -21,6 +21,7 @@ export const pagesToPrerender: {
infrance: string[]
} = {
'mon-entreprise': [
'/test-worker',
'/documentation/artisteauteur/cotisations/CSGCRDS/abattement',
// '/iframes/pamc',
// '/iframes/simulateur-embauche',

View File

@ -75,10 +75,10 @@
"modele-social": "workspace:^",
"publicodes": "^1.0.0-beta.73",
"publicodes-react": "^1.0.0-beta.73",
"react": "^18.2.0",
"react": "18.3.0-canary-e5205658f-20230913",
"react-aria": "^3.24.0",
"react-day-picker": "^8.7.1",
"react-dom": "^18.2.0",
"react-dom": "18.3.0-canary-e5205658f-20230913",
"react-easy-emoji": "^1.8.1",
"react-flip-move": "^3.0.5",
"react-helmet-async": "^1.3.0",
@ -86,7 +86,7 @@
"react-instantsearch": "^6.38.1",
"react-instantsearch-dom": "^6.38.1",
"react-redux": "^8.0.5",
"react-router-dom": "^6.14.2",
"react-router-dom": "^6.15.0",
"react-signature-pad-wrapper": "^3.3.1",
"react-spring": "^9.5.5",
"react-stately": "^3.22.0",
@ -118,7 +118,7 @@
"@storybook/react-vite": "^7.0.5",
"@storybook/testing-library": "^0.1.0",
"@types/history": "^5.0.0",
"@types/react": "^18.2.21",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
"@types/react-instantsearch-dom": "^6.12.3",
"@types/react-redux": "^7.1.25",

View File

@ -1,13 +1,18 @@
import {
SuspensePromise,
useAsyncShallowCopy,
useLazyPromise,
PromiseSSR,
usePromise,
useWorkerEngine,
} from '@publicodes/worker-react'
import { ErrorBoundary } from '@sentry/react'
import { FallbackRender } from '@sentry/react/types/errorboundary'
import { ComponentProps, StrictMode, useEffect, useState } from 'react'
import {
ComponentProps,
StrictMode,
Suspense,
use,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Route, Routes } from 'react-router-dom'
import { css, styled } from 'styled-components'
@ -42,15 +47,22 @@ type RootProps = {
basename: ProviderProps['basename']
}
const TestWorkerEngineWrapper = () => (
<Suspense fallback={'TestWorkerEngineWrapper loading...'}>
<TestWorkerEngine />
</Suspense>
)
const TestWorkerEngine = () => {
const [refresh, setRefresh] = useState(0)
const workerEngine = useWorkerEngine()
const [, trigger] = useLazyPromise(
async () => workerEngine.asyncSetSituation({ SMIC: '1000€/mois' }),
[workerEngine],
{ defaultValue: 'loading...' }
)
// const [, trigger] = useLazyPromise(
// async () => workerEngine.asyncSetSituation({ SMIC: '1000€/mois' }),
// [workerEngine],
// { defaultValue: 'loading...' }
// )
const trigger = () => workerEngine.asyncSetSituation({ SMIC: '1000€/mois' })
const date = workerEngine.getRule('date')
const SMIC = workerEngine.getRule('SMIC')
@ -58,38 +70,63 @@ const TestWorkerEngine = () => {
// const parsedRules = useAsyncParsedRules()
const parsedRules = workerEngine.getParsedRules()
// const resultSmic = usePromise(
// () => workerEngine.asyncEvaluate('SMIC'),
// [workerEngine],
// 'loading...'
// )
const resultSmic = usePromise(
() => workerEngine.asyncEvaluate('SMIC'),
[workerEngine],
'loading...'
() =>
workerEngine.asyncEvaluate('SMIC').then((val) => {
console.log('**************************************')
return val
}),
[workerEngine]
)
const resultSmicx = usePromise(
() =>
workerEngine.asyncEvaluate('SMIC').then((val) => {
console.log('¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤¤')
return val
}),
[workerEngine]
)
console.log('#####>', resultSmic, resultSmicx)
// const [resultLazySmic, triggerLazySmic] = useLazyPromise(
// () => workerEngine.asyncEvaluate('SMIC'),
// [workerEngine],
// 'wait 3sec...'
// )
useEffect(
() => {
void (async () => {
await workerEngine.isWorkerReady
setTimeout(() => {
// void triggerLazySmic()
}, 3000)
})()
},
[
// triggerLazySmic, workerEngine.isWorkerReady
]
)
const [resultLazySmic, triggerLazySmic] = useLazyPromise(
() => workerEngine.asyncEvaluate('SMIC'),
[workerEngine],
'wait 3sec...'
)
useEffect(() => {
void (async () => {
await workerEngine.isWorkerReady
setTimeout(() => {
void triggerLazySmic()
}, 3000)
})()
}, [triggerLazySmic, workerEngine.isWorkerReady])
const workerEngineCopy = useAsyncShallowCopy(workerEngine)
// const workerEngineCopy = useAsyncShallowCopy(workerEngine)
// // const workerEngineCopy = workerEngine
console.log('=========>', workerEngine, workerEngineCopy)
// console.log('=========>', workerEngine, workerEngineCopy)
const [, triggerCopy] = useLazyPromise(async () => {
console.log('+++++++++>', workerEngineCopy)
// const [, triggerCopy] = useLazyPromise(async () => {
// console.log('+++++++++>', workerEngineCopy)
await workerEngineCopy?.asyncSetSituation({
SMIC: '2000€/mois',
})
}, [workerEngineCopy])
// await workerEngineCopy?.asyncSetSituation({
// SMIC: '2000€/mois',
// })
// }, [workerEngineCopy])
// const dateCopy = useAsyncGetRule('date', {
// defaultValue: 'loading...',
@ -100,54 +137,80 @@ const TestWorkerEngine = () => {
// workerEngine: workerEngineCopy,
// })
const dateCopy = workerEngineCopy?.getRule('date')
const parsedRulesCopy = workerEngineCopy?.getParsedRules()
// const dateCopy = workerEngineCopy?.getRule('date')
// const parsedRulesCopy = workerEngineCopy?.getParsedRules()
const resultSmicCopy = usePromise(
async () =>
!workerEngineCopy
? 'still loading...'
: workerEngineCopy.asyncEvaluate('SMIC'),
[workerEngineCopy],
'loading...'
)
// const resultSmicCopy = usePromise(
// async () =>
// !workerEngineCopy
// ? 'still loading...'
// : workerEngineCopy.asyncEvaluate('SMIC'),
// [workerEngineCopy],
// 'loading...'
// )
// const resultSmicCopy = !workerEngineCopy
// ? 'still loading...'
// : use(workerEngineCopy.asyncEvaluate('SMIC'))
const [resultLazySmicCopy, triggerLazySmicCopy] = useLazyPromise(
async () =>
!workerEngineCopy
? 'still loading...'
: workerEngineCopy.asyncEvaluate('SMIC'),
[workerEngineCopy],
'wait 3sec...'
)
// const [resultLazySmicCopy, triggerLazySmicCopy] = useLazyPromise(
// async () =>
// !workerEngineCopy
// ? 'still loading...'
// : workerEngineCopy.asyncEvaluate('SMIC'),
// [workerEngineCopy],
// 'wait 3sec...'
// )
useEffect(() => {
// console.log('useEffect')
// useEffect(
// () => {
// // console.log('useEffect')
void (async () => {
await workerEngine.isWorkerReady
setTimeout(() => {
void triggerLazySmicCopy()
}, 3000)
})()
}, [triggerLazySmicCopy, workerEngine.isWorkerReady])
// void (async () => {
// await workerEngine.isWorkerReady
// setTimeout(() => {
// // void triggerLazySmicCopy()
// }, 3000)
// })()
// },
// [
// // triggerLazySmicCopy, workerEngine.isWorkerReady
// ]
// )
const { asyncSetSituation } = workerEngineCopy ?? {}
usePromise(async () => {
// console.log('**************>', workerEngineCopy, resultSmic)
// const { asyncSetSituation } = workerEngineCopy ?? {}
// usePromise(async () => {
// // console.log('**************>', workerEngineCopy, resultSmic)
if (
resultSmic &&
typeof resultSmic !== 'string' &&
typeof resultSmic.nodeValue === 'number'
) {
// console.log('ooooooooooooooooooo', resultSmic)
// if (
// resultSmic &&
// typeof resultSmic !== 'string' &&
// typeof resultSmic.nodeValue === 'number'
// ) {
// // console.log('ooooooooooooooooooo', resultSmic)
await asyncSetSituation?.({
SMIC: resultSmic.nodeValue + '€/mois',
})
}
}, [asyncSetSituation, resultSmic])
// await asyncSetSituation?.({
// SMIC: resultSmic.nodeValue + '€/mois',
// })
// }
// }, [asyncSetSituation, resultSmic])
// use(
// (async () => {
// // console.log('**************>', workerEngineCopy, resultSmic)
// if (
// resultSmic &&
// typeof resultSmic !== 'string' &&
// typeof resultSmic.nodeValue === 'number'
// ) {
// // console.log('ooooooooooooooooooo', resultSmic)
// await asyncSetSituation?.({
// SMIC: resultSmic.nodeValue + '€/mois',
// })
// }
// })()
// )
return (
<div>
@ -178,40 +241,40 @@ const TestWorkerEngine = () => {
</p>
<p>
resultLazySmic:{' '}
{JSON.stringify(
{/* {JSON.stringify(
typeof resultLazySmic === 'string'
? resultLazySmic
: resultLazySmic?.nodeValue
)}
)} */}
</p>
<p>workerEngineCopy: {JSON.stringify(workerEngineCopy?.engineId)}</p>
{/* <p>workerEngineCopy: {JSON.stringify(workerEngineCopy?.engineId)}</p> */}
<p>
dateCopy title:{' '}
{JSON.stringify(
{/* {JSON.stringify(
typeof dateCopy === 'string' ? dateCopy : dateCopy?.title
)}
)} */}
</p>
<p>
parsedRulesCopy length:{' '}
{JSON.stringify(Object.entries(parsedRulesCopy ?? {}).length)}
{/* {JSON.stringify(Object.entries(parsedRulesCopy ?? {}).length)} */}
</p>
<p>
{/* <p>
resultSmicCopy:{' '}
{JSON.stringify(
typeof resultSmicCopy === 'string'
? resultSmicCopy
: resultSmicCopy?.nodeValue
)}
</p>
</p> */}
<p>
resultLazySmicCopy:{' '}
{JSON.stringify(
{/* {JSON.stringify(
typeof resultLazySmicCopy === 'string'
? resultLazySmicCopy
: resultLazySmicCopy?.nodeValue
)}
)} */}
</p>
</div>
)
@ -233,73 +296,39 @@ RootProps) {
// [rules]
// )
const [promiseSSR, setPromiseSSR] = useState(false)
const elems = (
<Provider basename={basename}>
<Redirections>
<Router />
</Redirections>
</Provider>
)
return (
<StrictMode>
<Provider basename={basename}>
<Redirections>
<Router />
</Redirections>
</Provider>
<button onClick={() => setPromiseSSR((v) => !v)}>
use is {promiseSSR ? 'enabled' : 'disabled'}
</button>
{promiseSSR ? <PromiseSSR>{elems}</PromiseSSR> : elems}
</StrictMode>
)
}
const Router = () => {
/*
const exampleSyncValue = usePromiseOnSituationChange(
() => asyncEvaluate('SMIC'),
[]
)?.nodeValue
const exampleSyncValueWithDefault = usePromiseOnSituationChange(
async () => (await asyncEvaluate('SMIC')).nodeValue,
[],
'loading...'
)
const [exampleAsyncValue, fireEvaluate] = useLazyPromise(
async (param: PublicodesExpression) =>
(await asyncEvaluate(param)).nodeValue,
[],
42
)
usePromise(async () => {
let count = 0
const interval = setInterval(() => {
void fireEvaluate(count++ % 2 === 0 ? 'date' : 'SMIC')
if (count === 7) clearInterval(interval)
}, 1000)
await new Promise((resolve) => setTimeout(resolve, 3000))
await asyncSetSituation({ date: '01/01/2022' })
await new Promise((resolve) => setTimeout(resolve, 3000))
await asyncSetSituation({ date: '01/01/2021' })
await new Promise((resolve) => setTimeout(resolve, 3000))
}, [fireEvaluate])
*/
return (
<>
{/* exemple sans valeur par defaut : {JSON.stringify(exampleSyncValue)}
<br />
exemple avec valeur par defaut :{' '}
{JSON.stringify(exampleSyncValueWithDefault)} <br />
exemple d'execution manuel : {JSON.stringify(exampleAsyncValue)} */}
{/* */}
<Routes>
<Route
path="test-worker"
element={
<>
<SuspensePromise isSSR={import.meta.env.SSR}>
{/* <TestWorkerEngine /> */}
<div>
<TestWorkerEngine />
</div>
</SuspensePromise>
</>
<div>
<PromiseSSR>
<TestWorkerEngineWrapper />
<TestWorkerEngineWrapper />
</PromiseSSR>
</div>
}
/>
@ -355,52 +384,54 @@ const App = () => {
</a>
<Container>
<ErrorBoundary fallback={CatchOffline}>
<Routes>
<Route index element={<Landing />} />
<Suspense>
<Routes>
<Route index element={<Landing />} />
{/* <Route
{/* <Route
path={relativeSitePaths.assistants.index + '/*'}
element={<Assistants />}
/> */}
<Route
path={relativeSitePaths.simulateurs.index + '/*'}
element={<Simulateurs />}
/>
<Route
path={relativeSitePaths.simulateursEtAssistants + '/*'}
element={<SimulateursEtAssistants />}
/>
<Route
path={relativeSitePaths.documentation.index + '/*'}
element={
<Documentation
documentationPath={documentationPath}
engine={workerEngine}
/>
}
/>
<Route
path={relativeSitePaths.développeur.index + '/*'}
element={<Integration />}
/>
<Route
path={relativeSitePaths.nouveautés.index + '/*'}
element={<Nouveautés />}
/>
<Route path={relativeSitePaths.stats} element={<Stats />} />
<Route path={relativeSitePaths.budget} element={<Budget />} />
<Route
path={relativeSitePaths.accessibilité}
element={<Accessibilité />}
/>
<Route
path="/dev/integration-test"
element={<IntegrationTest />}
/>
<Route path={relativeSitePaths.plan} element={<Plan />} />
<Route
path={relativeSitePaths.simulateurs.index + '/*'}
element={<Simulateurs />}
/>
<Route
path={relativeSitePaths.simulateursEtAssistants + '/*'}
element={<SimulateursEtAssistants />}
/>
<Route
path={relativeSitePaths.documentation.index + '/*'}
element={
<Documentation
documentationPath={documentationPath}
engine={workerEngine}
/>
}
/>
<Route
path={relativeSitePaths.développeur.index + '/*'}
element={<Integration />}
/>
<Route
path={relativeSitePaths.nouveautés.index + '/*'}
element={<Nouveautés />}
/>
<Route path={relativeSitePaths.stats} element={<Stats />} />
<Route path={relativeSitePaths.budget} element={<Budget />} />
<Route
path={relativeSitePaths.accessibilité}
element={<Accessibilité />}
/>
<Route
path="/dev/integration-test"
element={<IntegrationTest />}
/>
<Route path={relativeSitePaths.plan} element={<Plan />} />
<Route path="*" element={<Page404 />} />
</Routes>
<Route path="*" element={<Page404 />} />
</Routes>
</Suspense>
</ErrorBoundary>
</Container>
</main>

View File

@ -1,28 +1,28 @@
import NodeWorker from '@eshaz/web-worker'
import { createWorkerEngineClient } from '@publicodes/worker'
import {
SuspensePromise,
useWorkerEngine,
WorkerEngineProvider,
} from '@publicodes/worker-react'
import { useWorkerEngine, WorkerEngineProvider } from '@publicodes/worker-react'
import { OverlayProvider } from '@react-aria/overlays'
import { ErrorBoundary } from '@sentry/react'
import i18next from 'i18next'
import { createContext, ReactNode } from 'react'
import { createContext, ReactNode, Suspense } from 'react'
import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider, Trans, useTranslation } from 'react-i18next'
import { Provider as ReduxProvider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
// import NodeWorker from 'whatwg-worker'
import logo from '@/assets/images/logo-monentreprise.svg'
import FeedbackForm from '@/components/Feedback/FeedbackForm'
import { ThemeColorsProvider } from '@/components/utils/colors'
import { DisableAnimationOnPrintProvider } from '@/components/utils/DisableAnimationContext'
import { Loader } from '@/design-system/icons/Loader'
import { Container, Grid } from '@/design-system/layout'
import DesignSystemThemeProvider from '@/design-system/root'
import { H1, H4 } from '@/design-system/typography/heading'
import { Link } from '@/design-system/typography/link'
import { Body, Intro } from '@/design-system/typography/paragraphs'
import { ClientOnly } from '@/hooks/useClientOnly'
// import { workerClient } from '@/entries/entry-fr'
import { EmbededContextProvider } from '@/hooks/useIsEmbedded'
import { Actions } from '@/worker/socialWorkerEngine.worker'
@ -43,11 +43,13 @@ console.time('start!')
export const worker = import.meta.env.SSR
? // Node doesn't support web worker :( upvote issue here: https://github.com/nodejs/node/issues/43583
new NodeWorker(
new URL('../worker/socialWorkerEngine.worker.js', import.meta.url),
new URL('./worker/socialWorkerEngine.worker.js', import.meta.url),
{ type: 'module' }
)
: new SocialeWorkerEngine()
console.log('worker', worker)
const workerClient = createWorkerEngineClient<Actions>(worker, {
initParams: [{ basename: 'mon-entreprise' }],
})
@ -84,7 +86,7 @@ export default function Provider({
<I18nextProvider i18n={i18next}>
<ReduxProvider store={store}>
<BrowserRouterProvider basename={basename}>
<SuspensePromise isSSR={import.meta.env.SSR}>
<Suspense fallback={<Loader />}>
<WorkerEngineProvider workerClient={workerClient}>
<SituationSynchronize>
<ErrorBoundary
@ -93,9 +95,10 @@ export default function Provider({
<ErrorFallback {...errorData} />
)}
>
{!import.meta.env.SSR &&
import.meta.env.MODE === 'production' &&
'serviceWorker' in navigator && <ServiceWorker />}
<ClientOnly>
{!import.meta.env.SSR &&
'serviceWorker' in navigator && <ServiceWorker />}
</ClientOnly>
<IframeResizer />
<OverlayProvider>
<ThemeColorsProvider>
@ -109,7 +112,7 @@ export default function Provider({
</ErrorBoundary>
</SituationSynchronize>
</WorkerEngineProvider>
</SuspensePromise>
</Suspense>
</BrowserRouterProvider>
</ReduxProvider>
</I18nextProvider>

View File

@ -1,4 +1,4 @@
import { ComponentPropsWithoutRef } from 'react'
import { ComponentPropsWithoutRef, Suspense } from 'react'
import { useSelector } from 'react-redux'
import { useLocation } from 'react-router-dom'
import { styled } from 'styled-components'
@ -102,7 +102,9 @@ export default function SimulateurOrAssistantPage() {
</>
)}
<Component />
<Suspense>
<Component />
</Suspense>
{!inIframe && (
<>

View File

@ -1,7 +1,7 @@
import { useWorkerEngine } from '@publicodes/worker-react'
import { usePromise, useWorkerEngine } from '@publicodes/worker-react'
import { DottedName } from 'modele-social'
import { formatValue, PublicodesExpression } from 'publicodes'
import React, { useCallback, useState } from 'react'
import React, { Suspense, useCallback, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { styled } from 'styled-components'
@ -9,7 +9,6 @@ import { ForceThemeProvider } from '@/components/utils/DarkModeContext'
import { Grid } from '@/design-system/layout'
import { Strong } from '@/design-system/typography'
import { Body, SmallBody } from '@/design-system/typography/paragraphs'
import { usePromise } from '@/hooks/usePromise'
import { updateSituation } from '@/store/actions/actions'
import { targetUnitSelector } from '@/store/selectors/simulationSelectors'
@ -59,7 +58,7 @@ export function SimulationGoal({
arrondi: round ? 'oui' : 'non',
...(!isTypeBoolean ? { unité: currentUnit } : {}),
}),
[workerEngine, dottedName, round, isTypeBoolean, currentUnit]
[currentUnit, dottedName, isTypeBoolean, round, workerEngine]
)
const rule = workerEngine.getRule(dottedName)
@ -143,33 +142,35 @@ export function SimulationGoal({
{!isFocused && !small && evaluation && (
<AnimatedTargetValue value={evaluation.nodeValue as number} />
)}
<RuleInput
modifiers={
!isTypeBoolean
? {
unité: currentUnit,
}
: undefined
}
aria-label={rule?.title}
aria-describedby={`${dottedName.replace(
/\s|\./g,
'_'
)}-description`}
aria-labelledby="simu-update-explaining"
displayedUnit={displayedUnit}
dottedName={dottedName}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={onChange}
missing={
evaluation && dottedName in evaluation.missingVariables
}
small={small}
formatOptions={{
maximumFractionDigits: round ? 0 : 2,
}}
/>
<Suspense>
<RuleInput
modifiers={
!isTypeBoolean
? {
unité: currentUnit,
}
: undefined
}
aria-label={rule?.title}
aria-describedby={`${dottedName.replace(
/\s|\./g,
'_'
)}-description`}
aria-labelledby="simu-update-explaining"
displayedUnit={displayedUnit}
dottedName={dottedName}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={onChange}
missing={
evaluation && dottedName in evaluation.missingVariables
}
small={small}
formatOptions={{
maximumFractionDigits: round ? 0 : 2,
}}
/>
</Suspense>
</Grid>
) : (
<Grid item>

View File

@ -1,7 +1,7 @@
import { useWorkerEngine, WorkerEngine } from '@publicodes/worker-react'
import { DottedName } from 'modele-social'
import { PublicodesExpression, RuleNode } from 'publicodes'
import React, { useCallback, useEffect, useState } from 'react'
import React, { Suspense, useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
@ -143,13 +143,15 @@ export default function Conversation({
'Répondez à quelques questions additionnelles afin de préciser votre résultat.'
)}
</legend>
<RuleInput
dottedName={currentQuestion}
onChange={onChange}
key={currentQuestion}
onSubmit={goToNext}
aria-labelledby="questionHeader"
/>
<Suspense>
<RuleInput
dottedName={currentQuestion}
onChange={onChange}
key={currentQuestion}
onSubmit={goToNext}
aria-labelledby="questionHeader"
/>
</Suspense>
</fieldset>
<Spacing md />
<Grid container spacing={2}>

View File

@ -108,9 +108,6 @@ export default function RuleInput({
const value = evaluation?.nodeValue
const isMultipleChoices =
rule && isMultiplePossibilities(workerEngine, dottedName)
const choice = usePromise(
() => getOnePossibilityOptions(workerEngine, dottedName),
[workerEngine, dottedName]
@ -139,7 +136,7 @@ export default function RuleInput({
}
const meta = getMeta<{ affichage?: string }>(rule.rawNode, {})
if (isMultipleChoices) {
if (rule && isMultiplePossibilities(workerEngine, dottedName)) {
return (
<MultipleChoicesInput
{...commonProps}

View File

@ -8,6 +8,7 @@ import { Emoji } from '@/design-system/emoji'
import { Container } from '@/design-system/layout'
import { Switch } from '@/design-system/switch'
import { Link } from '@/design-system/typography/link'
import { ClientOnly } from '@/hooks/useClientOnly'
import { useDarkMode } from '@/hooks/useDarkMode'
import { useGetFullURL } from '@/hooks/useGetFullURL'
import { useSitePaths } from '@/sitePaths'
@ -72,7 +73,7 @@ export default function Header() {
<Menu />
</StyledHeader>
<BrowserOnly>{i18n.language === 'fr' && <NewsBanner />}</BrowserOnly>
<ClientOnly>{i18n.language === 'fr' && <NewsBanner />}</ClientOnly>
</Container>
</header>
)

View File

@ -170,6 +170,7 @@ export const useSetupSafeSituation = (workerEngine?: WorkerEngine) => {
console.log('set rawSituation', rawSituation, workerEngine)
void asyncSetSituation(rawSituation)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asyncSetSituation, rawSituation])
// try {

View File

@ -1,27 +1,28 @@
import { I18nProvider } from '@react-aria/i18n'
import { withProfiler } from '@sentry/react'
import { createRoot } from 'react-dom/client'
import { createRoot, hydrateRoot } from 'react-dom/client'
import App from '../components/App'
import i18next from '../locales/i18n'
import ruleTranslations from '../locales/rules-en.yaml'
import translateRules from '../locales/translateRules'
// import ruleTranslations from '../locales/rules-en.yaml'
// import translateRules from '../locales/translateRules'
import translations from '../locales/ui-en.yaml'
import '../api/sentry'
export const AppEn = () => (
const AppEn = () => (
<I18nProvider locale="en-GB">
<App
basename="infrance"
rulesPreTransform={(rules) =>
translateRules('en', ruleTranslations, rules)
}
// TODO: translate worker
// rulesPreTransform={(rules) =>
// translateRules('en', ruleTranslations, rules)
// }
/>
</I18nProvider>
)
const AppEnWithProfiler = withProfiler(AppEn)
export const AppEnWithProfiler = withProfiler(AppEn)
i18next.addResourceBundle('en', 'translation', translations)
@ -32,6 +33,12 @@ if (!import.meta.env.SSR) {
)
const container = document.querySelector('#js') as Element
const root = createRoot(container)
root.render(<AppEnWithProfiler />)
if (window.PRERENDER) {
container.innerHTML = container.innerHTML.trim() // Trim before hydrating to avoid mismatche error.
const root = hydrateRoot(container, <AppEnWithProfiler />)
console.log('>>> hydrateRoot DONE', root)
} else {
const root = createRoot(container)
root.render(<AppEnWithProfiler />)
}
}

View File

@ -13,7 +13,7 @@ declare global {
}
}
export const AppFr = () => {
const AppFr = () => {
return (
<I18nProvider locale="fr-FR">
<App basename="mon-entreprise" />
@ -21,7 +21,7 @@ export const AppFr = () => {
)
}
const AppFrWithProfiler = withProfiler(AppFr)
export const AppFrWithProfiler = withProfiler(AppFr)
if (!import.meta.env.SSR) {
i18next.changeLanguage('fr').catch((err) =>
@ -30,7 +30,9 @@ if (!import.meta.env.SSR) {
)
const container = document.querySelector('#js') as Element
if (window.PRERENDER) {
container.innerHTML = container.innerHTML.trim() // Trim before hydrating to avoid mismatche error.
const root = hydrateRoot(container, <AppFrWithProfiler />)
console.log('>>> hydrateRoot DONE', root)
} else {
const root = createRoot(container)
root.render(<AppFrWithProfiler />)

View File

@ -1,5 +1,5 @@
import { PromiseSSR } from '@publicodes/worker-react'
import { SSRProvider } from '@react-aria/ssr'
import { lazy } from 'react'
import ReactDomServerType from 'react-dom/server'
// @ts-ignore
import ReactDomServer from 'react-dom/server.browser'
@ -8,6 +8,8 @@ import { StaticRouter } from 'react-router-dom/server'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
import i18next from '../locales/i18n'
import { AppEnWithProfiler as AppEn } from './entry-en'
import { AppFrWithProfiler as AppFr } from './entry-fr'
const { renderToReadableStream } = ReactDomServer as typeof ReactDomServerType
@ -15,14 +17,6 @@ function streamToString(stream: ReadableStream<Uint8Array>) {
return new Response(stream).text()
}
const AppFrLazy = lazy(async () => ({
default: (await import('./entry-fr')).AppFr,
}))
const AppEnLazy = lazy(async () => ({
default: (await import('./entry-en')).AppEn,
}))
// @ts-ignore
global.window = {
// @ts-ignore
@ -38,7 +32,6 @@ interface Result {
export async function render(url: string, lang: 'fr' | 'en'): Promise<Result> {
global.window.location.href = url
global.window.location.search = ''
console.log({ url, lang })
const sheet = new ServerStyleSheet()
const helmetContext = {} as FilledContext
@ -52,35 +45,30 @@ export async function render(url: string, lang: 'fr' | 'en'): Promise<Result> {
<SSRProvider>
<StyleSheetManager sheet={sheet.instance}>
<StaticRouter location={url}>
[prerender] window: {JSON.stringify(window)}
{lang === 'fr' ? <AppFrLazy /> : <AppEnLazy />}
<PromiseSSR>{lang === 'fr' ? <AppFr /> : <AppEn />}</PromiseSSR>
</StaticRouter>
</StyleSheetManager>
</SSRProvider>
</HelmetProvider>
)
console.log('!!! STARTING !!!')
try {
const stream = await renderToReadableStream(element, {
onError(error, errorInfo) {
// eslint-disable-next-line no-console
console.error({ error, errorInfo })
},
})
console.log('!!! LOADING !!!')
await stream.allReady
console.log('!!! DONE !!!')
const html = await streamToString(stream)
const styleTags = sheet.getStyleTags()
return { html, styleTags, helmet: helmetContext.helmet }
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw error

View File

@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'
// Refacto of https://github.com/gfmio/react-client-only/blob/master/index.ts
/** React hook that returns true if the component has mounted client-side */
export const useClientOnly = () => {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => setHasMounted(true), [])
return hasMounted
}
/** React component that renders its children client-side only / after first mount */
export const ClientOnly = ({ children }: { children: React.ReactNode }) =>
useClientOnly() ? children : null

View File

@ -1,12 +1,8 @@
import {
SuspensePromise,
useWorkerEngine,
WorkerEngine,
} from '@publicodes/worker-react'
import { useWorkerEngine, WorkerEngine } from '@publicodes/worker-react'
import rules, { DottedName } from 'modele-social'
import Engine from 'publicodes'
import { RulePage, useDocumentationSiteMap } from 'publicodes-react'
import { ComponentProps, useMemo, useRef } from 'react'
import { ComponentProps, Suspense, useMemo, useRef } from 'react'
import { Helmet } from 'react-helmet-async'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
@ -45,16 +41,12 @@ interface DocumentationProps {
export default function DocumentationWrapper(props: DocumentationProps) {
return (
<SuspensePromise
isSSR={import.meta.env.SSR}
fallback={<div>DocumentationWrapper loading...</div>}
activateInBrowser
>
<Suspense fallback={<div>DocumentationWrapper loading...</div>}>
<Documentation
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
</SuspensePromise>
</Suspense>
)
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { Suspense, useEffect, useMemo } from 'react'
import { Trans } from 'react-i18next'
import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
@ -42,7 +42,11 @@ export default function Simulateurs() {
path={
s.path.replace(absoluteSitePaths.simulateurs.index, '') + '/*'
}
element={<SimulateurOrAssistantPage />}
element={
<Suspense>
<SimulateurOrAssistantPage />
</Suspense>
}
/>
)),
[simulatorsData, absoluteSitePaths]

View File

@ -23,7 +23,7 @@ function getUnitKey(unit: string): string {
}
let warnCount = 0
let timeout: NodeJS.Timeout | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
const logger = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
warn: (message: string) => {
@ -57,12 +57,12 @@ const init = ({ basename }: Pick<ProviderProps, 'basename'>) => {
const engine = new Engine(rules, { getUnitKey, logger })
console.timeEnd('[createWorkerEngine] init')
console.timeEnd('(createWorkerEngine) init')
return engine
}
console.time('[createWorkerEngine] init')
console.time('(createWorkerEngine) init')
createWorkerEngine(init, {
...publicodesReactActions(),

View File

@ -14,7 +14,7 @@
"paths": {
"@/*": ["*"]
},
"types": ["vite/client", "vite-plugin-pwa/client"],
"types": ["vite/client", "vite-plugin-pwa/client", "react/canary"],
"typeRoots": [
"../node_modules/@types",
"../node_modules",

View File

@ -139,8 +139,13 @@ export default defineConfig(({ command, mode }) => ({
optimizeDeps: {
entries: ['./source/entries/entry-fr.tsx', './source/entries/entry-en.tsx'],
include: ['publicodes-react > react/jsx-runtime'],
exclude: ['publicodes-react', 'publicodes'],
include: [],
exclude: [
'publicodes-react',
'publicodes',
'@publicodes/worker',
'@publicodes/worker-react',
],
},
ssr: {

106
yarn.lock
View File

@ -7390,7 +7390,7 @@ __metadata:
"@publicodes/api@betagouv/publicodes#head=publicodes-in-worker&workspace=@publicodes/api&v0":
version: 1.0.0-beta.72
resolution: "@publicodes/api@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fapi&v0=&commit=6eab7e0019e133a012b216da6d60e3314aec5707"
resolution: "@publicodes/api@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fapi&v0=&commit=2125c306e130ea2a67e547539e47a6792e203b0f"
dependencies:
"@koa/cors": ^3.3.0
"@koa/router": ^10.1.1
@ -7405,23 +7405,23 @@ __metadata:
"@publicodes/worker-react@betagouv/publicodes#head=publicodes-in-worker&workspace=@publicodes/worker-react&v0":
version: 1.0.0-beta.71
resolution: "@publicodes/worker-react@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fworker-react&v0=&commit=6eab7e0019e133a012b216da6d60e3314aec5707"
resolution: "@publicodes/worker-react@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fworker-react&v0=&commit=2125c306e130ea2a67e547539e47a6792e203b0f"
dependencies:
"@publicodes/worker": ^1.0.0-beta.71
peerDependencies:
publicodes: ^1.0.0-beta.40
react: ^17 || ^18
react-dom: ^17 || ^18
checksum: 2428c2acd4a5cde2432c532562044b94fef0e9aa430406cc07c164caa71ec6484cdcf7906face107968d42bba70b1aa86e35f37bbcc63b9b2f000ba17c7e327d
react: 18.3.0-canary-e5205658f-20230913
react-dom: 18.3.0-canary-e5205658f-20230913
checksum: 24c7a4067e1b8055e850be2ae5c00bea501295478afb894b7ca014456a0337e92535ba20eda00514a15caca47a80c353e9db5364105fe61d7ae3577d786b340f
languageName: node
linkType: hard
"@publicodes/worker@betagouv/publicodes#head=publicodes-in-worker&workspace=@publicodes/worker&v0":
version: 1.0.0-beta.71
resolution: "@publicodes/worker@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fworker&v0=&commit=6eab7e0019e133a012b216da6d60e3314aec5707"
resolution: "@publicodes/worker@https://github.com/betagouv/publicodes.git#workspace=%40publicodes%2Fworker&v0=&commit=2125c306e130ea2a67e547539e47a6792e203b0f"
peerDependencies:
publicodes: ^1.0.0-beta.40
checksum: 26c9335ef5b4f28f241ed21f2409dc2509293432f070b76524dea877ffdf82f08e349183f87463e9f5c61ee6548640af741c40e5970f13973c8b1a250d39f6c1
checksum: 75967563fa5d4c7eb8980d0f5625028bdf7d997f3d368949669ac3ebbd82800573ecfc5eede9baa2ccb184d8ed766ec45f988acd48a985e4a493276b07d0d7e4
languageName: node
linkType: hard
@ -9401,10 +9401,10 @@ __metadata:
languageName: node
linkType: hard
"@remix-run/router@npm:1.7.2":
version: 1.7.2
resolution: "@remix-run/router@npm:1.7.2"
checksum: ea43bb662f1f5c93965989b1667fb6e8a301cb69c44341ee92c81cb15ea685b494168e5905593b5777d59058f1455b4b58083d5b895f04382e49362e420d7af4
"@remix-run/router@npm:1.8.0":
version: 1.8.0
resolution: "@remix-run/router@npm:1.8.0"
checksum: f754f02d3b4fc86791b88acf16065000609e2324b9436027844a76831c7107c0994067cb83abdd6093c282bd518a5c89b5e02aead585782978586e3a04534428
languageName: node
linkType: hard
@ -25150,21 +25150,21 @@ __metadata:
"publicodes-react@betagouv/publicodes#head=publicodes-in-worker&workspace=publicodes-react&v0":
version: 1.0.0-beta.72
resolution: "publicodes-react@https://github.com/betagouv/publicodes.git#workspace=publicodes-react&v0=&commit=6eab7e0019e133a012b216da6d60e3314aec5707"
resolution: "publicodes-react@https://github.com/betagouv/publicodes.git#workspace=publicodes-react&v0=&commit=2125c306e130ea2a67e547539e47a6792e203b0f"
dependencies:
"@publicodes/worker-react": ^1.0.0-beta.71
styled-components: ^6.0.7
peerDependencies:
publicodes: ^1.0.0-beta.72
react: ^18
react-dom: ^18
checksum: e775ffb71a63949fbb2f3f55e97981b376464e3cc075f3c4f4c443e516d66910d7346a1f3952ffc905237680ba532253fde57a24b17a46e48c6940321383dedb
react: 18.3.0-canary-e5205658f-20230913
react-dom: 18.3.0-canary-e5205658f-20230913
checksum: 676b1bee77dabd644726259640cd08c5a59c316390b3036b2d3f06df39478cb5666399baae501447a455ec67ae1604c9d6b6f1dcb090fbbbfa4b7b614ea351e0
languageName: node
linkType: hard
"publicodes@betagouv/publicodes#head=publicodes-in-worker&workspace=publicodes&v0":
version: 1.0.0-beta.72
resolution: "publicodes@https://github.com/betagouv/publicodes.git#workspace=publicodes&v0=&commit=6eab7e0019e133a012b216da6d60e3314aec5707"
resolution: "publicodes@https://github.com/betagouv/publicodes.git#workspace=publicodes&v0=&commit=2125c306e130ea2a67e547539e47a6792e203b0f"
peerDependencies:
"@types/mocha": ^9.0.0
checksum: ab67797de175ae5b6991cf6de0163d927160599afb5d002b611b7e39844d06a3c855615cd4ebced250c60e1e9e86335579ba61e417e3fdcac9cf18f5c8ec003d
@ -25496,15 +25496,15 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:^18.2.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
"react-dom@npm:18.3.0-canary-e5205658f-20230913":
version: 18.3.0-canary-e5205658f-20230913
resolution: "react-dom@npm:18.3.0-canary-e5205658f-20230913"
dependencies:
loose-envify: ^1.1.0
scheduler: ^0.23.0
scheduler: 0.24.0-canary-e5205658f-20230913
peerDependencies:
react: ^18.2.0
checksum: 7d323310bea3a91be2965f9468d552f201b1c27891e45ddc2d6b8f717680c95a75ae0bc1e3f5cf41472446a2589a75aed4483aee8169287909fcd59ad149e8cc
react: 18.3.0-canary-e5205658f-20230913
checksum: da09fd41323281c79403666ef57ac5ec30998dd88dd0b2875cbdd9faf413410b930ef8e08b292c965743d9c2005991580cc1ed1e754e4899175da3616f89569c
languageName: node
linkType: hard
@ -25746,27 +25746,27 @@ __metadata:
languageName: node
linkType: hard
"react-router-dom@npm:^6.14.2":
version: 6.14.2
resolution: "react-router-dom@npm:6.14.2"
"react-router-dom@npm:^6.15.0":
version: 6.15.0
resolution: "react-router-dom@npm:6.15.0"
dependencies:
"@remix-run/router": 1.7.2
react-router: 6.14.2
"@remix-run/router": 1.8.0
react-router: 6.15.0
peerDependencies:
react: ">=16.8"
react-dom: ">=16.8"
checksum: a53dbc566ecab7890b829d42d38553684f704803c1f615db1bd6aa2d71542c369a1a79e4385be31ae71a14b72ddbcd0d8b51188248c2bccd44e015050d1927df
checksum: 95301837e293654f00934de6a4bdb27bfb06f613503e4cce7a93f19384793729832e7479d50faf3b9457d149014d4df40a3ee3a5193d7e3a3caadb7aaa6ec0f9
languageName: node
linkType: hard
"react-router@npm:6.14.2":
version: 6.14.2
resolution: "react-router@npm:6.14.2"
"react-router@npm:6.15.0":
version: 6.15.0
resolution: "react-router@npm:6.15.0"
dependencies:
"@remix-run/router": 1.7.2
"@remix-run/router": 1.8.0
peerDependencies:
react: ">=16.8"
checksum: 7507bf5732b3a8ddbd901c2061216eebca73e194449bff58acc1445171e22bdda36b455b8af066e467748ebfb5875b3c0a565941c46af65c6f653a6ed0dc4fe4
checksum: 345b29277e13997f2625f0037f537eaf1955bb9f44ebfea80dd3ff83fc06273f7b64e1be944bfc75945fd2af5af917874133a8a93ed5ecaca523be8f045ae166
languageName: node
linkType: hard
@ -25885,12 +25885,12 @@ __metadata:
languageName: node
linkType: hard
"react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
"react@npm:18.3.0-canary-e5205658f-20230913":
version: 18.3.0-canary-e5205658f-20230913
resolution: "react@npm:18.3.0-canary-e5205658f-20230913"
dependencies:
loose-envify: ^1.1.0
checksum: 88e38092da8839b830cda6feef2e8505dec8ace60579e46aa5490fc3dc9bba0bd50336507dc166f43e3afc1c42939c09fe33b25fae889d6f402721dcd78fca1b
checksum: 5f76591c029feec8e664739ef9bf1bb41ee68fbca5e43e3cb4673b0793f01b59207519cd58c8ddd381768032d7972b90ae4937da1f059e57b897b3bd5d7644ff
languageName: node
linkType: hard
@ -26992,6 +26992,15 @@ __metadata:
languageName: node
linkType: hard
"scheduler@npm:0.24.0-canary-e5205658f-20230913":
version: 0.24.0-canary-e5205658f-20230913
resolution: "scheduler@npm:0.24.0-canary-e5205658f-20230913"
dependencies:
loose-envify: ^1.1.0
checksum: 6c95ebfe834cc515366cb7bacf83f464e01eec2387d43665d5ce2af1eb6ff0c3ff3d92d9c4f2720d79380b1f5420c3d89a756d632bc4310ead045bf379e321c7
languageName: node
linkType: hard
"scheduler@npm:^0.17.0":
version: 0.17.0
resolution: "scheduler@npm:0.17.0"
@ -27002,15 +27011,6 @@ __metadata:
languageName: node
linkType: hard
"scheduler@npm:^0.23.0":
version: 0.23.0
resolution: "scheduler@npm:0.23.0"
dependencies:
loose-envify: ^1.1.0
checksum: d79192eeaa12abef860c195ea45d37cbf2bbf5f66e3c4dcd16f54a7da53b17788a70d109ee3d3dde1a0fd50e6a8fc171f4300356c5aee4fc0171de526bf35f8a
languageName: node
linkType: hard
"scripts@workspace:site/scripts/NAFAndGuichetData":
version: 0.0.0-use.local
resolution: "scripts@workspace:site/scripts/NAFAndGuichetData"
@ -27397,7 +27397,7 @@ __metadata:
"@storybook/react-vite": ^7.0.5
"@storybook/testing-library": ^0.1.0
"@types/history": ^5.0.0
"@types/react": ^18.2.21
"@types/react": ^18.2.18
"@types/react-dom": ^18.2.7
"@types/react-instantsearch-dom": ^6.12.3
"@types/react-redux": ^7.1.25
@ -27423,10 +27423,10 @@ __metadata:
netlify-cli: ^15.11.0
publicodes: ^1.0.0-beta.73
publicodes-react: ^1.0.0-beta.73
react: ^18.2.0
react: 18.3.0-canary-e5205658f-20230913
react-aria: ^3.24.0
react-day-picker: ^8.7.1
react-dom: ^18.2.0
react-dom: 18.3.0-canary-e5205658f-20230913
react-easy-emoji: ^1.8.1
react-flip-move: ^3.0.5
react-helmet-async: ^1.3.0
@ -27434,7 +27434,7 @@ __metadata:
react-instantsearch: ^6.38.1
react-instantsearch-dom: ^6.38.1
react-redux: ^8.0.5
react-router-dom: ^6.14.2
react-router-dom: ^6.15.0
react-signature-pad-wrapper: ^3.3.1
react-spring: ^9.5.5
react-stately: ^3.22.0
@ -30260,9 +30260,9 @@ __metadata:
linkType: hard
"whatwg-fetch@npm:^3.6.2":
version: 3.6.2
resolution: "whatwg-fetch@npm:3.6.2"
checksum: ee976b7249e7791edb0d0a62cd806b29006ad7ec3a3d89145921ad8c00a3a67e4be8f3fb3ec6bc7b58498724fd568d11aeeeea1f7827e7e1e5eae6c8a275afed
version: 3.6.19
resolution: "whatwg-fetch@npm:3.6.19"
checksum: 2896bc9ca867ea514392c73e2a272f65d5c4916248fe0837a9df5b1b92f247047bc76cf7c29c28a01ac6c5fb4314021d2718958c8a08292a96d56f72b2f56806
languageName: node
linkType: hard