meno-core 1.0.53 → 1.1.0
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/.claude/settings.local.json +1 -3
- package/bin/cli.ts +66 -261
- package/build-astro.ts +150 -128
- package/dist/bin/cli.js +42 -185
- package/dist/bin/cli.js.map +2 -2
- package/dist/chunks/chunk-2AR55GYH.js +42 -0
- package/dist/chunks/chunk-2AR55GYH.js.map +7 -0
- package/dist/chunks/{chunk-JGWFTO6P.js → chunk-2IIQK7T3.js} +2624 -1389
- package/dist/chunks/chunk-2IIQK7T3.js.map +7 -0
- package/dist/chunks/chunk-4ZRU52J2.js +169 -0
- package/dist/chunks/chunk-4ZRU52J2.js.map +7 -0
- package/dist/chunks/{chunk-UB44F4Z2.js → chunk-5X4HCN7J.js} +5 -7
- package/dist/chunks/chunk-5X4HCN7J.js.map +7 -0
- package/dist/chunks/{chunk-WQFG7PAH.js → chunk-7CCVOL5M.js} +15 -19
- package/dist/chunks/chunk-7CCVOL5M.js.map +7 -0
- package/dist/chunks/{chunk-YBLHKYFF.js → chunk-AR7LHIFY.js} +8 -2
- package/dist/chunks/chunk-AR7LHIFY.js.map +7 -0
- package/dist/chunks/{chunk-O3NAGJP4.js → chunk-NUP7H7D3.js} +135 -41
- package/dist/chunks/chunk-NUP7H7D3.js.map +7 -0
- package/dist/chunks/{chunk-X754AHS5.js → chunk-QWTQZHG3.js} +194 -90
- package/dist/chunks/chunk-QWTQZHG3.js.map +7 -0
- package/dist/chunks/{chunk-H3GJ4H2U.js → chunk-XTKNX4FW.js} +216 -87
- package/dist/chunks/chunk-XTKNX4FW.js.map +7 -0
- package/dist/chunks/{fs-JGINUXGL.js → fs-G3ANWEFP.js} +2 -2
- package/dist/lib/client/index.js +1003 -475
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +14393 -6371
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +342 -101
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/shared/richtext/index.js +1 -1
- package/dist/lib/test-utils/index.js +64 -80
- package/dist/lib/test-utils/index.js.map +2 -2
- package/entries/client-router.tsx +10 -8
- package/lib/client/ClientInitializer.ts +8 -8
- package/lib/client/ErrorBoundary.test.tsx +246 -186
- package/lib/client/ErrorBoundary.tsx +183 -132
- package/lib/client/componentRegistry.test.ts +96 -108
- package/lib/client/componentRegistry.ts +1 -3
- package/lib/client/contexts/ThemeContext.tsx +3 -2
- package/lib/client/core/ComponentBuilder.test.ts +663 -560
- package/lib/client/core/ComponentBuilder.ts +427 -155
- package/lib/client/core/ComponentRenderer.test.tsx +2 -4
- package/lib/client/core/ComponentRenderer.tsx +46 -33
- package/lib/client/core/builders/embedBuilder.ts +242 -54
- package/lib/client/core/builders/linkBuilder.ts +72 -45
- package/lib/client/core/builders/linkNodeBuilder.ts +79 -54
- package/lib/client/core/builders/listBuilder.ts +155 -89
- package/lib/client/core/builders/localeListBuilder.ts +97 -62
- package/lib/client/core/builders/types.ts +5 -5
- package/lib/client/core/cmsTemplateProcessor.ts +8 -9
- package/lib/client/elementRegistry.ts +3 -3
- package/lib/client/fontFamiliesService.test.ts +2 -10
- package/lib/client/fontFamiliesService.ts +3 -3
- package/lib/client/hmr/HMRManager.tsx +47 -30
- package/lib/client/hmrCssReload.ts +49 -14
- package/lib/client/hmrWebSocket.ts +14 -19
- package/lib/client/hooks/useColorVariables.test.ts +21 -21
- package/lib/client/hooks/useColorVariables.ts +23 -18
- package/lib/client/hooks/usePropertyAutocomplete.ts +4 -6
- package/lib/client/hooks/useVariables.ts +6 -4
- package/lib/client/hydration/HydrationUtils.test.ts +24 -25
- package/lib/client/hydration/HydrationUtils.ts +3 -4
- package/lib/client/i18nConfigService.test.ts +2 -7
- package/lib/client/i18nConfigService.ts +2 -2
- package/lib/client/meno-filter/MenoFilter.test.ts +47 -49
- package/lib/client/meno-filter/MenoFilter.ts +14 -15
- package/lib/client/meno-filter/bindings.ts +18 -43
- package/lib/client/meno-filter/init.ts +4 -4
- package/lib/client/meno-filter/renderer.ts +30 -32
- package/lib/client/meno-filter/script.generated.ts +1 -3
- package/lib/client/meno-filter/ui.ts +4 -6
- package/lib/client/meno-filter/updates.ts +18 -23
- package/lib/client/meno-filter/utils.ts +1 -1
- package/lib/client/navigation.test.ts +174 -178
- package/lib/client/navigation.ts +0 -1
- package/lib/client/responsiveStyleResolver.knownTokens.test.ts +71 -0
- package/lib/client/responsiveStyleResolver.test.ts +234 -232
- package/lib/client/responsiveStyleResolver.ts +74 -20
- package/lib/client/routing/RouteLoader.test.ts +24 -25
- package/lib/client/routing/RouteLoader.ts +27 -35
- package/lib/client/routing/Router.tsx +210 -153
- package/lib/client/scripts/ScriptExecutor.test.ts +157 -158
- package/lib/client/scripts/ScriptExecutor.ts +18 -36
- package/lib/client/services/PrefetchService.test.ts +3 -3
- package/lib/client/services/PrefetchService.ts +23 -29
- package/lib/client/styleProcessor.test.ts +9 -9
- package/lib/client/styleProcessor.ts +24 -15
- package/lib/client/styles/StyleInjector.test.ts +122 -115
- package/lib/client/styles/StyleInjector.ts +10 -7
- package/lib/client/styles/UtilityClassCollector.ts +62 -28
- package/lib/client/styles/cspNonce.test.ts +2 -5
- package/lib/client/templateEngine.test.ts +554 -488
- package/lib/client/templateEngine.ts +203 -73
- package/lib/client/theme.test.ts +1 -1
- package/lib/client/theme.ts +0 -1
- package/lib/client/utils/toast.ts +0 -1
- package/lib/server/__integration__/api-routes.test.ts +8 -4
- package/lib/server/__integration__/cms-integration.test.ts +1 -4
- package/lib/server/__integration__/server-lifecycle.test.ts +4 -5
- package/lib/server/__integration__/ssr-rendering.test.ts +47 -37
- package/lib/server/__integration__/static-assets.test.ts +3 -3
- package/lib/server/__integration__/test-helpers.ts +87 -74
- package/lib/server/ab/generateFunctions.ts +12 -10
- package/lib/server/astro/cmsPageEmitter.ts +26 -42
- package/lib/server/astro/componentEmitter.ts +35 -43
- package/lib/server/astro/cssCollector.ts +10 -26
- package/lib/server/astro/nodeToAstro.test.ts +1754 -34
- package/lib/server/astro/nodeToAstro.ts +232 -216
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +115 -119
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +11 -18
- package/lib/server/astro/pageEmitter.ts +9 -13
- package/lib/server/astro/tailwindMapper.test.ts +10 -37
- package/lib/server/astro/tailwindMapper.ts +33 -40
- package/lib/server/astro/templateTransformer.ts +17 -20
- package/lib/server/createServer.ts +7 -19
- package/lib/server/cssGenerator.test.ts +35 -44
- package/lib/server/cssGenerator.ts +8 -19
- package/lib/server/fileWatcher.test.ts +164 -13
- package/lib/server/fileWatcher.ts +209 -124
- package/lib/server/index.ts +14 -17
- package/lib/server/jsonLoader.test.ts +39 -2
- package/lib/server/jsonLoader.ts +39 -34
- package/lib/server/middleware/cors.test.ts +21 -21
- package/lib/server/middleware/cors.ts +28 -4
- package/lib/server/middleware/errorHandler.test.ts +7 -5
- package/lib/server/middleware/errorHandler.ts +3 -8
- package/lib/server/middleware/index.ts +0 -1
- package/lib/server/middleware/logger.test.ts +10 -8
- package/lib/server/middleware/logger.ts +17 -29
- package/lib/server/migrateTemplates.ts +3 -3
- package/lib/server/pageCache.test.ts +76 -77
- package/lib/server/pageCache.ts +0 -1
- package/lib/server/projectContext.ts +5 -4
- package/lib/server/providers/fileSystemCMSProvider.test.ts +142 -99
- package/lib/server/providers/fileSystemCMSProvider.ts +62 -38
- package/lib/server/routes/api/cms.test.ts +26 -14
- package/lib/server/routes/api/cms.ts +9 -14
- package/lib/server/routes/api/colors.test.ts +2 -2
- package/lib/server/routes/api/components.ts +31 -35
- package/lib/server/routes/api/config.ts +3 -4
- package/lib/server/routes/api/core-routes.ts +49 -76
- package/lib/server/routes/api/enums.test.ts +2 -2
- package/lib/server/routes/api/functions.ts +9 -17
- package/lib/server/routes/api/index.ts +3 -6
- package/lib/server/routes/api/pages.ts +8 -11
- package/lib/server/routes/api/shared.test.ts +1 -1
- package/lib/server/routes/api/shared.ts +12 -9
- package/lib/server/routes/api/variables.test.ts +3 -5
- package/lib/server/routes/api/variables.ts +1 -1
- package/lib/server/routes/index.ts +25 -28
- package/lib/server/routes/pages.ts +35 -31
- package/lib/server/routes/static.ts +17 -6
- package/lib/server/runtime/bundler.ts +98 -61
- package/lib/server/runtime/fs.ts +16 -26
- package/lib/server/runtime/httpServer.ts +8 -12
- package/lib/server/services/ColorService.test.ts +3 -3
- package/lib/server/services/ColorService.ts +37 -29
- package/lib/server/services/EnumService.test.ts +4 -8
- package/lib/server/services/EnumService.ts +11 -3
- package/lib/server/services/VariableService.test.ts +8 -12
- package/lib/server/services/VariableService.ts +11 -3
- package/lib/server/services/cmsService.test.ts +158 -120
- package/lib/server/services/cmsService.ts +36 -146
- package/lib/server/services/componentService.test.ts +125 -45
- package/lib/server/services/componentService.ts +70 -46
- package/lib/server/services/configService.test.ts +22 -45
- package/lib/server/services/configService.ts +50 -27
- package/lib/server/services/fileWatcherService.ts +42 -8
- package/lib/server/services/index.ts +0 -1
- package/lib/server/services/pageService.test.ts +8 -10
- package/lib/server/services/pageService.ts +32 -18
- package/lib/server/ssr/attributeBuilder.ts +18 -8
- package/lib/server/ssr/buildErrorOverlay.ts +11 -12
- package/lib/server/ssr/clientDataInjector.ts +7 -21
- package/lib/server/ssr/cmsSSRProcessor.ts +4 -8
- package/lib/server/ssr/cssCollector.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +1 -1
- package/lib/server/ssr/errorOverlay.ts +3 -9
- package/lib/server/ssr/htmlGenerator.nonce.test.ts +9 -14
- package/lib/server/ssr/htmlGenerator.test.ts +120 -44
- package/lib/server/ssr/htmlGenerator.ts +126 -91
- package/lib/server/ssr/imageMetadata.test.ts +3 -1
- package/lib/server/ssr/imageMetadata.ts +27 -21
- package/lib/server/ssr/jsCollector.test.ts +8 -18
- package/lib/server/ssr/jsCollector.ts +3 -8
- package/lib/server/ssr/liveReloadIntegration.test.ts +76 -36
- package/lib/server/ssr/metaTagGenerator.ts +2 -2
- package/lib/server/ssr/ssrRenderer.branches.test.ts +1103 -0
- package/lib/server/ssr/ssrRenderer.test.ts +316 -256
- package/lib/server/ssr/ssrRenderer.ts +795 -243
- package/lib/server/ssrRenderer.test.ts +1048 -954
- package/lib/server/utils/jsonLineMapper.test.ts +28 -28
- package/lib/server/utils/jsonLineMapper.ts +7 -7
- package/lib/server/validateStyleCoverage.ts +18 -21
- package/lib/server/websocketManager.ts +28 -19
- package/lib/shared/attributeNodeUtils.test.ts +15 -15
- package/lib/shared/attributeNodeUtils.ts +5 -12
- package/lib/shared/breakpoints.test.ts +5 -5
- package/lib/shared/breakpoints.ts +4 -11
- package/lib/shared/cmsQuery.test.ts +81 -0
- package/lib/shared/cmsQuery.ts +92 -0
- package/lib/shared/cmsQueryParser.test.ts +50 -42
- package/lib/shared/cmsQueryParser.ts +60 -38
- package/lib/shared/colorConversions.ts +9 -9
- package/lib/shared/colorVariableUtils.test.ts +27 -5
- package/lib/shared/colorVariableUtils.ts +21 -12
- package/lib/shared/componentRefs.ts +1 -5
- package/lib/shared/constants.test.ts +3 -3
- package/lib/shared/constants.ts +8 -8
- package/lib/shared/cssGeneration.test.ts +285 -144
- package/lib/shared/cssGeneration.ts +217 -531
- package/lib/shared/cssNamedColors.ts +152 -30
- package/lib/shared/cssProperties.test.ts +69 -70
- package/lib/shared/cssProperties.ts +298 -114
- package/lib/shared/elementClassName.test.ts +109 -109
- package/lib/shared/elementClassName.ts +3 -3
- package/lib/shared/elementUtils.ts +12 -16
- package/lib/shared/errorLogger.test.ts +12 -12
- package/lib/shared/errorLogger.ts +2 -10
- package/lib/shared/errors.test.ts +2 -13
- package/lib/shared/errors.ts +2 -8
- package/lib/shared/expressionEvaluator.test.ts +145 -0
- package/lib/shared/expressionEvaluator.ts +52 -24
- package/lib/shared/fontLoader.test.ts +32 -18
- package/lib/shared/fontLoader.ts +2 -2
- package/lib/shared/friendlyError.ts +2 -3
- package/lib/shared/gradientUtils.test.ts +3 -7
- package/lib/shared/gradientUtils.ts +17 -14
- package/lib/shared/hrefRefs.test.ts +2 -2
- package/lib/shared/hrefRefs.ts +2 -10
- package/lib/shared/i18n.test.ts +4 -4
- package/lib/shared/i18n.ts +18 -38
- package/lib/shared/index.ts +27 -0
- package/lib/shared/inlineSvgStyleRules.ts +14 -13
- package/lib/shared/interactiveStyleMappings.test.ts +32 -54
- package/lib/shared/interactiveStyleMappings.ts +11 -24
- package/lib/shared/interactiveStyles.test.ts +165 -188
- package/lib/shared/interfaces/contentProvider.ts +1 -1
- package/lib/shared/itemTemplateUtils.test.ts +11 -17
- package/lib/shared/itemTemplateUtils.ts +27 -39
- package/lib/shared/jsonRepair.ts +16 -10
- package/lib/shared/libraryLoader.test.ts +21 -55
- package/lib/shared/libraryLoader.ts +7 -22
- package/lib/shared/linkUtils.ts +1 -1
- package/lib/shared/logger.test.ts +66 -0
- package/lib/shared/logger.ts +94 -0
- package/lib/shared/markdown.ts +25 -0
- package/lib/shared/netlifyLocale404.test.ts +179 -0
- package/lib/shared/netlifyLocale404.ts +110 -0
- package/lib/shared/nodeUtils.test.ts +28 -16
- package/lib/shared/nodeUtils.ts +76 -23
- package/lib/shared/pathArrayUtils.test.ts +1 -2
- package/lib/shared/pathArrayUtils.ts +1 -1
- package/lib/shared/pathSecurity.ts +2 -2
- package/lib/shared/pathUtils.test.ts +4 -6
- package/lib/shared/pathUtils.ts +42 -49
- package/lib/shared/paths/Path.test.ts +2 -2
- package/lib/shared/paths/Path.ts +0 -1
- package/lib/shared/paths/PathConverter.test.ts +1 -1
- package/lib/shared/paths/PathConverter.ts +17 -20
- package/lib/shared/paths/PathUtils.ts +13 -13
- package/lib/shared/paths/PathValidator.test.ts +2 -15
- package/lib/shared/paths/PathValidator.ts +12 -10
- package/lib/shared/paths/index.ts +1 -2
- package/lib/shared/permissions.test.ts +168 -0
- package/lib/shared/permissions.ts +162 -0
- package/lib/shared/propResolver.test.ts +240 -244
- package/lib/shared/propResolver.ts +15 -26
- package/lib/shared/pxToRem.test.ts +7 -6
- package/lib/shared/pxToRem.ts +2 -5
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +12 -6
- package/lib/shared/registry/ClientRegistry.ts +0 -2
- package/lib/shared/registry/ComponentRegistry.test.ts +46 -32
- package/lib/shared/registry/ComponentRegistry.ts +12 -9
- package/lib/shared/registry/NodeTypeDefinition.ts +16 -9
- package/lib/shared/registry/RegistryManager.ts +1 -2
- package/lib/shared/registry/SSRRegistry.ts +0 -2
- package/lib/shared/registry/createNodeType.ts +7 -9
- package/lib/shared/registry/defineNodeType.ts +2 -6
- package/lib/shared/registry/fieldPresets.ts +3 -1
- package/lib/shared/registry/index.ts +0 -1
- package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +14 -15
- package/lib/shared/registry/nodeTypes/CustomNodeType.ts +103 -0
- package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +18 -11
- package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +48 -19
- package/lib/shared/registry/nodeTypes/IslandNodeType.ts +129 -0
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +21 -19
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +117 -77
- package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +27 -21
- package/lib/shared/registry/nodeTypes/MarkdownNodeType.ts +78 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +12 -8
- package/lib/shared/registry/nodeTypes/index.ts +26 -3
- package/lib/shared/responsiveScaling.test.ts +16 -34
- package/lib/shared/responsiveScaling.ts +61 -42
- package/lib/shared/responsiveStyleUtils.test.ts +0 -1
- package/lib/shared/responsiveStyleUtils.ts +11 -13
- package/lib/shared/richtext/htmlToTiptap.test.ts +139 -130
- package/lib/shared/richtext/htmlToTiptap.ts +2 -4
- package/lib/shared/richtext/tiptapToHtml.test.ts +5 -6
- package/lib/shared/richtext/types.ts +1 -8
- package/lib/shared/slugTranslator.test.ts +13 -13
- package/lib/shared/slugTranslator.ts +12 -16
- package/lib/shared/slugify.ts +9 -15
- package/lib/shared/styleNodeUtils.test.ts +8 -8
- package/lib/shared/styleNodeUtils.ts +18 -20
- package/lib/shared/styleUtils.test.ts +87 -61
- package/lib/shared/styleUtils.ts +5 -6
- package/lib/shared/themeDefaults.test.ts +11 -14
- package/lib/shared/themeDefaults.ts +3 -4
- package/lib/shared/tree/PathBuilder.test.ts +63 -145
- package/lib/shared/tree/PathBuilder.ts +59 -47
- package/lib/shared/treePathUtils.test.ts +51 -12
- package/lib/shared/treePathUtils.ts +91 -60
- package/lib/shared/types/api.ts +105 -8
- package/lib/shared/types/cms.test.ts +34 -0
- package/lib/shared/types/cms.ts +90 -34
- package/lib/shared/types/comment.ts +49 -16
- package/lib/shared/types/components.ts +51 -25
- package/lib/shared/types/errors.test.ts +1 -6
- package/lib/shared/types/errors.ts +3 -7
- package/lib/shared/types/experiments.ts +28 -28
- package/lib/shared/types/index.ts +25 -2
- package/lib/shared/types/permissions.ts +139 -0
- package/lib/shared/types/styles.ts +0 -1
- package/lib/shared/types/variables.test.ts +4 -13
- package/lib/shared/types/variables.ts +51 -30
- package/lib/shared/types.ts +1 -2
- package/lib/shared/utilityClassConfig.ts +751 -321
- package/lib/shared/utilityClassMapper.durability.test.ts +117 -0
- package/lib/shared/utilityClassMapper.test.ts +433 -95
- package/lib/shared/utilityClassMapper.ts +322 -249
- package/lib/shared/utilityClassNames.test.ts +38 -0
- package/lib/shared/utilityClassNames.ts +458 -0
- package/lib/shared/utils.test.ts +2 -10
- package/lib/shared/utils.ts +19 -10
- package/lib/shared/validation/cmsValidators.ts +2 -1
- package/lib/shared/validation/commentValidators.test.ts +53 -0
- package/lib/shared/validation/commentValidators.ts +12 -1
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/validation/permissionsValidators.test.ts +49 -0
- package/lib/shared/validation/permissionsValidators.ts +49 -0
- package/lib/shared/validation/propValidator.test.ts +19 -21
- package/lib/shared/validation/propValidator.ts +13 -18
- package/lib/shared/validation/schemas.test.ts +64 -32
- package/lib/shared/validation/schemas.ts +616 -345
- package/lib/shared/validation/validators.test.ts +3 -8
- package/lib/shared/validation/validators.ts +89 -68
- package/lib/shared/viewportUnits.integration.test.ts +14 -10
- package/lib/shared/viewportUnits.test.ts +24 -23
- package/lib/shared/viewportUnits.ts +12 -5
- package/lib/test-utils/dom-setup.ts +1 -1
- package/lib/test-utils/factories/ConsoleMockFactory.ts +10 -14
- package/lib/test-utils/factories/DomMockFactory.ts +17 -23
- package/lib/test-utils/factories/EventMockFactory.ts +7 -13
- package/lib/test-utils/factories/FetchMockFactory.ts +39 -57
- package/lib/test-utils/factories/ServerMockFactory.ts +6 -10
- package/lib/test-utils/factories/StoreMockFactory.ts +18 -29
- package/lib/test-utils/fixtures.ts +45 -45
- package/lib/test-utils/helpers/asyncHelpers.test.ts +15 -18
- package/lib/test-utils/helpers/asyncHelpers.ts +11 -20
- package/lib/test-utils/helpers.ts +1 -5
- package/lib/test-utils/index.ts +0 -4
- package/lib/test-utils/mockFactories.ts +13 -19
- package/lib/test-utils/mocks.ts +6 -4
- package/package.json +3 -5
- package/scripts/build-for-publish.mjs +2 -4
- package/scripts/build-meno-filter.ts +4 -7
- package/tsconfig.json +1 -0
- package/vite.config.ts +4 -4
- package/build-next.ts +0 -1361
- package/build-static.test.ts +0 -424
- package/build-static.ts +0 -1074
- package/dist/build-static.js +0 -36
- package/dist/chunks/chunk-2MHDV5BF.js +0 -251
- package/dist/chunks/chunk-2MHDV5BF.js.map +0 -7
- package/dist/chunks/chunk-AZQYF6KE.js +0 -559
- package/dist/chunks/chunk-AZQYF6KE.js.map +0 -7
- package/dist/chunks/chunk-GZHGVVW3.js +0 -322
- package/dist/chunks/chunk-GZHGVVW3.js.map +0 -7
- package/dist/chunks/chunk-H3GJ4H2U.js.map +0 -7
- package/dist/chunks/chunk-I7YIGZXT.js +0 -467
- package/dist/chunks/chunk-I7YIGZXT.js.map +0 -7
- package/dist/chunks/chunk-IGYR22T6.js +0 -6192
- package/dist/chunks/chunk-IGYR22T6.js.map +0 -7
- package/dist/chunks/chunk-JGP5A3Y5.js +0 -1136
- package/dist/chunks/chunk-JGP5A3Y5.js.map +0 -7
- package/dist/chunks/chunk-JGWFTO6P.js.map +0 -7
- package/dist/chunks/chunk-O3NAGJP4.js.map +0 -7
- package/dist/chunks/chunk-QB2LNO4W.js +0 -77
- package/dist/chunks/chunk-QB2LNO4W.js.map +0 -7
- package/dist/chunks/chunk-R6XHAFBF.js +0 -3821
- package/dist/chunks/chunk-R6XHAFBF.js.map +0 -7
- package/dist/chunks/chunk-UB44F4Z2.js.map +0 -7
- package/dist/chunks/chunk-WQFG7PAH.js.map +0 -7
- package/dist/chunks/chunk-X754AHS5.js.map +0 -7
- package/dist/chunks/chunk-YBLHKYFF.js.map +0 -7
- package/dist/chunks/configService-R3OGU2UD.js +0 -13
- package/dist/chunks/configService-R3OGU2UD.js.map +0 -7
- package/dist/chunks/constants-STK2YBIW.js +0 -46
- package/dist/chunks/constants-STK2YBIW.js.map +0 -7
- package/dist/chunks/fs-JGINUXGL.js.map +0 -7
- package/dist/entries/server-router.js +0 -67
- package/dist/entries/server-router.js.map +0 -7
- package/entries/server-router.tsx +0 -81
- package/lib/server/providers/fileSystemPageProvider.ts +0 -160
- package/lib/server/webflow/buildWebflow.ts +0 -635
- package/lib/server/webflow/index.ts +0 -23
- package/lib/server/webflow/nodeToWebflow.test.ts +0 -3170
- package/lib/server/webflow/nodeToWebflow.ts +0 -2435
- package/lib/server/webflow/styleMapper.test.ts +0 -389
- package/lib/server/webflow/styleMapper.ts +0 -695
- package/lib/server/webflow/templateWrapper.ts +0 -49
- package/lib/server/webflow/types.ts +0 -396
- /package/dist/{build-static.js.map → chunks/fs-G3ANWEFP.js.map} +0 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characterization tests for ssrRenderer.ts — uncovered branch coverage.
|
|
3
|
+
*
|
|
4
|
+
* Complements ssrRenderer.test.ts by pinning current behavior of the branches
|
|
5
|
+
* that the main suite does not reach: item-template `if` conditions, CMS filter
|
|
6
|
+
* template resolution, rich-text component expansion, nested cms-list
|
|
7
|
+
* placeholders, draft filtering on ID-based queries, prop-source list edge
|
|
8
|
+
* cases, CSS-variable interactive style mappings, template-mode raw HTML,
|
|
9
|
+
* embed html mappings, link-object hrefs, responsive props.style on component
|
|
10
|
+
* instances, raw-text elements (<style>/<script>), locale-list interactive
|
|
11
|
+
* styles, component render error fallbacks, and CMS templates in og:image.
|
|
12
|
+
*
|
|
13
|
+
* These are CHARACTERIZATION tests: they pin behavior as-is ahead of a planned
|
|
14
|
+
* refactor. Suspicious behaviors are flagged with `// NOTE: possible bug —`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
// bun's mock.module() patches are PROCESS-GLOBAL and persist across test
|
|
20
|
+
// files in the same run. Snapshot the real module exports here (imports are
|
|
21
|
+
// evaluated before this module body runs, so these capture the originals)
|
|
22
|
+
// and restore them in afterAll so later test files see the real
|
|
23
|
+
// implementations. (Same pattern as ssrRenderer.test.ts.)
|
|
24
|
+
import * as realJsonLoader from '../jsonLoader';
|
|
25
|
+
import * as realImageMetadata from './imageMetadata';
|
|
26
|
+
import * as realConfigServiceModule from '../services/configService';
|
|
27
|
+
import * as realValidateStyleCoverage from '../validateStyleCoverage';
|
|
28
|
+
|
|
29
|
+
const REAL_JSON_LOADER = { ...realJsonLoader };
|
|
30
|
+
const REAL_IMAGE_METADATA = { ...realImageMetadata };
|
|
31
|
+
const REAL_CONFIG_SERVICE_MODULE = { ...realConfigServiceModule };
|
|
32
|
+
const REAL_VALIDATE_STYLE_COVERAGE = { ...realValidateStyleCoverage };
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
mock.module('../jsonLoader', () => REAL_JSON_LOADER);
|
|
36
|
+
mock.module('./imageMetadata', () => REAL_IMAGE_METADATA);
|
|
37
|
+
mock.module('../services/configService', () => REAL_CONFIG_SERVICE_MODULE);
|
|
38
|
+
mock.module('../validateStyleCoverage', () => REAL_VALIDATE_STYLE_COVERAGE);
|
|
39
|
+
// errorLogger handler state is process-global too — don't leak it.
|
|
40
|
+
setErrorHandler(null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let mockImageMetadataMap = new Map();
|
|
44
|
+
let mockImageFormat: 'webp' | 'avif' = 'webp';
|
|
45
|
+
|
|
46
|
+
mock.module('../jsonLoader', () => ({
|
|
47
|
+
loadBreakpointConfig: async () => ({
|
|
48
|
+
breakpoints: [
|
|
49
|
+
{ name: 'mobile', maxWidth: 767 },
|
|
50
|
+
{ name: 'tablet', minWidth: 768, maxWidth: 1023 },
|
|
51
|
+
{ name: 'desktop', minWidth: 1024 },
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
54
|
+
loadI18nConfig: async () => ({
|
|
55
|
+
defaultLocale: 'en',
|
|
56
|
+
locales: [
|
|
57
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en', icon: '/flags/en.svg' },
|
|
58
|
+
{ code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr', icon: '/flags/fr.svg' },
|
|
59
|
+
{ code: 'de', name: 'German', nativeName: 'Deutsch', langTag: 'de' },
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
loadIconsConfig: async () => ({ favicon: '', appleTouchIcon: '' }),
|
|
63
|
+
loadPrefetchConfig: async () => ({ enabled: false }),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
mock.module('./imageMetadata', () => ({
|
|
67
|
+
buildImageMetadataMap: async () => mockImageMetadataMap,
|
|
68
|
+
DEFAULT_SIZES: '100vw',
|
|
69
|
+
ImageMetadataMap: Map,
|
|
70
|
+
rewriteRichTextImages: REAL_IMAGE_METADATA.rewriteRichTextImages,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
mock.module('../services/configService', () => ({
|
|
74
|
+
configService: {
|
|
75
|
+
getImageFormat: () => mockImageFormat,
|
|
76
|
+
getResponsiveScales: () => ({ enabled: false, baseReference: 16 }),
|
|
77
|
+
getSocial: () => ({}),
|
|
78
|
+
load: async () => {},
|
|
79
|
+
isLoaded: () => true,
|
|
80
|
+
reset: () => {},
|
|
81
|
+
},
|
|
82
|
+
ConfigService: class {},
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
mock.module('../validateStyleCoverage', () => ({
|
|
86
|
+
validateStyleCoverage: () => {},
|
|
87
|
+
printMissingStyleWarnings: () => {},
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
import { buildComponentHTML, renderPageSSR } from './ssrRenderer';
|
|
91
|
+
import { RAW_HTML_PREFIX } from '../../shared/constants';
|
|
92
|
+
import { setErrorHandler } from '../../shared/errorLogger';
|
|
93
|
+
import type { ComponentDefinition, JSONPage } from '../../shared/types';
|
|
94
|
+
|
|
95
|
+
/** Capture logRuntimeError entries via the errorLogger's telemetry hook. */
|
|
96
|
+
interface CapturedLogEntry {
|
|
97
|
+
location: string;
|
|
98
|
+
context?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
function captureErrorLog(): CapturedLogEntry[] {
|
|
101
|
+
const entries: CapturedLogEntry[] = [];
|
|
102
|
+
setErrorHandler((entry) => entries.push({ location: entry.location, context: entry.context }));
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
interface RenderOpts {
|
|
111
|
+
globalComponents?: Record<string, ComponentDefinition>;
|
|
112
|
+
pageComponents?: Record<string, ComponentDefinition>;
|
|
113
|
+
locale?: string;
|
|
114
|
+
i18nConfig?: any;
|
|
115
|
+
slugMappings?: any[];
|
|
116
|
+
pagePath?: string;
|
|
117
|
+
cmsContext?: { cms?: Record<string, unknown> };
|
|
118
|
+
cmsService?: any;
|
|
119
|
+
isProductionBuild?: boolean;
|
|
120
|
+
injectEditorAttrs?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function renderFull(node: any, opts?: RenderOpts) {
|
|
124
|
+
return await buildComponentHTML(
|
|
125
|
+
node,
|
|
126
|
+
opts?.globalComponents ?? {},
|
|
127
|
+
opts?.pageComponents ?? {},
|
|
128
|
+
opts?.locale,
|
|
129
|
+
opts?.i18nConfig,
|
|
130
|
+
opts?.slugMappings,
|
|
131
|
+
opts?.pagePath,
|
|
132
|
+
opts?.cmsContext,
|
|
133
|
+
opts?.cmsService,
|
|
134
|
+
opts?.isProductionBuild,
|
|
135
|
+
opts?.injectEditorAttrs,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function render(node: any, opts?: RenderOpts) {
|
|
140
|
+
return (await renderFull(node, opts)).html;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const I18N_EN_FR = {
|
|
144
|
+
defaultLocale: 'en',
|
|
145
|
+
locales: [
|
|
146
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en' },
|
|
147
|
+
{ code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr' },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// =========================================================================
|
|
152
|
+
// TEST SUITES
|
|
153
|
+
// =========================================================================
|
|
154
|
+
|
|
155
|
+
describe('ssrRenderer branches', () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
mockImageMetadataMap = new Map();
|
|
158
|
+
mockImageFormat = 'webp';
|
|
159
|
+
setErrorHandler(null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// 1. evaluateIfCondition — item-template string `if` inside a list
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
describe('if conditions with item templates', () => {
|
|
166
|
+
test('renders child when item-template if resolves truthy, skips when falsy', async () => {
|
|
167
|
+
const node = {
|
|
168
|
+
type: 'list',
|
|
169
|
+
sourceType: 'prop',
|
|
170
|
+
source: 'tags',
|
|
171
|
+
itemAs: 'tag',
|
|
172
|
+
children: [{ type: 'node', tag: 'span', if: '{{tag.visible}}', children: '{{tag.name}}' }],
|
|
173
|
+
};
|
|
174
|
+
// Prop-mode list at page level resolves its source from cmsContext.cms
|
|
175
|
+
const cmsContext = {
|
|
176
|
+
cms: {
|
|
177
|
+
tags: [
|
|
178
|
+
{ name: 'Shown', visible: true },
|
|
179
|
+
{ name: 'Hidden', visible: false },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const html = await render(node, { cmsContext });
|
|
184
|
+
expect(html).toContain('Shown');
|
|
185
|
+
expect(html).not.toContain('Hidden');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('item-template if resolving to 0 is falsy', async () => {
|
|
189
|
+
const node = {
|
|
190
|
+
type: 'list',
|
|
191
|
+
sourceType: 'prop',
|
|
192
|
+
source: 'tags',
|
|
193
|
+
itemAs: 'tag',
|
|
194
|
+
children: [{ type: 'node', tag: 'span', if: '{{tag.count}}', children: '{{tag.name}}' }],
|
|
195
|
+
};
|
|
196
|
+
const cmsContext = {
|
|
197
|
+
cms: {
|
|
198
|
+
tags: [
|
|
199
|
+
{ name: 'HasCount', count: 3 },
|
|
200
|
+
{ name: 'ZeroCount', count: 0 },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const html = await render(node, { cmsContext });
|
|
205
|
+
expect(html).toContain('HasCount');
|
|
206
|
+
expect(html).not.toContain('ZeroCount');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// -----------------------------------------------------------------------
|
|
211
|
+
// 2. CMS filter template resolution (resolveFilterTemplates / resolveFilterValue)
|
|
212
|
+
// -----------------------------------------------------------------------
|
|
213
|
+
describe('collection list filter templates', () => {
|
|
214
|
+
function makeCapturingCmsService(items: any[] = [{ _id: 'p1', title: 'Post One' }]) {
|
|
215
|
+
const queries: any[] = [];
|
|
216
|
+
return {
|
|
217
|
+
queries,
|
|
218
|
+
service: {
|
|
219
|
+
queryItems: async (query: any) => {
|
|
220
|
+
queries.push(query);
|
|
221
|
+
return items;
|
|
222
|
+
},
|
|
223
|
+
getItemsByIds: async () => items,
|
|
224
|
+
getSchema: () => undefined,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test('array filter: template values resolve against parent list scope, literals pass through', async () => {
|
|
230
|
+
const { queries, service } = makeCapturingCmsService();
|
|
231
|
+
const node = {
|
|
232
|
+
type: 'list',
|
|
233
|
+
sourceType: 'prop',
|
|
234
|
+
source: 'categories',
|
|
235
|
+
itemAs: 'category',
|
|
236
|
+
children: [
|
|
237
|
+
{
|
|
238
|
+
type: 'list',
|
|
239
|
+
sourceType: 'collection',
|
|
240
|
+
source: 'posts',
|
|
241
|
+
filter: [
|
|
242
|
+
{ field: 'category', value: '{{category.slug}}' },
|
|
243
|
+
{ field: 'status', value: 'published' },
|
|
244
|
+
],
|
|
245
|
+
children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
const cmsContext = { cms: { categories: [{ slug: 'tech' }] } };
|
|
250
|
+
const html = await render(node, { cmsContext, cmsService: service });
|
|
251
|
+
|
|
252
|
+
expect(html).toContain('Post One');
|
|
253
|
+
expect(queries.length).toBe(1);
|
|
254
|
+
expect(queries[0].filter).toEqual([
|
|
255
|
+
{ field: 'category', value: 'tech' },
|
|
256
|
+
{ field: 'status', value: 'published' },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('single condition object filter resolves its template value', async () => {
|
|
261
|
+
const { queries, service } = makeCapturingCmsService();
|
|
262
|
+
const node = {
|
|
263
|
+
type: 'list',
|
|
264
|
+
sourceType: 'prop',
|
|
265
|
+
source: 'categories',
|
|
266
|
+
itemAs: 'category',
|
|
267
|
+
children: [
|
|
268
|
+
{
|
|
269
|
+
type: 'list',
|
|
270
|
+
sourceType: 'collection',
|
|
271
|
+
source: 'posts',
|
|
272
|
+
filter: { field: 'category', value: '{{category.slug}}' },
|
|
273
|
+
children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
const cmsContext = { cms: { categories: [{ slug: 'design' }] } };
|
|
278
|
+
await render(node, { cmsContext, cmsService: service });
|
|
279
|
+
|
|
280
|
+
expect(queries[0].filter).toEqual({ field: 'category', value: 'design' });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('unresolvable template value drops the condition and logs a warning', async () => {
|
|
284
|
+
const { queries, service } = makeCapturingCmsService();
|
|
285
|
+
const logged = captureErrorLog();
|
|
286
|
+
const node = {
|
|
287
|
+
type: 'list',
|
|
288
|
+
sourceType: 'prop',
|
|
289
|
+
source: 'categories',
|
|
290
|
+
itemAs: 'category',
|
|
291
|
+
children: [
|
|
292
|
+
{
|
|
293
|
+
type: 'list',
|
|
294
|
+
sourceType: 'collection',
|
|
295
|
+
source: 'posts',
|
|
296
|
+
filter: [
|
|
297
|
+
{ field: 'category', value: '{{category.missing}}' },
|
|
298
|
+
{ field: 'status', value: 'published' },
|
|
299
|
+
],
|
|
300
|
+
children: ['{{post.title}}'],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
const cmsContext = { cms: { categories: [{ slug: 'tech' }] } };
|
|
305
|
+
await render(node, { cmsContext, cmsService: service });
|
|
306
|
+
|
|
307
|
+
// An unresolved filter template would be sent to the CMS query verbatim
|
|
308
|
+
// ('{{category.missing}}') and silently match nothing. Instead the
|
|
309
|
+
// condition is dropped from the query (resolvable conditions survive)…
|
|
310
|
+
expect(queries[0].filter).toEqual([{ field: 'status', value: 'published' }]);
|
|
311
|
+
// …and the drop is surfaced through the runtime error logger so users
|
|
312
|
+
// can see why the list's results changed.
|
|
313
|
+
expect(logged.length).toBe(1);
|
|
314
|
+
expect(logged[0]!.location).toBe('ssrRenderer.resolveFilterTemplates');
|
|
315
|
+
expect(logged[0]!.context).toMatchObject({
|
|
316
|
+
collection: 'posts',
|
|
317
|
+
field: 'category',
|
|
318
|
+
template: '{{category.missing}}',
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// -----------------------------------------------------------------------
|
|
324
|
+
// 3. expandRichTextComponents — component markers in raw HTML
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
describe('rich-text component expansion', () => {
|
|
327
|
+
const Icon: ComponentDefinition = {
|
|
328
|
+
component: {
|
|
329
|
+
structure: { type: 'node', tag: 'i', children: '{{name}}' },
|
|
330
|
+
interface: { name: { type: 'string', default: 'default-icon' } },
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
test('expands a known component marker, preserving surrounding HTML', async () => {
|
|
335
|
+
const raw =
|
|
336
|
+
RAW_HTML_PREFIX +
|
|
337
|
+
'<p>before</p>' +
|
|
338
|
+
'<div data-meno-component="BrIcon" data-meno-props="{"name":"star"}"></div>' +
|
|
339
|
+
'<p>after</p>';
|
|
340
|
+
const result = await renderFull(raw, { globalComponents: { BrIcon: Icon } });
|
|
341
|
+
expect(result.html).toContain('<p>before</p>');
|
|
342
|
+
expect(result.html).toContain('<p>after</p>');
|
|
343
|
+
// The marker div is replaced by the rendered component; the rendered
|
|
344
|
+
// root carries the regular component-instance stamp.
|
|
345
|
+
expect(result.html).toContain('<i data-meno-component="BrIcon" data-meno-component-instance="0">star</i>');
|
|
346
|
+
expect(result.html).not.toContain('data-meno-props');
|
|
347
|
+
// Raw→processed mapping collected for the Astro exporter
|
|
348
|
+
const rawSlice = raw.slice(RAW_HTML_PREFIX.length);
|
|
349
|
+
expect(result.processedRawHtmlCollector.get(rawSlice)).toBe(result.html);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('keeps marker verbatim for unknown components', async () => {
|
|
353
|
+
const marker = '<div data-meno-component="BrNotRegistered" data-meno-props=""></div>';
|
|
354
|
+
const html = await render(RAW_HTML_PREFIX + marker);
|
|
355
|
+
expect(html).toContain(marker);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('invalid props JSON falls back to default props and logs the parse failure', async () => {
|
|
359
|
+
const logged = captureErrorLog();
|
|
360
|
+
const raw = `${RAW_HTML_PREFIX}<div data-meno-component="BrIcon" data-meno-props="{not-json"></div>`;
|
|
361
|
+
const html = await render(raw, { globalComponents: { BrIcon: Icon } });
|
|
362
|
+
// Graceful fallback: the component still renders with interface defaults
|
|
363
|
+
// (a malformed marker must never crash the page)…
|
|
364
|
+
expect(html).toContain('default-icon');
|
|
365
|
+
// …but the parse failure is surfaced, including the component name.
|
|
366
|
+
expect(logged.length).toBe(1);
|
|
367
|
+
expect(logged[0]!.location).toBe('ssrRenderer.expandRichTextComponents');
|
|
368
|
+
expect(logged[0]!.context).toMatchObject({ component: 'BrIcon' });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('expands multiple markers in one raw HTML string', async () => {
|
|
372
|
+
const raw =
|
|
373
|
+
RAW_HTML_PREFIX +
|
|
374
|
+
'<div data-meno-component="BrIcon" data-meno-props="{"name":"one"}"></div>' +
|
|
375
|
+
'<span>mid</span>' +
|
|
376
|
+
'<div data-meno-component="BrIcon" data-meno-props="{"name":"two"}"></div>';
|
|
377
|
+
const html = await render(raw, { globalComponents: { BrIcon: Icon } });
|
|
378
|
+
expect(html).toContain('one');
|
|
379
|
+
expect(html).toContain('<span>mid</span>');
|
|
380
|
+
expect(html).toContain('two');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// -----------------------------------------------------------------------
|
|
385
|
+
// 4. Nested cms-list placeholder (emitTemplate + nested collection list)
|
|
386
|
+
// -----------------------------------------------------------------------
|
|
387
|
+
describe('nested collection list placeholder', () => {
|
|
388
|
+
test('nested collection list inside emitTemplate emits a hydration placeholder', async () => {
|
|
389
|
+
const cmsService = {
|
|
390
|
+
queryItems: async (query: any) => {
|
|
391
|
+
if (query.collection === 'posts') return [{ _id: 'p1', title: 'Post One' }];
|
|
392
|
+
if (query.collection === 'tags') return [{ _id: 't1', name: 'TagOne' }];
|
|
393
|
+
return [];
|
|
394
|
+
},
|
|
395
|
+
getItemsByIds: async () => [],
|
|
396
|
+
getSchema: () => undefined,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const node = {
|
|
400
|
+
type: 'list',
|
|
401
|
+
sourceType: 'collection',
|
|
402
|
+
source: 'posts',
|
|
403
|
+
emitTemplate: true,
|
|
404
|
+
children: [
|
|
405
|
+
{ type: 'node', tag: 'h2', children: '{{post.title}}' },
|
|
406
|
+
{
|
|
407
|
+
type: 'list',
|
|
408
|
+
sourceType: 'collection',
|
|
409
|
+
source: 'tags',
|
|
410
|
+
itemAs: 'tag',
|
|
411
|
+
limit: 5,
|
|
412
|
+
children: [{ type: 'node', tag: 'span', children: '{{tag.name}}' }],
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const result = await renderFull(node, { cmsService });
|
|
418
|
+
|
|
419
|
+
// Regular item render: both lists fully rendered
|
|
420
|
+
expect(result.html).toContain('Post One');
|
|
421
|
+
expect(result.html).toContain('TagOne');
|
|
422
|
+
|
|
423
|
+
// Template emission: nested list becomes a placeholder with config + inner template
|
|
424
|
+
expect(result.html).toContain('<template data-meno-item>');
|
|
425
|
+
expect(result.html).toContain('data-cms-list-nested="true"');
|
|
426
|
+
expect(result.html).toContain('data-collection="tags"');
|
|
427
|
+
expect(result.html).toContain('data-cms-config=');
|
|
428
|
+
expect(result.html).toContain('<template data-nested-template>');
|
|
429
|
+
// Inner template preserves {{tag.name}} placeholders for client hydration
|
|
430
|
+
expect(result.html).toContain('{{tag.name}}');
|
|
431
|
+
// Config JSON is escaped into the attribute
|
|
432
|
+
expect(result.html).toContain('"collection":"tags"');
|
|
433
|
+
expect(result.html).toContain('"limit":5');
|
|
434
|
+
|
|
435
|
+
// The nested collection is tracked for client-side data injection
|
|
436
|
+
expect(result.neededCollections.has('tags')).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// -----------------------------------------------------------------------
|
|
441
|
+
// 5. Draft filtering on ID-based collection queries
|
|
442
|
+
// -----------------------------------------------------------------------
|
|
443
|
+
describe('ID-based collection list draft filtering', () => {
|
|
444
|
+
test('filters out items that are draft for the active locale', async () => {
|
|
445
|
+
const cmsService = {
|
|
446
|
+
getItemsByIds: async () => [
|
|
447
|
+
{ _id: '1', title: 'Published Item' },
|
|
448
|
+
{ _id: '2', title: 'Draft Item', _draftLocales: ['en'] },
|
|
449
|
+
],
|
|
450
|
+
queryItems: async () => [],
|
|
451
|
+
getSchema: () => undefined,
|
|
452
|
+
};
|
|
453
|
+
const node = {
|
|
454
|
+
type: 'list',
|
|
455
|
+
sourceType: 'collection',
|
|
456
|
+
source: 'posts',
|
|
457
|
+
items: ['1', '2'],
|
|
458
|
+
children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
|
|
459
|
+
};
|
|
460
|
+
const html = await render(node, { cmsService, locale: 'en', i18nConfig: I18N_EN_FR });
|
|
461
|
+
expect(html).toContain('Published Item');
|
|
462
|
+
expect(html).not.toContain('Draft Item');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('keeps items that are draft only for other locales', async () => {
|
|
466
|
+
const cmsService = {
|
|
467
|
+
getItemsByIds: async () => [{ _id: '2', title: 'FR Draft', _draftLocales: ['fr'] }],
|
|
468
|
+
queryItems: async () => [],
|
|
469
|
+
getSchema: () => undefined,
|
|
470
|
+
};
|
|
471
|
+
const node = {
|
|
472
|
+
type: 'list',
|
|
473
|
+
sourceType: 'collection',
|
|
474
|
+
source: 'posts',
|
|
475
|
+
items: ['2'],
|
|
476
|
+
children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
|
|
477
|
+
};
|
|
478
|
+
const html = await render(node, { cmsService, locale: 'en', i18nConfig: I18N_EN_FR });
|
|
479
|
+
expect(html).toContain('FR Draft');
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// -----------------------------------------------------------------------
|
|
484
|
+
// 6. getPropItems — template-expression and legacy-collection sources
|
|
485
|
+
// -----------------------------------------------------------------------
|
|
486
|
+
describe('prop-source list resolution', () => {
|
|
487
|
+
test('nested list with {{parent.field}} source resolves from parent template context', async () => {
|
|
488
|
+
const node = {
|
|
489
|
+
type: 'list',
|
|
490
|
+
sourceType: 'prop',
|
|
491
|
+
source: 'sections',
|
|
492
|
+
itemAs: 'section',
|
|
493
|
+
children: [
|
|
494
|
+
{
|
|
495
|
+
type: 'list',
|
|
496
|
+
sourceType: 'prop',
|
|
497
|
+
source: '{{section.items}}',
|
|
498
|
+
children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
const cmsContext = { cms: { sections: [{ items: [{ label: 'X' }, { label: 'Y' }] }] } };
|
|
503
|
+
const html = await render(node, { cmsContext });
|
|
504
|
+
expect(html).toContain('X');
|
|
505
|
+
expect(html).toContain('Y');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('{{parent.field}} source resolving to a non-array renders nothing', async () => {
|
|
509
|
+
const node = {
|
|
510
|
+
type: 'list',
|
|
511
|
+
sourceType: 'prop',
|
|
512
|
+
source: 'sections',
|
|
513
|
+
itemAs: 'section',
|
|
514
|
+
children: [
|
|
515
|
+
{
|
|
516
|
+
type: 'list',
|
|
517
|
+
sourceType: 'prop',
|
|
518
|
+
source: '{{section.items}}',
|
|
519
|
+
children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
const cmsContext = { cms: { sections: [{ items: 'not-an-array' }] } };
|
|
524
|
+
const html = await render(node, { cmsContext });
|
|
525
|
+
expect(html).toBe('');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('bare source naming a non-array CMS field renders nothing', async () => {
|
|
529
|
+
const node = {
|
|
530
|
+
type: 'list',
|
|
531
|
+
sourceType: 'prop',
|
|
532
|
+
source: 'title',
|
|
533
|
+
children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
|
|
534
|
+
};
|
|
535
|
+
const cmsContext = { cms: { title: 'just a string' } };
|
|
536
|
+
const html = await render(node, { cmsContext });
|
|
537
|
+
expect(html).toBe('');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('legacy `collection` key with sourceType prop resolves from component props', async () => {
|
|
541
|
+
// processStructure pre-resolves bare `source` prop names, but the legacy
|
|
542
|
+
// `collection` key is untouched — exercising the componentResolvedProps
|
|
543
|
+
// lookup branch in getPropItems.
|
|
544
|
+
const Direct: ComponentDefinition = {
|
|
545
|
+
component: {
|
|
546
|
+
interface: { features: { type: 'list' as const, itemSchema: {}, default: [] } },
|
|
547
|
+
structure: {
|
|
548
|
+
type: 'list',
|
|
549
|
+
sourceType: 'prop',
|
|
550
|
+
collection: 'features',
|
|
551
|
+
itemAs: 'feature',
|
|
552
|
+
children: [{ type: 'node', tag: 'li', children: '{{feature.label}}' }],
|
|
553
|
+
} as any,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
const node = {
|
|
557
|
+
type: 'component',
|
|
558
|
+
component: 'BrLegacyList',
|
|
559
|
+
props: { features: [{ label: 'Fast' }, { label: 'Safe' }] },
|
|
560
|
+
};
|
|
561
|
+
const html = await render(node, { globalComponents: { BrLegacyList: Direct } });
|
|
562
|
+
expect(html).toContain('Fast');
|
|
563
|
+
expect(html).toContain('Safe');
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// -----------------------------------------------------------------------
|
|
568
|
+
// 7. Interactive style mappings → CSS variable inline style
|
|
569
|
+
// -----------------------------------------------------------------------
|
|
570
|
+
describe('interactive style mappings (CSS variables)', () => {
|
|
571
|
+
test('link node mapping resolves to inline --is-N variable from component props', async () => {
|
|
572
|
+
const Btn: ComponentDefinition = {
|
|
573
|
+
component: {
|
|
574
|
+
interface: { variant: { type: 'string', default: 'primary' } },
|
|
575
|
+
structure: {
|
|
576
|
+
type: 'link',
|
|
577
|
+
href: '/go',
|
|
578
|
+
interactiveStyles: [
|
|
579
|
+
{
|
|
580
|
+
postfix: ':hover',
|
|
581
|
+
style: {
|
|
582
|
+
color: { _mapping: true, prop: 'variant', values: { primary: '#0070f3', secondary: '#999999' } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
children: 'Go',
|
|
587
|
+
} as any,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
const node = { type: 'component', component: 'BrMappedLink', props: { variant: 'primary' } };
|
|
591
|
+
const result = await renderFull(node, { globalComponents: { BrMappedLink: Btn }, pagePath: '/test' });
|
|
592
|
+
|
|
593
|
+
// Inline style carries the resolved CSS variable
|
|
594
|
+
expect(result.html).toContain('style="--is-0: #0070f3"');
|
|
595
|
+
// The registered interactive style references the variable, not the mapping
|
|
596
|
+
const [styles] = [...result.interactiveStylesMap.values()];
|
|
597
|
+
expect(JSON.stringify(styles)).toContain('var(--is-0)');
|
|
598
|
+
expect(JSON.stringify(styles)).not.toContain('_mapping');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('mapping with prop value not in values map emits no CSS variable', async () => {
|
|
602
|
+
const Btn: ComponentDefinition = {
|
|
603
|
+
component: {
|
|
604
|
+
interface: { variant: { type: 'string', default: 'unknown-variant' } },
|
|
605
|
+
structure: {
|
|
606
|
+
type: 'link',
|
|
607
|
+
href: '/go',
|
|
608
|
+
interactiveStyles: [
|
|
609
|
+
{
|
|
610
|
+
postfix: ':hover',
|
|
611
|
+
style: { color: { _mapping: true, prop: 'variant', values: { primary: '#0070f3' } } },
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
children: 'Go',
|
|
615
|
+
} as any,
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
const node = { type: 'component', component: 'BrMappedLinkMiss', props: {} };
|
|
619
|
+
const result = await renderFull(node, { globalComponents: { BrMappedLinkMiss: Btn }, pagePath: '/test' });
|
|
620
|
+
expect(result.html).not.toContain('--is-0:');
|
|
621
|
+
// Style still registered with the var() reference even though it is unset
|
|
622
|
+
const [styles] = [...result.interactiveStylesMap.values()];
|
|
623
|
+
expect(JSON.stringify(styles)).toContain('var(--is-0)');
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// -----------------------------------------------------------------------
|
|
628
|
+
// 8. Verbatim-code markers
|
|
629
|
+
// -----------------------------------------------------------------------
|
|
630
|
+
describe('verbatim code markers', () => {
|
|
631
|
+
test('renders nothing for { _code: true } nodes', async () => {
|
|
632
|
+
const html = await render({ _code: true, expr: 'doSomething()' });
|
|
633
|
+
expect(html).toBe('');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test('renders siblings around a _code marker', async () => {
|
|
637
|
+
const html = await render([
|
|
638
|
+
{ type: 'node', tag: 'span', children: 'before' },
|
|
639
|
+
{ _code: true, expr: 'skipMe()' },
|
|
640
|
+
{ type: 'node', tag: 'span', children: 'after' },
|
|
641
|
+
]);
|
|
642
|
+
expect(html).toContain('before');
|
|
643
|
+
expect(html).toContain('after');
|
|
644
|
+
expect(html).not.toContain('skipMe');
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// -----------------------------------------------------------------------
|
|
649
|
+
// 9. Template mode text: CMS substitution + raw HTML
|
|
650
|
+
// -----------------------------------------------------------------------
|
|
651
|
+
describe('template mode text processing', () => {
|
|
652
|
+
const cmsService = {
|
|
653
|
+
queryItems: async () => [{ _id: 'p1', title: 'Post One' }],
|
|
654
|
+
getItemsByIds: async () => [],
|
|
655
|
+
getSchema: () => undefined,
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
test('emitTemplate processes {{cms.*}} but preserves {{item.*}} placeholders', async () => {
|
|
659
|
+
const node = {
|
|
660
|
+
type: 'list',
|
|
661
|
+
sourceType: 'collection',
|
|
662
|
+
source: 'posts',
|
|
663
|
+
emitTemplate: true,
|
|
664
|
+
children: [{ type: 'node', tag: 'span', children: '{{cms.brand}}: {{post.title}}' }],
|
|
665
|
+
};
|
|
666
|
+
const cmsContext = { cms: { brand: 'Acme' } };
|
|
667
|
+
const html = await render(node, { cmsService, cmsContext });
|
|
668
|
+
// Template part keeps the item placeholder but substitutes the CMS value
|
|
669
|
+
expect(html).toContain('<template data-meno-item>');
|
|
670
|
+
expect(html).toContain('Acme: {{post.title}}');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('emitTemplate emits rich-text CMS fields as raw HTML inside the template', async () => {
|
|
674
|
+
const node = {
|
|
675
|
+
type: 'list',
|
|
676
|
+
sourceType: 'collection',
|
|
677
|
+
source: 'posts',
|
|
678
|
+
emitTemplate: true,
|
|
679
|
+
children: [{ type: 'node', tag: 'div', children: '{{cms.body}}' }],
|
|
680
|
+
};
|
|
681
|
+
const cmsContext = { cms: { body: { __richtext__: true, html: '<p>Rich <b>content</b></p>' } } };
|
|
682
|
+
const result = await renderFull(node, { cmsService, cmsContext });
|
|
683
|
+
expect(result.html).toContain('<p>Rich <b>content</b></p>');
|
|
684
|
+
expect(result.html).not.toContain('<p>');
|
|
685
|
+
// Raw→processed mapping collected
|
|
686
|
+
expect(result.processedRawHtmlCollector.get('<p>Rich <b>content</b></p>')).toBe('<p>Rich <b>content</b></p>');
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// -----------------------------------------------------------------------
|
|
691
|
+
// 10. Embed nodes — html mappings, template mode, item templates, classes
|
|
692
|
+
// -----------------------------------------------------------------------
|
|
693
|
+
describe('embed node branches', () => {
|
|
694
|
+
test('html mapping (passthrough) resolves from component props', async () => {
|
|
695
|
+
const Logo: ComponentDefinition = {
|
|
696
|
+
component: {
|
|
697
|
+
interface: { svg: { type: 'string', default: '<svg viewBox="0 0 10 10"></svg>' } },
|
|
698
|
+
structure: { type: 'embed', html: { _mapping: true, prop: 'svg' } } as any,
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
const node = { type: 'component', component: 'BrEmbedMap', props: {} };
|
|
702
|
+
const html = await render(node, { globalComponents: { BrEmbedMap: Logo } });
|
|
703
|
+
expect(html).toContain('<span class="oem"');
|
|
704
|
+
expect(html).toContain('<svg');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('html mapping (value map) resolves the variant HTML', async () => {
|
|
708
|
+
const IconSet: ComponentDefinition = {
|
|
709
|
+
component: {
|
|
710
|
+
interface: { icon: { type: 'string', default: 'arrow' } },
|
|
711
|
+
structure: {
|
|
712
|
+
type: 'embed',
|
|
713
|
+
html: { _mapping: true, prop: 'icon', values: { arrow: '<b>arrow-svg</b>', check: '<b>check-svg</b>' } },
|
|
714
|
+
} as any,
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
const node = { type: 'component', component: 'BrEmbedValues', props: { icon: 'check' } };
|
|
718
|
+
const html = await render(node, { globalComponents: { BrEmbedValues: IconSet } });
|
|
719
|
+
expect(html).toContain('check-svg');
|
|
720
|
+
expect(html).not.toContain('arrow-svg');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('html mapping that cannot resolve renders an empty embed span', async () => {
|
|
724
|
+
const Broken: ComponentDefinition = {
|
|
725
|
+
component: {
|
|
726
|
+
interface: {},
|
|
727
|
+
structure: { type: 'embed', html: { _mapping: true, prop: 'missing' } } as any,
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
const node = { type: 'component', component: 'BrEmbedMiss', props: {} };
|
|
731
|
+
const html = await render(node, { globalComponents: { BrEmbedMiss: Broken } });
|
|
732
|
+
expect(html).toContain('<span class="oem"');
|
|
733
|
+
expect(html).toContain('></span>');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('html mapping on a page-level embed (no component props in scope) renders empty', async () => {
|
|
737
|
+
// Inside a component, processStructure resolves the mapping before the
|
|
738
|
+
// renderer sees it; at page level the renderer resolves it itself and
|
|
739
|
+
// has no componentResolvedProps → empty content.
|
|
740
|
+
const html = await render({ type: 'embed', html: { _mapping: true, prop: 'svg' } });
|
|
741
|
+
expect(html).toContain('<span class="oem"');
|
|
742
|
+
expect(html).toContain('></span>');
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('non-string non-mapping html value renders an empty embed span', async () => {
|
|
746
|
+
const html = await render({ type: 'embed', html: 12345 });
|
|
747
|
+
expect(html).toContain('<span class="oem"');
|
|
748
|
+
expect(html).toContain('></span>');
|
|
749
|
+
expect(html).not.toContain('12345');
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('embed in emitTemplate processes {{cms.*}} in html', async () => {
|
|
753
|
+
const cmsService = {
|
|
754
|
+
queryItems: async () => [{ _id: 'p1', title: 'Post One' }],
|
|
755
|
+
getItemsByIds: async () => [],
|
|
756
|
+
getSchema: () => undefined,
|
|
757
|
+
};
|
|
758
|
+
const node = {
|
|
759
|
+
type: 'list',
|
|
760
|
+
sourceType: 'collection',
|
|
761
|
+
source: 'posts',
|
|
762
|
+
emitTemplate: true,
|
|
763
|
+
children: [{ type: 'embed', html: '<b>{{cms.brand}}</b>' }],
|
|
764
|
+
};
|
|
765
|
+
const cmsContext = { cms: { brand: 'Acme' } };
|
|
766
|
+
const html = await render(node, { cmsService, cmsContext });
|
|
767
|
+
expect(html).toContain('<b>Acme</b>');
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('embed inside a list processes {{item.*}} templates in html', async () => {
|
|
771
|
+
const node = {
|
|
772
|
+
type: 'list',
|
|
773
|
+
sourceType: 'prop',
|
|
774
|
+
source: 'icons',
|
|
775
|
+
children: [{ type: 'embed', html: '<b>{{item.glyph}}</b>' }],
|
|
776
|
+
};
|
|
777
|
+
const cmsContext = { cms: { icons: [{ glyph: 'STAR' }] } };
|
|
778
|
+
const html = await render(node, { cmsContext });
|
|
779
|
+
expect(html).toContain('<b>STAR</b>');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('embed with interactive styles merges attribute class into class list', async () => {
|
|
783
|
+
const node = {
|
|
784
|
+
type: 'embed',
|
|
785
|
+
html: '<b>x</b>',
|
|
786
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
787
|
+
attributes: { class: 'embed-extra' },
|
|
788
|
+
};
|
|
789
|
+
const result = await renderFull(node, { pagePath: '/test' });
|
|
790
|
+
const classMatch = result.html.match(/class="([^"]*)"/);
|
|
791
|
+
expect(classMatch).not.toBeNull();
|
|
792
|
+
expect(classMatch![1]).toContain('oem');
|
|
793
|
+
expect(classMatch![1]).toContain('embed-extra');
|
|
794
|
+
// Element class registered for interactive CSS generation
|
|
795
|
+
expect(result.interactiveStylesMap.size).toBe(1);
|
|
796
|
+
const [elementClass] = [...result.interactiveStylesMap.keys()];
|
|
797
|
+
expect(classMatch![1]).toContain(elementClass);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// -----------------------------------------------------------------------
|
|
802
|
+
// 11. Link nodes — link-object hrefs from item templates
|
|
803
|
+
// -----------------------------------------------------------------------
|
|
804
|
+
describe('link node item-template hrefs', () => {
|
|
805
|
+
test('href template resolving to a link object extracts href and target', async () => {
|
|
806
|
+
const node = {
|
|
807
|
+
type: 'list',
|
|
808
|
+
sourceType: 'prop',
|
|
809
|
+
source: 'links',
|
|
810
|
+
children: [{ type: 'link', href: '{{item.link}}', children: '{{item.label}}' }],
|
|
811
|
+
};
|
|
812
|
+
const cmsContext = { cms: { links: [{ label: 'About', link: { href: '/about', target: '_blank' } }] } };
|
|
813
|
+
const html = await render(node, { cmsContext });
|
|
814
|
+
expect(html).toContain('href="/about"');
|
|
815
|
+
expect(html).toContain('target="_blank"');
|
|
816
|
+
expect(html).toContain('About');
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('double-wrapped link objects are unwrapped (target hoisted from inner)', async () => {
|
|
820
|
+
const node = {
|
|
821
|
+
type: 'list',
|
|
822
|
+
sourceType: 'prop',
|
|
823
|
+
source: 'links',
|
|
824
|
+
children: [{ type: 'link', href: '{{item.link}}', children: 'Inner' }],
|
|
825
|
+
};
|
|
826
|
+
const cmsContext = {
|
|
827
|
+
cms: { links: [{ link: { href: { href: '/inner', target: '_blank' } } }] },
|
|
828
|
+
};
|
|
829
|
+
const html = await render(node, { cmsContext });
|
|
830
|
+
expect(html).toContain('href="/inner"');
|
|
831
|
+
expect(html).toContain('target="_blank"');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('explicit target attribute wins over link-object target', async () => {
|
|
835
|
+
const node = {
|
|
836
|
+
type: 'list',
|
|
837
|
+
sourceType: 'prop',
|
|
838
|
+
source: 'links',
|
|
839
|
+
children: [{ type: 'link', href: '{{item.link}}', attributes: { target: '_self' }, children: 'Self' }],
|
|
840
|
+
};
|
|
841
|
+
const cmsContext = { cms: { links: [{ link: { href: '/x', target: '_blank' } }] } };
|
|
842
|
+
const html = await render(node, { cmsContext });
|
|
843
|
+
expect(html).toContain('target="_self"');
|
|
844
|
+
expect(html).not.toContain('target="_blank"');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test('href template resolving to a plain string is interpolated', async () => {
|
|
848
|
+
const node = {
|
|
849
|
+
type: 'list',
|
|
850
|
+
sourceType: 'prop',
|
|
851
|
+
source: 'links',
|
|
852
|
+
children: [{ type: 'link', href: '{{item.url}}', children: 'Plain' }],
|
|
853
|
+
};
|
|
854
|
+
const cmsContext = { cms: { links: [{ url: '/plain-target' }] } };
|
|
855
|
+
const html = await render(node, { cmsContext });
|
|
856
|
+
expect(html).toContain('href="/plain-target"');
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// -----------------------------------------------------------------------
|
|
861
|
+
// 12. Component instance with responsive props.style (no node.style)
|
|
862
|
+
// -----------------------------------------------------------------------
|
|
863
|
+
describe('component instance with responsive props.style', () => {
|
|
864
|
+
test('responsive props.style is merged at desktop viewport and applied to root', async () => {
|
|
865
|
+
const Card: ComponentDefinition = {
|
|
866
|
+
component: {
|
|
867
|
+
interface: {},
|
|
868
|
+
structure: { type: 'node', tag: 'div', children: 'Card content' },
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
const node = {
|
|
872
|
+
type: 'component',
|
|
873
|
+
component: 'BrRespCard',
|
|
874
|
+
props: { style: { base: { padding: '40px' }, mobile: { padding: '10px' } } },
|
|
875
|
+
};
|
|
876
|
+
const html = await render(node, { globalComponents: { BrRespCard: Card } });
|
|
877
|
+
expect(html).toContain('Card content');
|
|
878
|
+
// SSR renders at the desktop viewport (1920px) → base tier wins
|
|
879
|
+
expect(html).toContain('class="');
|
|
880
|
+
expect(html).toContain('p-10');
|
|
881
|
+
expect(html).not.toContain('p-2.5');
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// -----------------------------------------------------------------------
|
|
886
|
+
// 13. <a> tag is-current appended to existing classes
|
|
887
|
+
// -----------------------------------------------------------------------
|
|
888
|
+
describe('anchor is-current class merging', () => {
|
|
889
|
+
test('is-current is appended after existing classes for the current page', async () => {
|
|
890
|
+
const node = {
|
|
891
|
+
type: 'node',
|
|
892
|
+
tag: 'a',
|
|
893
|
+
attributes: { href: '/about', class: 'nav-link' },
|
|
894
|
+
children: 'About',
|
|
895
|
+
};
|
|
896
|
+
const html = await render(node, { pagePath: '/about' });
|
|
897
|
+
expect(html).toContain('class="nav-link is-current"');
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('non-matching href keeps original classes only', async () => {
|
|
901
|
+
const node = {
|
|
902
|
+
type: 'node',
|
|
903
|
+
tag: 'a',
|
|
904
|
+
attributes: { href: '/contact', class: 'nav-link' },
|
|
905
|
+
children: 'Contact',
|
|
906
|
+
};
|
|
907
|
+
const html = await render(node, { pagePath: '/about' });
|
|
908
|
+
expect(html).toContain('class="nav-link"');
|
|
909
|
+
expect(html).not.toContain('is-current');
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// -----------------------------------------------------------------------
|
|
914
|
+
// 14. Raw text elements (<style> / <script>) — children emitted verbatim
|
|
915
|
+
// -----------------------------------------------------------------------
|
|
916
|
+
describe('raw text elements', () => {
|
|
917
|
+
test('<style> children are not HTML-escaped', async () => {
|
|
918
|
+
const css = '.a > .b { background: url("x.png?a=1&b=2"); }';
|
|
919
|
+
const html = await render({ type: 'node', tag: 'style', children: [css] });
|
|
920
|
+
expect(html).toBe(`<style>${css}</style>`);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('<script> string child is emitted verbatim', async () => {
|
|
924
|
+
const js = 'if (a < b && c > d) { run("x"); }';
|
|
925
|
+
const html = await render({ type: 'node', tag: 'script', children: js });
|
|
926
|
+
expect(html).toBe(`<script>${js}</script>`);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test('flatten handles numbers, nested arrays, null, and drops objects with a logged warning', async () => {
|
|
930
|
+
const logged = captureErrorLog();
|
|
931
|
+
const html = await render({
|
|
932
|
+
type: 'node',
|
|
933
|
+
tag: 'script',
|
|
934
|
+
children: ['var n = ', 42, [';', null, [' // tail']], { type: 'node', tag: 'span', children: 'dropped' }],
|
|
935
|
+
});
|
|
936
|
+
// Object children inside <script>/<style> are dropped — raw-text elements
|
|
937
|
+
// cannot contain child elements, so this is correct output behavior…
|
|
938
|
+
expect(html).toBe('<script>var n = 42; // tail</script>');
|
|
939
|
+
// …but the authored content silently vanishing is surfaced via the logger.
|
|
940
|
+
expect(logged.length).toBe(1);
|
|
941
|
+
expect(logged[0]!.location).toBe('ssrRenderer.renderHtmlElement');
|
|
942
|
+
expect(logged[0]!.context).toMatchObject({ tag: 'script' });
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('verbatim _code markers inside <script> are dropped silently (by design)', async () => {
|
|
946
|
+
const logged = captureErrorLog();
|
|
947
|
+
const html = await render({
|
|
948
|
+
type: 'node',
|
|
949
|
+
tag: 'script',
|
|
950
|
+
children: ['var a = 1;', { _code: true, expr: 'nativeOnly()' }],
|
|
951
|
+
});
|
|
952
|
+
// _code markers render natively in the Astro build and have nothing to
|
|
953
|
+
// emit in meno-core SSR — mirroring renderNode, they drop without noise.
|
|
954
|
+
expect(html).toBe('<script>var a = 1;</script>');
|
|
955
|
+
expect(logged.length).toBe(0);
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// -----------------------------------------------------------------------
|
|
960
|
+
// 15. Locale-list interactive styles
|
|
961
|
+
// -----------------------------------------------------------------------
|
|
962
|
+
describe('locale-list interactive styles', () => {
|
|
963
|
+
const slugMappings = [{ pageId: 'about', slugs: { en: 'about', fr: 'a-propos' } }];
|
|
964
|
+
|
|
965
|
+
test('locale-list with interactiveStyles registers element class on container', async () => {
|
|
966
|
+
const node = {
|
|
967
|
+
type: 'locale-list',
|
|
968
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.7' } }],
|
|
969
|
+
};
|
|
970
|
+
const result = await renderFull(node, {
|
|
971
|
+
locale: 'en',
|
|
972
|
+
i18nConfig: I18N_EN_FR,
|
|
973
|
+
slugMappings,
|
|
974
|
+
pagePath: '/about',
|
|
975
|
+
});
|
|
976
|
+
expect(result.html).toContain('data-locale-list="true"');
|
|
977
|
+
expect(result.interactiveStylesMap.size).toBe(1);
|
|
978
|
+
const [elementClass] = [...result.interactiveStylesMap.keys()];
|
|
979
|
+
expect(result.html).toContain(`class="${elementClass}"`);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test('locale-list with generateElementClass gets element class without styles', async () => {
|
|
983
|
+
const node = { type: 'locale-list', generateElementClass: true };
|
|
984
|
+
const result = await renderFull(node, {
|
|
985
|
+
locale: 'en',
|
|
986
|
+
i18nConfig: I18N_EN_FR,
|
|
987
|
+
slugMappings,
|
|
988
|
+
pagePath: '/about',
|
|
989
|
+
});
|
|
990
|
+
expect(result.html).toMatch(/data-locale-list="true" class="[^"]+"/);
|
|
991
|
+
expect(result.interactiveStylesMap.size).toBe(0);
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// -----------------------------------------------------------------------
|
|
996
|
+
// 16. renderComponent error fallback
|
|
997
|
+
// -----------------------------------------------------------------------
|
|
998
|
+
describe('component render error fallback', () => {
|
|
999
|
+
function makeThrowingDef(): ComponentDefinition {
|
|
1000
|
+
return {
|
|
1001
|
+
component: {
|
|
1002
|
+
interface: {},
|
|
1003
|
+
get structure() {
|
|
1004
|
+
throw new Error('structure exploded');
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
} as unknown as ComponentDefinition;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
test('component that throws renders nothing outside the editor', async () => {
|
|
1011
|
+
const node = { type: 'component', component: 'BrThrows', props: {} };
|
|
1012
|
+
const html = await render(node, { globalComponents: { BrThrows: makeThrowingDef() } });
|
|
1013
|
+
expect(html).toBe('');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
test('component that throws renders a visible error placeholder in editor preview', async () => {
|
|
1017
|
+
const node = { type: 'component', component: 'BrThrowsEditor', props: {} };
|
|
1018
|
+
const html = await render(node, {
|
|
1019
|
+
globalComponents: { BrThrowsEditor: makeThrowingDef() },
|
|
1020
|
+
injectEditorAttrs: true,
|
|
1021
|
+
});
|
|
1022
|
+
// Errors surface in the editor instead of vanishing (see commit de167014)
|
|
1023
|
+
expect(html).toContain('data-meno-render-error');
|
|
1024
|
+
expect(html).toContain('⚠️');
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test('a broken component does not take down sibling nodes', async () => {
|
|
1028
|
+
const nodes = [
|
|
1029
|
+
{ type: 'node', tag: 'p', children: 'before' },
|
|
1030
|
+
{ type: 'component', component: 'BrThrowsSibling', props: {} },
|
|
1031
|
+
{ type: 'node', tag: 'p', children: 'after' },
|
|
1032
|
+
];
|
|
1033
|
+
const html = await render(nodes, { globalComponents: { BrThrowsSibling: makeThrowingDef() } });
|
|
1034
|
+
expect(html).toContain('before');
|
|
1035
|
+
expect(html).toContain('after');
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// -----------------------------------------------------------------------
|
|
1040
|
+
// 17. renderComponent className merging onto structure roots
|
|
1041
|
+
// -----------------------------------------------------------------------
|
|
1042
|
+
describe('component instance class merging', () => {
|
|
1043
|
+
test('instance class is appended to an HTML root with an existing class', async () => {
|
|
1044
|
+
const Box: ComponentDefinition = {
|
|
1045
|
+
component: {
|
|
1046
|
+
interface: {},
|
|
1047
|
+
structure: { type: 'node', tag: 'div', attributes: { class: 'root-cls' }, children: 'Box' } as any,
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
const node = { type: 'component', component: 'BrBoxA', attributes: { class: 'inst-cls' }, props: {} };
|
|
1051
|
+
const html = await render(node, { globalComponents: { BrBoxA: Box } });
|
|
1052
|
+
expect(html).toMatch(/class="[^"]*root-cls[^"]*inst-cls[^"]*"/);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test('instance class lands on an HTML root without an existing class', async () => {
|
|
1056
|
+
const Box: ComponentDefinition = {
|
|
1057
|
+
component: {
|
|
1058
|
+
interface: {},
|
|
1059
|
+
structure: { type: 'node', tag: 'div', children: 'Box' },
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
const node = { type: 'component', component: 'BrBoxB', attributes: { class: 'only-inst' }, props: {} };
|
|
1063
|
+
const html = await render(node, { globalComponents: { BrBoxB: Box } });
|
|
1064
|
+
expect(html).toContain('only-inst');
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('instance class flows through a nested-component root to the final element', async () => {
|
|
1068
|
+
const Inner: ComponentDefinition = {
|
|
1069
|
+
component: {
|
|
1070
|
+
interface: {},
|
|
1071
|
+
structure: { type: 'node', tag: 'div', children: 'Inner' },
|
|
1072
|
+
},
|
|
1073
|
+
};
|
|
1074
|
+
const Outer: ComponentDefinition = {
|
|
1075
|
+
component: {
|
|
1076
|
+
interface: {},
|
|
1077
|
+
structure: { type: 'component', component: 'BrNestedInner', props: {} } as any,
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
const node = { type: 'component', component: 'BrNestedOuter', attributes: { class: 'outer-cls' }, props: {} };
|
|
1081
|
+
const html = await render(node, {
|
|
1082
|
+
globalComponents: { BrNestedOuter: Outer, BrNestedInner: Inner },
|
|
1083
|
+
});
|
|
1084
|
+
expect(html).toContain('Inner');
|
|
1085
|
+
expect(html).toContain('outer-cls');
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// -----------------------------------------------------------------------
|
|
1090
|
+
// 18. renderPageSSR — CMS template in og:image
|
|
1091
|
+
// -----------------------------------------------------------------------
|
|
1092
|
+
describe('renderPageSSR og:image CMS template', () => {
|
|
1093
|
+
test('processes {{cms.*}} in meta.ogImage', async () => {
|
|
1094
|
+
const pageData: JSONPage = {
|
|
1095
|
+
root: { type: 'node', tag: 'div', children: 'Body' } as any,
|
|
1096
|
+
meta: { title: 'Page', ogImage: '{{cms.image}}' },
|
|
1097
|
+
} as JSONPage;
|
|
1098
|
+
const cmsContext = { cms: { image: '/img/hero.png' } };
|
|
1099
|
+
const result = await renderPageSSR(pageData, {}, '/', '', undefined, undefined, undefined, cmsContext);
|
|
1100
|
+
expect(result.meta).toContain('/img/hero.png');
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
});
|