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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tests the core ComponentNode -> Astro template markup converter
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe, test, expect
|
|
6
|
+
import { describe, test, expect } from 'bun:test';
|
|
7
7
|
import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
|
|
8
8
|
import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
|
|
9
9
|
import type { ComponentNode } from '../../shared/types';
|
|
@@ -29,7 +29,7 @@ function createContext(overrides?: Partial<AstroEmitContext>): AstroEmitContext
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Strip leading/trailing whitespace from each line for easier comparison */
|
|
32
|
-
function
|
|
32
|
+
function _trimLines(s: string): string {
|
|
33
33
|
return s
|
|
34
34
|
.split('\n')
|
|
35
35
|
.map((l) => l.trimEnd())
|
|
@@ -206,7 +206,7 @@ describe('nodeToAstro', () => {
|
|
|
206
206
|
test('should handle indentation via context', () => {
|
|
207
207
|
const ctx = createContext({ indent: 2 });
|
|
208
208
|
const result = nodeToAstro('text', ctx);
|
|
209
|
-
expect(result).toMatch(/^
|
|
209
|
+
expect(result).toMatch(/^ {4}text/); // 2 * 2 spaces = 4 spaces
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
212
|
|
|
@@ -954,12 +954,15 @@ describe('nodeToAstro', () => {
|
|
|
954
954
|
test('should emit <Picture> for static image type node with metadata', () => {
|
|
955
955
|
const ctx = createContext({
|
|
956
956
|
imageMetadataMap: new Map([
|
|
957
|
-
[
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
957
|
+
[
|
|
958
|
+
'/images/photo.jpg',
|
|
959
|
+
{
|
|
960
|
+
width: 800,
|
|
961
|
+
height: 600,
|
|
962
|
+
srcset: '/images/photo-400.webp 400w',
|
|
963
|
+
avifSrcset: '/images/photo-400.avif 400w',
|
|
964
|
+
},
|
|
965
|
+
],
|
|
963
966
|
]) as any,
|
|
964
967
|
});
|
|
965
968
|
const node = {
|
|
@@ -983,12 +986,15 @@ describe('nodeToAstro', () => {
|
|
|
983
986
|
|
|
984
987
|
describe('static image <Picture> emission', () => {
|
|
985
988
|
const staticMetadata = new Map([
|
|
986
|
-
[
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
989
|
+
[
|
|
990
|
+
'/images/hero.jpg',
|
|
991
|
+
{
|
|
992
|
+
width: 1920,
|
|
993
|
+
height: 1080,
|
|
994
|
+
srcset: '/images/hero-800.webp 800w',
|
|
995
|
+
avifSrcset: '/images/hero-800.avif 800w',
|
|
996
|
+
},
|
|
997
|
+
],
|
|
992
998
|
]);
|
|
993
999
|
|
|
994
1000
|
test('static <img> emits <Picture> and records import', () => {
|
|
@@ -1090,13 +1096,16 @@ describe('nodeToAstro', () => {
|
|
|
1090
1096
|
test('blur hash puts blur on <picture> via pictureAttributes, no wrapper div', () => {
|
|
1091
1097
|
const ctx = createContext({
|
|
1092
1098
|
imageMetadataMap: new Map([
|
|
1093
|
-
[
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1099
|
+
[
|
|
1100
|
+
'/images/hero.jpg',
|
|
1101
|
+
{
|
|
1102
|
+
width: 1920,
|
|
1103
|
+
height: 1080,
|
|
1104
|
+
srcset: '/images/hero-800.webp 800w',
|
|
1105
|
+
avifSrcset: '/images/hero-800.avif 800w',
|
|
1106
|
+
blurHash: 'data:image/png;base64,blur',
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1100
1109
|
]) as any,
|
|
1101
1110
|
});
|
|
1102
1111
|
const node: ComponentNode = {
|
|
@@ -1140,9 +1149,7 @@ describe('nodeToAstro', () => {
|
|
|
1140
1149
|
{
|
|
1141
1150
|
type: 'node',
|
|
1142
1151
|
tag: 'div',
|
|
1143
|
-
children: [
|
|
1144
|
-
{ type: 'component', component: 'Button', children: [] },
|
|
1145
|
-
],
|
|
1152
|
+
children: [{ type: 'component', component: 'Button', children: [] }],
|
|
1146
1153
|
} as any,
|
|
1147
1154
|
];
|
|
1148
1155
|
nodeToAstro(nodes, ctx);
|
|
@@ -1164,11 +1171,11 @@ describe('nodeToAstro', () => {
|
|
|
1164
1171
|
tag: '{{headingTag}}',
|
|
1165
1172
|
children: ['Title'],
|
|
1166
1173
|
} as any;
|
|
1167
|
-
const
|
|
1174
|
+
const _result = nodeToAstro(node, ctx);
|
|
1168
1175
|
expect(ctx.dynamicTags).toBeDefined();
|
|
1169
1176
|
expect(ctx.dynamicTags!.size).toBe(1);
|
|
1170
1177
|
// Should use Tag_<path> variable
|
|
1171
|
-
const entry = [...ctx.dynamicTags!.entries()][0]
|
|
1178
|
+
const entry = [...ctx.dynamicTags!.entries()][0]!;
|
|
1172
1179
|
expect(entry[0]).toMatch(/^Tag_/);
|
|
1173
1180
|
});
|
|
1174
1181
|
});
|
|
@@ -1265,9 +1272,7 @@ describe('nodeToAstro', () => {
|
|
|
1265
1272
|
children: [],
|
|
1266
1273
|
} as any;
|
|
1267
1274
|
const result = nodeToAstro(node, ctx);
|
|
1268
|
-
expect(result).toContain(
|
|
1269
|
-
'sizes="(max-width: 540px) 340px, (max-width: 1024px) 500px, 700px"'
|
|
1270
|
-
);
|
|
1275
|
+
expect(result).toContain('sizes="(max-width: 540px) 340px, (max-width: 1024px) 500px, 700px"');
|
|
1271
1276
|
});
|
|
1272
1277
|
|
|
1273
1278
|
test('should fall back to 100vw when width is not a px value', () => {
|
|
@@ -1332,9 +1337,9 @@ describe('nodeToAstro', () => {
|
|
|
1332
1337
|
expect(result).toContain('</picture>');
|
|
1333
1338
|
expect(result).toContain('background-image:url(data:image/webp;base64,AAA)');
|
|
1334
1339
|
// The <picture>'s style must carry the background, and the <img> must not.
|
|
1335
|
-
const pictureLine = result.split('\n').find(l => l.includes('<picture'));
|
|
1340
|
+
const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
|
|
1336
1341
|
expect(pictureLine).toContain('background-image:url');
|
|
1337
|
-
const imgLine = result.split('\n').find(l => l.includes('<img'));
|
|
1342
|
+
const imgLine = result.split('\n').find((l) => l.includes('<img'));
|
|
1338
1343
|
expect(imgLine).not.toContain('background-image');
|
|
1339
1344
|
// The onload handler should clear the placeholder from the parent.
|
|
1340
1345
|
expect(result).toContain("this.parentElement.style.backgroundImage=''");
|
|
@@ -1363,10 +1368,10 @@ describe('nodeToAstro', () => {
|
|
|
1363
1368
|
expect(result).toContain('<picture');
|
|
1364
1369
|
expect(result).toContain('type="image/avif"');
|
|
1365
1370
|
expect(result).toContain('type="image/webp"');
|
|
1366
|
-
const pictureLine = result.split('\n').find(l => l.includes('<picture'));
|
|
1371
|
+
const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
|
|
1367
1372
|
expect(pictureLine).toContain('background-image:url(data:image/webp;base64,BBB)');
|
|
1368
1373
|
// Inner img should not carry an inline background-image anymore.
|
|
1369
|
-
const imgLine = result.split('\n').find(l => l.includes('<img'));
|
|
1374
|
+
const imgLine = result.split('\n').find((l) => l.includes('<img'));
|
|
1370
1375
|
expect(imgLine).not.toContain('background-image');
|
|
1371
1376
|
// Fill classes on inner img so it covers the picture's declared size.
|
|
1372
1377
|
expect(imgLine).toContain('block');
|
|
@@ -1490,4 +1495,1719 @@ describe('nodeToAstro', () => {
|
|
|
1490
1495
|
expect(result).toContain('id={item.id');
|
|
1491
1496
|
});
|
|
1492
1497
|
});
|
|
1498
|
+
|
|
1499
|
+
// =========================================================================
|
|
1500
|
+
// Style mappings -> class:list conditionals
|
|
1501
|
+
// =========================================================================
|
|
1502
|
+
|
|
1503
|
+
describe('style mappings to class:list', () => {
|
|
1504
|
+
const alignMapping = {
|
|
1505
|
+
_mapping: true,
|
|
1506
|
+
prop: 'align',
|
|
1507
|
+
values: { left: 'left', center: 'center' },
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
test('two-value mapping emits ternary in class:list (component def)', () => {
|
|
1511
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1512
|
+
const node: ComponentNode = {
|
|
1513
|
+
type: 'node',
|
|
1514
|
+
tag: 'div',
|
|
1515
|
+
style: { textAlign: alignMapping },
|
|
1516
|
+
children: ['x'],
|
|
1517
|
+
} as any;
|
|
1518
|
+
const result = nodeToAstro(node, ctx);
|
|
1519
|
+
expect(result).toContain(`class:list={[String(align) === "left" ? 'text-left' : 'text-center']}`);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
test('multi-value mapping emits one && entry per value', () => {
|
|
1523
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1524
|
+
const node: ComponentNode = {
|
|
1525
|
+
type: 'node',
|
|
1526
|
+
tag: 'div',
|
|
1527
|
+
style: {
|
|
1528
|
+
textAlign: {
|
|
1529
|
+
_mapping: true,
|
|
1530
|
+
prop: 'align',
|
|
1531
|
+
values: { left: 'left', center: 'center', right: 'right' },
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
children: ['x'],
|
|
1535
|
+
} as any;
|
|
1536
|
+
const result = nodeToAstro(node, ctx);
|
|
1537
|
+
expect(result).toContain(`String(align) === "left" && 'text-left'`);
|
|
1538
|
+
expect(result).toContain(`String(align) === "center" && 'text-center'`);
|
|
1539
|
+
expect(result).toContain(`String(align) === "right" && 'text-right'`);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test('boolean mapping keys are coerced then re-stringified', () => {
|
|
1543
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1544
|
+
const node: ComponentNode = {
|
|
1545
|
+
type: 'node',
|
|
1546
|
+
tag: 'div',
|
|
1547
|
+
style: {
|
|
1548
|
+
textAlign: { _mapping: true, prop: 'centered', values: { true: 'center', false: 'left' } },
|
|
1549
|
+
},
|
|
1550
|
+
children: ['x'],
|
|
1551
|
+
} as any;
|
|
1552
|
+
const result = nodeToAstro(node, ctx);
|
|
1553
|
+
expect(result).toContain(`String(centered) === "true" ? 'text-center' : 'text-left'`);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test('responsive mapping under tablet gets max-[1024px]: prefix', () => {
|
|
1557
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1558
|
+
const node: ComponentNode = {
|
|
1559
|
+
type: 'node',
|
|
1560
|
+
tag: 'div',
|
|
1561
|
+
style: { tablet: { textAlign: alignMapping } },
|
|
1562
|
+
children: ['x'],
|
|
1563
|
+
} as any;
|
|
1564
|
+
const result = nodeToAstro(node, ctx);
|
|
1565
|
+
expect(result).toContain(`'max-[1024px]:text-left'`);
|
|
1566
|
+
expect(result).toContain(`'max-[1024px]:text-center'`);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test('responsive mapping under mobile gets max-[540px]: prefix', () => {
|
|
1570
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1571
|
+
const node: ComponentNode = {
|
|
1572
|
+
type: 'node',
|
|
1573
|
+
tag: 'div',
|
|
1574
|
+
style: { mobile: { textAlign: alignMapping } },
|
|
1575
|
+
children: ['x'],
|
|
1576
|
+
} as any;
|
|
1577
|
+
const result = nodeToAstro(node, ctx);
|
|
1578
|
+
expect(result).toContain(`'max-[540px]:text-left'`);
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
test('static classes and conditionals combine in class:list', () => {
|
|
1582
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1583
|
+
const node: ComponentNode = {
|
|
1584
|
+
type: 'node',
|
|
1585
|
+
tag: 'div',
|
|
1586
|
+
style: { display: 'flex', textAlign: alignMapping },
|
|
1587
|
+
children: ['x'],
|
|
1588
|
+
} as any;
|
|
1589
|
+
const result = nodeToAstro(node, ctx);
|
|
1590
|
+
expect(result).toContain(`class:list={['flex', String(align) === "left"`);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
test('mapping values without a Tailwind class are dropped', () => {
|
|
1594
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1595
|
+
const node: ComponentNode = {
|
|
1596
|
+
type: 'node',
|
|
1597
|
+
tag: 'div',
|
|
1598
|
+
style: {
|
|
1599
|
+
// gap '' produces no class; both must resolve or the ternary is skipped
|
|
1600
|
+
gap: { _mapping: true, prop: 'spacing', values: { a: '', b: '' } },
|
|
1601
|
+
},
|
|
1602
|
+
children: ['x'],
|
|
1603
|
+
} as any;
|
|
1604
|
+
const result = nodeToAstro(node, ctx);
|
|
1605
|
+
expect(result).not.toContain('class:list');
|
|
1606
|
+
expect(result).not.toContain('class=');
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
// Mappings reference component props by bare identifier; at page scope those
|
|
1610
|
+
// identifiers don't exist (runtime resolveStyleMapping() receives no props
|
|
1611
|
+
// there and drops the property), so the emitter must not reference them —
|
|
1612
|
+
// doing so is an Astro build error, not a graceful degradation.
|
|
1613
|
+
test('page context drops style-mapping conditionals instead of referencing missing props', () => {
|
|
1614
|
+
const ctx = createContext({ isComponentDef: false });
|
|
1615
|
+
const node: ComponentNode = {
|
|
1616
|
+
type: 'node',
|
|
1617
|
+
tag: 'div',
|
|
1618
|
+
style: { textAlign: alignMapping },
|
|
1619
|
+
children: ['x'],
|
|
1620
|
+
} as any;
|
|
1621
|
+
const result = nodeToAstro(node, ctx);
|
|
1622
|
+
expect(result).not.toContain('class:list');
|
|
1623
|
+
expect(result).not.toContain('align');
|
|
1624
|
+
expect(result).toContain('<div>');
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
test('page context keeps static classes while dropping mapping conditionals', () => {
|
|
1628
|
+
const ctx = createContext({ isComponentDef: false });
|
|
1629
|
+
const node: ComponentNode = {
|
|
1630
|
+
type: 'node',
|
|
1631
|
+
tag: 'div',
|
|
1632
|
+
style: { display: 'flex', textAlign: alignMapping },
|
|
1633
|
+
children: ['x'],
|
|
1634
|
+
} as any;
|
|
1635
|
+
const result = nodeToAstro(node, ctx);
|
|
1636
|
+
expect(result).toContain('class="flex"');
|
|
1637
|
+
expect(result).not.toContain('class:list');
|
|
1638
|
+
expect(result).not.toContain('align');
|
|
1639
|
+
});
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
// =========================================================================
|
|
1643
|
+
// Dynamic (template-expression) styles -> inline style attribute
|
|
1644
|
+
// =========================================================================
|
|
1645
|
+
|
|
1646
|
+
describe('dynamic template styles', () => {
|
|
1647
|
+
test('template style value becomes template-literal style attr in component def', () => {
|
|
1648
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1649
|
+
const node: ComponentNode = {
|
|
1650
|
+
type: 'node',
|
|
1651
|
+
tag: 'div',
|
|
1652
|
+
style: { width: '{{w}}' },
|
|
1653
|
+
children: ['x'],
|
|
1654
|
+
} as any;
|
|
1655
|
+
const result = nodeToAstro(node, ctx);
|
|
1656
|
+
expect(result).toContain('style={`width: ${w}`}');
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test('multiple dynamic styles join with semicolons', () => {
|
|
1660
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1661
|
+
const node: ComponentNode = {
|
|
1662
|
+
type: 'node',
|
|
1663
|
+
tag: 'div',
|
|
1664
|
+
style: { width: '{{w}}', minHeight: '{{h}}' },
|
|
1665
|
+
children: ['x'],
|
|
1666
|
+
} as any;
|
|
1667
|
+
const result = nodeToAstro(node, ctx);
|
|
1668
|
+
expect(result).toContain('style={`width: ${w}; min-height: ${h}`}');
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
test('dynamic styles rewrite item vars inside list context', () => {
|
|
1672
|
+
const ctx = createContext({
|
|
1673
|
+
isComponentDef: true,
|
|
1674
|
+
listItemBinding: 'card',
|
|
1675
|
+
listIndexVar: 'cardIndex',
|
|
1676
|
+
listSourceVar: 'cards',
|
|
1677
|
+
});
|
|
1678
|
+
const node: ComponentNode = {
|
|
1679
|
+
type: 'node',
|
|
1680
|
+
tag: 'div',
|
|
1681
|
+
style: { width: '{{item.width}}' },
|
|
1682
|
+
children: ['x'],
|
|
1683
|
+
} as any;
|
|
1684
|
+
const result = nodeToAstro(node, ctx);
|
|
1685
|
+
expect(result).toContain('style={`width: ${card.width}`}');
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test('dynamic styles are NOT emitted in page context', () => {
|
|
1689
|
+
const ctx = createContext({ isComponentDef: false });
|
|
1690
|
+
const node: ComponentNode = {
|
|
1691
|
+
type: 'node',
|
|
1692
|
+
tag: 'div',
|
|
1693
|
+
style: { width: '{{w}}' },
|
|
1694
|
+
children: ['x'],
|
|
1695
|
+
} as any;
|
|
1696
|
+
const result = nodeToAstro(node, ctx);
|
|
1697
|
+
expect(result).not.toContain('style=');
|
|
1698
|
+
});
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
// =========================================================================
|
|
1702
|
+
// Interactive style mappings -> --is-N CSS variables
|
|
1703
|
+
// =========================================================================
|
|
1704
|
+
|
|
1705
|
+
describe('interactive style mappings', () => {
|
|
1706
|
+
test('two-value mapping emits ternary CSS variable', () => {
|
|
1707
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1708
|
+
const node: ComponentNode = {
|
|
1709
|
+
type: 'node',
|
|
1710
|
+
tag: 'div',
|
|
1711
|
+
label: 'Btn',
|
|
1712
|
+
interactiveStyles: [
|
|
1713
|
+
{
|
|
1714
|
+
postfix: ':hover',
|
|
1715
|
+
style: {
|
|
1716
|
+
backgroundColor: { _mapping: true, prop: 'variant', values: { primary: '#00f', secondary: '#f00' } },
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
],
|
|
1720
|
+
children: ['x'],
|
|
1721
|
+
} as any;
|
|
1722
|
+
const result = nodeToAstro(node, ctx);
|
|
1723
|
+
expect(result).toContain(`'--is-0': variant === "primary" ? '#00f' : '#f00'`);
|
|
1724
|
+
expect(result).toContain('style={Object.entries({');
|
|
1725
|
+
// Element class is generated for interactive styles and prepended to classes
|
|
1726
|
+
expect(result).toContain('class="p_test-page_btn"');
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
test('multi-value mapping emits inline lookup object, filtering empty values', () => {
|
|
1730
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1731
|
+
const node: ComponentNode = {
|
|
1732
|
+
type: 'node',
|
|
1733
|
+
tag: 'div',
|
|
1734
|
+
interactiveStyles: [
|
|
1735
|
+
{
|
|
1736
|
+
postfix: ':hover',
|
|
1737
|
+
style: {
|
|
1738
|
+
borderWidth: { _mapping: true, prop: 'size', values: { a: '1px', b: '2px', c: '' } },
|
|
1739
|
+
},
|
|
1740
|
+
},
|
|
1741
|
+
],
|
|
1742
|
+
children: ['x'],
|
|
1743
|
+
} as any;
|
|
1744
|
+
const result = nodeToAstro(node, ctx);
|
|
1745
|
+
expect(result).toContain(`'--is-0': ({"a": '1px', "b": '2px'})[size] || ''`);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
test('interactive mapping merges with existing dynamic style attr', () => {
|
|
1749
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1750
|
+
const node: ComponentNode = {
|
|
1751
|
+
type: 'node',
|
|
1752
|
+
tag: 'div',
|
|
1753
|
+
style: { width: '{{w}}' },
|
|
1754
|
+
interactiveStyles: [
|
|
1755
|
+
{
|
|
1756
|
+
postfix: ':hover',
|
|
1757
|
+
style: {
|
|
1758
|
+
backgroundColor: { _mapping: true, prop: 'variant', values: { primary: '#00f', secondary: '#f00' } },
|
|
1759
|
+
},
|
|
1760
|
+
},
|
|
1761
|
+
],
|
|
1762
|
+
children: ['x'],
|
|
1763
|
+
} as any;
|
|
1764
|
+
const result = nodeToAstro(node, ctx);
|
|
1765
|
+
expect(result).toContain('style={`width: ${w}; ${ Object.entries({');
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
test('interactive styles register element class in collectedInteractiveStyles', () => {
|
|
1769
|
+
const collected = new Map();
|
|
1770
|
+
const ctx = createContext({
|
|
1771
|
+
collectedInteractiveStyles: collected as any,
|
|
1772
|
+
});
|
|
1773
|
+
const node: ComponentNode = {
|
|
1774
|
+
type: 'node',
|
|
1775
|
+
tag: 'div',
|
|
1776
|
+
label: 'Hero',
|
|
1777
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
1778
|
+
children: ['x'],
|
|
1779
|
+
} as any;
|
|
1780
|
+
const result = nodeToAstro(node, ctx);
|
|
1781
|
+
expect(result).toContain('class="p_test-page_hero"');
|
|
1782
|
+
expect(collected.get('p_test-page_hero')).toEqual([{ postfix: ':hover', style: { opacity: '0.5' } }]);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
test('generateElementClass flag alone produces hashed element class', () => {
|
|
1786
|
+
const ctx = createContext();
|
|
1787
|
+
const node: ComponentNode = {
|
|
1788
|
+
type: 'node',
|
|
1789
|
+
tag: 'div',
|
|
1790
|
+
generateElementClass: true,
|
|
1791
|
+
children: ['x'],
|
|
1792
|
+
} as any;
|
|
1793
|
+
const result = nodeToAstro(node, ctx);
|
|
1794
|
+
expect(result).toMatch(/class="p_test-page_[a-z0-9]+"/);
|
|
1795
|
+
});
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
// =========================================================================
|
|
1799
|
+
// Attribute template branches (buildAttributesString)
|
|
1800
|
+
// =========================================================================
|
|
1801
|
+
|
|
1802
|
+
describe('attribute template branches', () => {
|
|
1803
|
+
test('link-type prop in full-match attribute uses href coercion expr', () => {
|
|
1804
|
+
const ctx = createContext({
|
|
1805
|
+
isComponentDef: true,
|
|
1806
|
+
componentProps: { cta: { type: 'link', label: 'CTA' } as any },
|
|
1807
|
+
});
|
|
1808
|
+
const node: ComponentNode = {
|
|
1809
|
+
type: 'node',
|
|
1810
|
+
tag: 'div',
|
|
1811
|
+
attributes: { 'data-href': '{{cta}}' },
|
|
1812
|
+
children: [],
|
|
1813
|
+
} as any;
|
|
1814
|
+
const result = nodeToAstro(node, ctx);
|
|
1815
|
+
expect(result).toContain(`data-href={(typeof cta === 'string' ? cta : cta?.href) ?? "#"}`);
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test('mixed-content attribute becomes template literal in component def', () => {
|
|
1819
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1820
|
+
const node: ComponentNode = {
|
|
1821
|
+
type: 'node',
|
|
1822
|
+
tag: 'button',
|
|
1823
|
+
attributes: { 'aria-label': 'Open {{name}} menu' },
|
|
1824
|
+
children: ['x'],
|
|
1825
|
+
} as any;
|
|
1826
|
+
const result = nodeToAstro(node, ctx);
|
|
1827
|
+
expect(result).toContain('aria-label={`Open ${name} menu`}');
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
test('mixed-content attribute with link-type prop coerces href inside literal', () => {
|
|
1831
|
+
const ctx = createContext({
|
|
1832
|
+
isComponentDef: true,
|
|
1833
|
+
componentProps: { cta: { type: 'link', label: 'CTA' } as any },
|
|
1834
|
+
});
|
|
1835
|
+
const node: ComponentNode = {
|
|
1836
|
+
type: 'node',
|
|
1837
|
+
tag: 'div',
|
|
1838
|
+
attributes: { 'data-url': 'go-{{cta}}' },
|
|
1839
|
+
children: [],
|
|
1840
|
+
} as any;
|
|
1841
|
+
const result = nodeToAstro(node, ctx);
|
|
1842
|
+
expect(result).toContain('data-url={`go-${(typeof cta === \'string\' ? cta : cta?.href) ?? "#"}`}');
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test('list-item full-match attribute outside component def', () => {
|
|
1846
|
+
const ctx = createContext({
|
|
1847
|
+
isComponentDef: false,
|
|
1848
|
+
listItemBinding: 'item',
|
|
1849
|
+
});
|
|
1850
|
+
const node: ComponentNode = {
|
|
1851
|
+
type: 'node',
|
|
1852
|
+
tag: 'div',
|
|
1853
|
+
attributes: { id: '{{item.id}}' },
|
|
1854
|
+
children: [],
|
|
1855
|
+
} as any;
|
|
1856
|
+
const result = nodeToAstro(node, ctx);
|
|
1857
|
+
expect(result).toContain('id={item.id || undefined}');
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
test('list-item meta var in attribute resolves index expression', () => {
|
|
1861
|
+
const ctx = createContext({
|
|
1862
|
+
isComponentDef: false,
|
|
1863
|
+
listItemBinding: 'item',
|
|
1864
|
+
listIndexVar: 'i',
|
|
1865
|
+
listSourceVar: 'items',
|
|
1866
|
+
});
|
|
1867
|
+
const node: ComponentNode = {
|
|
1868
|
+
type: 'node',
|
|
1869
|
+
tag: 'div',
|
|
1870
|
+
attributes: { 'data-last': '{{itemLast}}' },
|
|
1871
|
+
children: [],
|
|
1872
|
+
} as any;
|
|
1873
|
+
const result = nodeToAstro(node, ctx);
|
|
1874
|
+
expect(result).toContain('data-last={(i === items.length - 1) || undefined}');
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
test('list-item mixed-content attribute becomes template literal', () => {
|
|
1878
|
+
const ctx = createContext({
|
|
1879
|
+
isComponentDef: false,
|
|
1880
|
+
listItemBinding: 'item',
|
|
1881
|
+
listIndexVar: 'i',
|
|
1882
|
+
listSourceVar: 'items',
|
|
1883
|
+
});
|
|
1884
|
+
const node: ComponentNode = {
|
|
1885
|
+
type: 'node',
|
|
1886
|
+
tag: 'div',
|
|
1887
|
+
attributes: { id: 'row-{{item.id}}' },
|
|
1888
|
+
children: [],
|
|
1889
|
+
} as any;
|
|
1890
|
+
const result = nodeToAstro(node, ctx);
|
|
1891
|
+
expect(result).toContain('id={`row-${item.id}`}');
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
test('CMS mixed-content attribute becomes template literal', () => {
|
|
1895
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
|
|
1896
|
+
const node: ComponentNode = {
|
|
1897
|
+
type: 'node',
|
|
1898
|
+
tag: 'div',
|
|
1899
|
+
attributes: { alt: 'Photo of {{cms.name}}' },
|
|
1900
|
+
children: [],
|
|
1901
|
+
} as any;
|
|
1902
|
+
const result = nodeToAstro(node, ctx);
|
|
1903
|
+
expect(result).toContain('alt={`Photo of ${entry.data.name}`}');
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
// =========================================================================
|
|
1908
|
+
// emitAttrValue via image emitter (CMS / list-item src)
|
|
1909
|
+
// =========================================================================
|
|
1910
|
+
|
|
1911
|
+
describe('image attribute value transformation', () => {
|
|
1912
|
+
test('CMS mixed-content img src becomes template literal', () => {
|
|
1913
|
+
const ctx = createContext({
|
|
1914
|
+
imageMetadataMap: new Map() as any,
|
|
1915
|
+
cmsMode: true,
|
|
1916
|
+
cmsEntryBinding: 'entry',
|
|
1917
|
+
});
|
|
1918
|
+
const node: ComponentNode = {
|
|
1919
|
+
type: 'node',
|
|
1920
|
+
tag: 'img',
|
|
1921
|
+
attributes: { src: 'x-{{cms.img}}', alt: 'A' },
|
|
1922
|
+
children: [],
|
|
1923
|
+
} as any;
|
|
1924
|
+
const result = nodeToAstro(node, ctx);
|
|
1925
|
+
expect(result).toContain('src={`x-${entry.data.img}`}');
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
test('list-item mixed-content img src becomes template literal', () => {
|
|
1929
|
+
const ctx = createContext({
|
|
1930
|
+
imageMetadataMap: new Map() as any,
|
|
1931
|
+
listItemBinding: 'post',
|
|
1932
|
+
listIndexVar: 'postIndex',
|
|
1933
|
+
listSourceVar: 'posts',
|
|
1934
|
+
});
|
|
1935
|
+
const node: ComponentNode = {
|
|
1936
|
+
type: 'node',
|
|
1937
|
+
tag: 'img',
|
|
1938
|
+
attributes: { src: '/i/{{post.slug}}.jpg', alt: 'A' },
|
|
1939
|
+
children: [],
|
|
1940
|
+
} as any;
|
|
1941
|
+
const result = nodeToAstro(node, ctx);
|
|
1942
|
+
expect(result).toContain('src={`/i/${post.slug}.jpg`}');
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
test('list-item full-match img src with meta var resolves index', () => {
|
|
1946
|
+
const ctx = createContext({
|
|
1947
|
+
imageMetadataMap: new Map() as any,
|
|
1948
|
+
listItemBinding: 'item',
|
|
1949
|
+
listIndexVar: 'i',
|
|
1950
|
+
listSourceVar: 'items',
|
|
1951
|
+
});
|
|
1952
|
+
const node: ComponentNode = {
|
|
1953
|
+
type: 'node',
|
|
1954
|
+
tag: 'img',
|
|
1955
|
+
attributes: { src: '{{itemIndex}}', alt: 'A' },
|
|
1956
|
+
children: [],
|
|
1957
|
+
} as any;
|
|
1958
|
+
const result = nodeToAstro(node, ctx);
|
|
1959
|
+
expect(result).toContain('src={i}');
|
|
1960
|
+
});
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
// =========================================================================
|
|
1964
|
+
// i18n value emission
|
|
1965
|
+
// =========================================================================
|
|
1966
|
+
|
|
1967
|
+
describe('i18n values', () => {
|
|
1968
|
+
test('component def without locale wraps i18n object with r() resolver', () => {
|
|
1969
|
+
const ctx = createContext({ isComponentDef: true });
|
|
1970
|
+
const node = { _i18n: true, en: 'Hello', fr: 'Bonjour' } as any;
|
|
1971
|
+
const result = nodeToAstro(node, ctx);
|
|
1972
|
+
expect(result).toContain('{r({"_i18n":true,"en":"Hello","fr":"Bonjour"})}');
|
|
1973
|
+
expect(ctx.needsI18nResolver).toBe(true);
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
test('i18n value resolving to a non-string is stringified', () => {
|
|
1977
|
+
const ctx = createContext({ locale: 'en' });
|
|
1978
|
+
const node = { _i18n: true, en: 42 } as any;
|
|
1979
|
+
const result = nodeToAstro(node, ctx);
|
|
1980
|
+
expect(result.trim()).toBe('42');
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
// An i18n object on a page emitted without a locale resolves through the
|
|
1984
|
+
// default locale (resolveTranslation falls back exact → default → first
|
|
1985
|
+
// available → empty) instead of stringifying to "[object Object]".
|
|
1986
|
+
test('page context without locale resolves i18n via default locale', () => {
|
|
1987
|
+
const ctx = createContext({ isComponentDef: false });
|
|
1988
|
+
const node = { _i18n: true, en: 'Hello' } as any;
|
|
1989
|
+
const result = nodeToAstro(node, ctx);
|
|
1990
|
+
expect(result.trim()).toBe('Hello');
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
test('page context without locale honors i18nConfig default locale', () => {
|
|
1994
|
+
const ctx = createContext({
|
|
1995
|
+
isComponentDef: false,
|
|
1996
|
+
i18nConfig: {
|
|
1997
|
+
defaultLocale: 'fr',
|
|
1998
|
+
locales: [
|
|
1999
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
|
|
2000
|
+
{ code: 'fr', name: 'French', nativeName: 'Français', langTag: 'fr-FR' },
|
|
2001
|
+
],
|
|
2002
|
+
},
|
|
2003
|
+
});
|
|
2004
|
+
const node = { _i18n: true, en: 'Hello', fr: 'Bonjour' } as any;
|
|
2005
|
+
const result = nodeToAstro(node, ctx);
|
|
2006
|
+
expect(result.trim()).toBe('Bonjour');
|
|
2007
|
+
});
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
// =========================================================================
|
|
2011
|
+
// Prop value formatting edge cases
|
|
2012
|
+
// =========================================================================
|
|
2013
|
+
|
|
2014
|
+
describe('prop value formatting', () => {
|
|
2015
|
+
test('null and undefined props emit {undefined}', () => {
|
|
2016
|
+
const ctx = createContext();
|
|
2017
|
+
const node: ComponentNode = {
|
|
2018
|
+
type: 'component',
|
|
2019
|
+
component: 'Widget',
|
|
2020
|
+
props: { a: null, b: undefined },
|
|
2021
|
+
children: [],
|
|
2022
|
+
} as any;
|
|
2023
|
+
const result = nodeToAstro(node, ctx);
|
|
2024
|
+
expect(result).toContain('a={undefined}');
|
|
2025
|
+
expect(result).toContain('b={undefined}');
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
test('object and array props emit JSON literals', () => {
|
|
2029
|
+
const ctx = createContext();
|
|
2030
|
+
const node: ComponentNode = {
|
|
2031
|
+
type: 'component',
|
|
2032
|
+
component: 'Widget',
|
|
2033
|
+
props: { cfg: { a: 1 }, list: [1, 2] },
|
|
2034
|
+
children: [],
|
|
2035
|
+
} as any;
|
|
2036
|
+
const result = nodeToAstro(node, ctx);
|
|
2037
|
+
expect(result).toContain('cfg={{"a":1}}');
|
|
2038
|
+
expect(result).toContain('list={[1,2]}');
|
|
2039
|
+
});
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
// =========================================================================
|
|
2043
|
+
// Slug-mapped link localization
|
|
2044
|
+
// =========================================================================
|
|
2045
|
+
|
|
2046
|
+
describe('slug-mapped link localization', () => {
|
|
2047
|
+
test('translates internal href through slug mappings', () => {
|
|
2048
|
+
const ctx = createContext({
|
|
2049
|
+
locale: 'fr',
|
|
2050
|
+
i18nDefaultLocale: 'en',
|
|
2051
|
+
slugMappings: [{ pageId: 'about', slugs: { en: 'about', fr: 'a-propos' } }],
|
|
2052
|
+
});
|
|
2053
|
+
const node: ComponentNode = {
|
|
2054
|
+
type: 'link',
|
|
2055
|
+
href: '/about',
|
|
2056
|
+
children: ['About'],
|
|
2057
|
+
} as any;
|
|
2058
|
+
const result = nodeToAstro(node, ctx);
|
|
2059
|
+
expect(result).toContain('href="/fr/a-propos"');
|
|
2060
|
+
});
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
// =========================================================================
|
|
2064
|
+
// Embed node advanced branches
|
|
2065
|
+
// =========================================================================
|
|
2066
|
+
|
|
2067
|
+
describe('embed node advanced', () => {
|
|
2068
|
+
test('HTML mapping emits Fragment set:html with prop ref in component def', () => {
|
|
2069
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2070
|
+
const node: ComponentNode = {
|
|
2071
|
+
type: 'embed',
|
|
2072
|
+
html: { _mapping: true, prop: 'content' },
|
|
2073
|
+
children: [],
|
|
2074
|
+
} as any;
|
|
2075
|
+
const result = nodeToAstro(node, ctx);
|
|
2076
|
+
expect(result).toContain('class="oem"');
|
|
2077
|
+
expect(result).toContain('Fragment set:html={content}');
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// NOTE: pinned degradation — an HTML mapping on a page (outside component
|
|
2081
|
+
// def) falls through to the literal path with html='' and emits an empty
|
|
2082
|
+
// embed. This matches SSR runtime, where resolveHtmlMapping() receives no
|
|
2083
|
+
// props at page scope and resolves to '' (ssrRenderer embed branch).
|
|
2084
|
+
test('HTML mapping in page context emits empty embed (pinned)', () => {
|
|
2085
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2086
|
+
const node: ComponentNode = {
|
|
2087
|
+
type: 'embed',
|
|
2088
|
+
html: { _mapping: true, prop: 'content' },
|
|
2089
|
+
children: [],
|
|
2090
|
+
} as any;
|
|
2091
|
+
const result = nodeToAstro(node, ctx);
|
|
2092
|
+
expect(result).toContain('Fragment set:html={``}');
|
|
2093
|
+
expect(result).not.toContain('content');
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
test('template html with static classes prefixes oem to class', () => {
|
|
2097
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2098
|
+
const node: ComponentNode = {
|
|
2099
|
+
type: 'embed',
|
|
2100
|
+
html: '{{icon}}',
|
|
2101
|
+
style: { display: 'flex' },
|
|
2102
|
+
children: [],
|
|
2103
|
+
} as any;
|
|
2104
|
+
const result = nodeToAstro(node, ctx);
|
|
2105
|
+
expect(result).toContain('class="oem flex"');
|
|
2106
|
+
expect(result).toContain('Fragment set:html={icon}');
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
test('literal embed with static classes prefixes oem to class', () => {
|
|
2110
|
+
const ctx = createContext();
|
|
2111
|
+
const node: ComponentNode = {
|
|
2112
|
+
type: 'embed',
|
|
2113
|
+
html: '<b>x</b>',
|
|
2114
|
+
style: { display: 'flex' },
|
|
2115
|
+
children: [],
|
|
2116
|
+
} as any;
|
|
2117
|
+
const result = nodeToAstro(node, ctx);
|
|
2118
|
+
expect(result).toContain('class="oem flex"');
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
test('mixed-template embed html falls through to literal emission (pinned)', () => {
|
|
2122
|
+
// Only a full-match {{expr}} is resolved; mixed content keeps the raw
|
|
2123
|
+
// {{...}} inside the emitted template literal.
|
|
2124
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2125
|
+
const node: ComponentNode = {
|
|
2126
|
+
type: 'embed',
|
|
2127
|
+
html: '<i>{{icon}}</i>',
|
|
2128
|
+
children: [],
|
|
2129
|
+
} as any;
|
|
2130
|
+
const result = nodeToAstro(node, ctx);
|
|
2131
|
+
expect(result).toContain('Fragment set:html={`<i>{{icon}}</i>`}');
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test('embed with interactive styles registers element class', () => {
|
|
2135
|
+
const collected = new Map();
|
|
2136
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2137
|
+
const node: ComponentNode = {
|
|
2138
|
+
type: 'embed',
|
|
2139
|
+
html: '<b>x</b>',
|
|
2140
|
+
label: 'Embed1',
|
|
2141
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
2142
|
+
children: [],
|
|
2143
|
+
} as any;
|
|
2144
|
+
const result = nodeToAstro(node, ctx);
|
|
2145
|
+
expect(result).toContain('p_test-page_embed1');
|
|
2146
|
+
expect(collected.has('p_test-page_embed1')).toBe(true);
|
|
2147
|
+
});
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
// =========================================================================
|
|
2151
|
+
// Link node advanced branches
|
|
2152
|
+
// =========================================================================
|
|
2153
|
+
|
|
2154
|
+
describe('link node advanced', () => {
|
|
2155
|
+
test('link mapping in component def emits href coercion over prop', () => {
|
|
2156
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2157
|
+
const node: ComponentNode = {
|
|
2158
|
+
type: 'link',
|
|
2159
|
+
href: { _mapping: true, prop: 'link' },
|
|
2160
|
+
children: ['Go'],
|
|
2161
|
+
} as any;
|
|
2162
|
+
const result = nodeToAstro(node, ctx);
|
|
2163
|
+
expect(result).toContain(`href={(typeof link === 'string' ? link : link?.href) ?? "#"}`);
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
// NOTE: pinned known broken-link mode — a link mapping on a page emits a
|
|
2167
|
+
// static href="#" because the prop does not exist at page scope. This
|
|
2168
|
+
// matches SSR runtime (non-string href falls back to '#'); the real fix
|
|
2169
|
+
// is upstream in the link binding, not in this emitter.
|
|
2170
|
+
test('link mapping in page context emits static href="#" (pinned)', () => {
|
|
2171
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2172
|
+
const node: ComponentNode = {
|
|
2173
|
+
type: 'link',
|
|
2174
|
+
href: { _mapping: true, prop: 'link' },
|
|
2175
|
+
children: ['Go'],
|
|
2176
|
+
} as any;
|
|
2177
|
+
const result = nodeToAstro(node, ctx);
|
|
2178
|
+
expect(result).toContain('href="#"');
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
test('mixed-template href becomes template literal in component def', () => {
|
|
2182
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2183
|
+
const node: ComponentNode = {
|
|
2184
|
+
type: 'link',
|
|
2185
|
+
href: '/blog/{{slug}}',
|
|
2186
|
+
children: ['Read'],
|
|
2187
|
+
} as any;
|
|
2188
|
+
const result = nodeToAstro(node, ctx);
|
|
2189
|
+
expect(result).toContain('href={`/blog/${slug}`}');
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
test('mixed-template href with link-type prop coerces inside literal', () => {
|
|
2193
|
+
const ctx = createContext({
|
|
2194
|
+
isComponentDef: true,
|
|
2195
|
+
componentProps: { cta: { type: 'link', label: 'CTA' } as any },
|
|
2196
|
+
});
|
|
2197
|
+
const node: ComponentNode = {
|
|
2198
|
+
type: 'link',
|
|
2199
|
+
href: '{{cta}}?utm=1',
|
|
2200
|
+
children: ['Go'],
|
|
2201
|
+
} as any;
|
|
2202
|
+
const result = nodeToAstro(node, ctx);
|
|
2203
|
+
expect(result).toContain('href={`${(typeof cta === \'string\' ? cta : cta?.href) ?? "#"}?utm=1`}');
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
test('CMS mixed-template href becomes template literal', () => {
|
|
2207
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
|
|
2208
|
+
const node: ComponentNode = {
|
|
2209
|
+
type: 'link',
|
|
2210
|
+
href: '/posts/{{cms.slug}}',
|
|
2211
|
+
children: ['Read'],
|
|
2212
|
+
} as any;
|
|
2213
|
+
const result = nodeToAstro(node, ctx);
|
|
2214
|
+
expect(result).toContain('href={`/posts/${entry.data.slug}`}');
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
test('static classes get olink prefix', () => {
|
|
2218
|
+
const ctx = createContext();
|
|
2219
|
+
const node: ComponentNode = {
|
|
2220
|
+
type: 'link',
|
|
2221
|
+
href: '/x',
|
|
2222
|
+
style: { display: 'flex' },
|
|
2223
|
+
children: ['x'],
|
|
2224
|
+
} as any;
|
|
2225
|
+
const result = nodeToAstro(node, ctx);
|
|
2226
|
+
expect(result).toContain('class="olink flex"');
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
test('link with interactive styles registers element class', () => {
|
|
2230
|
+
const collected = new Map();
|
|
2231
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2232
|
+
const node: ComponentNode = {
|
|
2233
|
+
type: 'link',
|
|
2234
|
+
href: '/x',
|
|
2235
|
+
label: 'NavLink',
|
|
2236
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
2237
|
+
children: ['x'],
|
|
2238
|
+
} as any;
|
|
2239
|
+
const result = nodeToAstro(node, ctx);
|
|
2240
|
+
expect(result).toContain('p_test-page_navlink');
|
|
2241
|
+
expect(collected.has('p_test-page_navlink')).toBe(true);
|
|
2242
|
+
});
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
// =========================================================================
|
|
2246
|
+
// Component instance advanced (props)
|
|
2247
|
+
// =========================================================================
|
|
2248
|
+
|
|
2249
|
+
describe('component instance advanced props', () => {
|
|
2250
|
+
test('mixed-template string prop becomes template literal', () => {
|
|
2251
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2252
|
+
const node: ComponentNode = {
|
|
2253
|
+
type: 'component',
|
|
2254
|
+
component: 'Card',
|
|
2255
|
+
props: { title: 'Hello {{name}}!' },
|
|
2256
|
+
children: [],
|
|
2257
|
+
} as any;
|
|
2258
|
+
const result = nodeToAstro(node, ctx);
|
|
2259
|
+
expect(result).toContain('title={`Hello ${name}!`}');
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
test('full-match prop rewrites item vars and meta vars in list context', () => {
|
|
2263
|
+
const ctx = createContext({
|
|
2264
|
+
isComponentDef: true,
|
|
2265
|
+
listItemBinding: 'card',
|
|
2266
|
+
listIndexVar: 'cardIndex',
|
|
2267
|
+
listSourceVar: 'cards',
|
|
2268
|
+
});
|
|
2269
|
+
const node: ComponentNode = {
|
|
2270
|
+
type: 'component',
|
|
2271
|
+
component: 'Badge',
|
|
2272
|
+
props: { text: '{{item.label}}', index: '{{itemIndex}}' },
|
|
2273
|
+
children: [],
|
|
2274
|
+
} as any;
|
|
2275
|
+
const result = nodeToAstro(node, ctx);
|
|
2276
|
+
expect(result).toContain('text={card.label}');
|
|
2277
|
+
expect(result).toContain('index={cardIndex}');
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
test('CMS full-match and mixed props transform to entry data access', () => {
|
|
2281
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
|
|
2282
|
+
const node: ComponentNode = {
|
|
2283
|
+
type: 'component',
|
|
2284
|
+
component: 'Card',
|
|
2285
|
+
props: { title: '{{cms.title}}', sub: 'By {{cms.author}}' },
|
|
2286
|
+
children: [],
|
|
2287
|
+
} as any;
|
|
2288
|
+
const result = nodeToAstro(node, ctx);
|
|
2289
|
+
expect(result).toContain('title={entry.data.title}');
|
|
2290
|
+
expect(result).toContain('sub={`By ${entry.data.author}`}');
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
test('CMS props honor cmsWrapFn', () => {
|
|
2294
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry', cmsWrapFn: 'r' });
|
|
2295
|
+
const node: ComponentNode = {
|
|
2296
|
+
type: 'component',
|
|
2297
|
+
component: 'Card',
|
|
2298
|
+
props: { title: '{{cms.title}}' },
|
|
2299
|
+
children: [],
|
|
2300
|
+
} as any;
|
|
2301
|
+
const result = nodeToAstro(node, ctx);
|
|
2302
|
+
expect(result).toContain('title={r(entry.data.title)}');
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
test('rich-text prop is passed as escaped template literal', () => {
|
|
2306
|
+
const ctx = createContext({
|
|
2307
|
+
globalComponents: {
|
|
2308
|
+
Card: {
|
|
2309
|
+
component: {
|
|
2310
|
+
interface: { content: { type: 'rich-text', label: 'Content' } },
|
|
2311
|
+
},
|
|
2312
|
+
},
|
|
2313
|
+
} as any,
|
|
2314
|
+
});
|
|
2315
|
+
const node: ComponentNode = {
|
|
2316
|
+
type: 'component',
|
|
2317
|
+
component: 'Card',
|
|
2318
|
+
props: { content: '<p>Hi `tick`</p>' },
|
|
2319
|
+
children: [],
|
|
2320
|
+
} as any;
|
|
2321
|
+
const result = nodeToAstro(node, ctx);
|
|
2322
|
+
expect(result).toContain('content={`<p>Hi \\`tick\\`</p>`}');
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
test('CMS consumer component gets cms entry threaded as prop', () => {
|
|
2326
|
+
const ctx = createContext({
|
|
2327
|
+
cmsMode: true,
|
|
2328
|
+
cmsEntryBinding: 'entry',
|
|
2329
|
+
cmsConsumers: new Set(['Card']),
|
|
2330
|
+
});
|
|
2331
|
+
const node: ComponentNode = {
|
|
2332
|
+
type: 'component',
|
|
2333
|
+
component: 'Card',
|
|
2334
|
+
children: [],
|
|
2335
|
+
} as any;
|
|
2336
|
+
const result = nodeToAstro(node, ctx);
|
|
2337
|
+
expect(result).toContain('cms={entry}');
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
test('CMS consumer with explicit cms prop is not overridden', () => {
|
|
2341
|
+
const ctx = createContext({
|
|
2342
|
+
cmsMode: true,
|
|
2343
|
+
cmsEntryBinding: 'entry',
|
|
2344
|
+
cmsConsumers: new Set(['Card']),
|
|
2345
|
+
});
|
|
2346
|
+
const node: ComponentNode = {
|
|
2347
|
+
type: 'component',
|
|
2348
|
+
component: 'Card',
|
|
2349
|
+
props: { cms: 'custom' },
|
|
2350
|
+
children: [],
|
|
2351
|
+
} as any;
|
|
2352
|
+
const result = nodeToAstro(node, ctx);
|
|
2353
|
+
expect(result).toContain('cms="custom"');
|
|
2354
|
+
expect(result).not.toContain('cms={entry}');
|
|
2355
|
+
});
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
// =========================================================================
|
|
2359
|
+
// Component instance style overrides
|
|
2360
|
+
// =========================================================================
|
|
2361
|
+
|
|
2362
|
+
describe('component instance style overrides', () => {
|
|
2363
|
+
test('instance style emits element class and collects unlayered rule', () => {
|
|
2364
|
+
const collected = new Map();
|
|
2365
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2366
|
+
const node: ComponentNode = {
|
|
2367
|
+
type: 'component',
|
|
2368
|
+
component: 'Card',
|
|
2369
|
+
label: 'MyCard',
|
|
2370
|
+
style: { base: { padding: '10px' } },
|
|
2371
|
+
children: [],
|
|
2372
|
+
} as any;
|
|
2373
|
+
const result = nodeToAstro(node, ctx);
|
|
2374
|
+
expect(result).toContain('class="p_test-page_mycard"');
|
|
2375
|
+
const rules = collected.get('p_test-page_mycard');
|
|
2376
|
+
expect(rules).toHaveLength(1);
|
|
2377
|
+
expect(rules[0].style).toEqual({ base: { padding: '10px' } });
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
test('instance style merges under component root style per breakpoint', () => {
|
|
2381
|
+
const collected = new Map();
|
|
2382
|
+
const ctx = createContext({
|
|
2383
|
+
collectedInteractiveStyles: collected as any,
|
|
2384
|
+
globalComponents: {
|
|
2385
|
+
Card: {
|
|
2386
|
+
component: {
|
|
2387
|
+
structure: { type: 'node', tag: 'div', style: { base: { color: 'red', padding: '4px' } } },
|
|
2388
|
+
},
|
|
2389
|
+
},
|
|
2390
|
+
} as any,
|
|
2391
|
+
});
|
|
2392
|
+
const node: ComponentNode = {
|
|
2393
|
+
type: 'component',
|
|
2394
|
+
component: 'Card',
|
|
2395
|
+
label: 'MyCard',
|
|
2396
|
+
style: { base: { padding: '10px' } },
|
|
2397
|
+
children: [],
|
|
2398
|
+
} as any;
|
|
2399
|
+
nodeToAstro(node, ctx);
|
|
2400
|
+
const rules = collected.get('p_test-page_mycard');
|
|
2401
|
+
expect(rules[0].style.base).toEqual({ color: 'red', padding: '10px' });
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
test('component root style is read from props.style for instance roots', () => {
|
|
2405
|
+
const collected = new Map();
|
|
2406
|
+
const ctx = createContext({
|
|
2407
|
+
collectedInteractiveStyles: collected as any,
|
|
2408
|
+
globalComponents: {
|
|
2409
|
+
Card: {
|
|
2410
|
+
component: {
|
|
2411
|
+
structure: {
|
|
2412
|
+
type: 'component',
|
|
2413
|
+
component: 'Inner',
|
|
2414
|
+
props: { style: { base: { color: 'blue' } } },
|
|
2415
|
+
},
|
|
2416
|
+
},
|
|
2417
|
+
},
|
|
2418
|
+
} as any,
|
|
2419
|
+
});
|
|
2420
|
+
const node: ComponentNode = {
|
|
2421
|
+
type: 'component',
|
|
2422
|
+
component: 'Card',
|
|
2423
|
+
label: 'MyCard',
|
|
2424
|
+
style: { base: { padding: '10px' } },
|
|
2425
|
+
children: [],
|
|
2426
|
+
} as any;
|
|
2427
|
+
nodeToAstro(node, ctx);
|
|
2428
|
+
const rules = collected.get('p_test-page_mycard');
|
|
2429
|
+
expect(rules[0].style.base).toEqual({ color: 'blue', padding: '10px' });
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
test('template-expression values are stripped from override CSS', () => {
|
|
2433
|
+
const collected = new Map();
|
|
2434
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2435
|
+
const node: ComponentNode = {
|
|
2436
|
+
type: 'component',
|
|
2437
|
+
component: 'Card',
|
|
2438
|
+
label: 'MyCard',
|
|
2439
|
+
style: { base: { padding: '{{pad}}', margin: '5px' } },
|
|
2440
|
+
children: [],
|
|
2441
|
+
} as any;
|
|
2442
|
+
nodeToAstro(node, ctx);
|
|
2443
|
+
const rules = collected.get('p_test-page_mycard');
|
|
2444
|
+
expect(rules[0].style.base).toEqual({ margin: '5px' });
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
test('all-template override emits no class and no rule', () => {
|
|
2448
|
+
const collected = new Map();
|
|
2449
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2450
|
+
const node: ComponentNode = {
|
|
2451
|
+
type: 'component',
|
|
2452
|
+
component: 'Card',
|
|
2453
|
+
style: { base: { padding: '{{pad}}' } },
|
|
2454
|
+
children: [],
|
|
2455
|
+
} as any;
|
|
2456
|
+
const result = nodeToAstro(node, ctx);
|
|
2457
|
+
expect(result).not.toContain('class=');
|
|
2458
|
+
expect(collected.size).toBe(0);
|
|
2459
|
+
});
|
|
2460
|
+
|
|
2461
|
+
test('empty responsive override shape is ignored', () => {
|
|
2462
|
+
const collected = new Map();
|
|
2463
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2464
|
+
const node: ComponentNode = {
|
|
2465
|
+
type: 'component',
|
|
2466
|
+
component: 'Card',
|
|
2467
|
+
style: { base: {}, tablet: {}, mobile: {} },
|
|
2468
|
+
children: [],
|
|
2469
|
+
} as any;
|
|
2470
|
+
const result = nodeToAstro(node, ctx);
|
|
2471
|
+
expect(result).not.toContain('class=');
|
|
2472
|
+
expect(collected.size).toBe(0);
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
test('instance interactive styles fold into the same element class', () => {
|
|
2476
|
+
const collected = new Map();
|
|
2477
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2478
|
+
const node: ComponentNode = {
|
|
2479
|
+
type: 'component',
|
|
2480
|
+
component: 'Card',
|
|
2481
|
+
label: 'HoverCard',
|
|
2482
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.7' } }],
|
|
2483
|
+
children: [],
|
|
2484
|
+
} as any;
|
|
2485
|
+
const result = nodeToAstro(node, ctx);
|
|
2486
|
+
expect(result).toContain('class="p_test-page_hovercard"');
|
|
2487
|
+
const rules = collected.get('p_test-page_hovercard');
|
|
2488
|
+
expect(rules).toHaveLength(1);
|
|
2489
|
+
expect(rules[0].postfix).toBe(':hover');
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test('flat instance style (legacy) is collected as-is', () => {
|
|
2493
|
+
const collected = new Map();
|
|
2494
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2495
|
+
const node: ComponentNode = {
|
|
2496
|
+
type: 'component',
|
|
2497
|
+
component: 'Card',
|
|
2498
|
+
label: 'FlatCard',
|
|
2499
|
+
style: { padding: '8px' },
|
|
2500
|
+
children: [],
|
|
2501
|
+
} as any;
|
|
2502
|
+
nodeToAstro(node, ctx);
|
|
2503
|
+
const rules = collected.get('p_test-page_flatcard');
|
|
2504
|
+
expect(rules[0].style).toEqual({ padding: '8px' });
|
|
2505
|
+
});
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
// =========================================================================
|
|
2509
|
+
// Image type node with metadata (non-static src)
|
|
2510
|
+
// =========================================================================
|
|
2511
|
+
|
|
2512
|
+
describe('image type node with metadata', () => {
|
|
2513
|
+
test('metadata without avif/blur emits plain img with srcset and dimensions', () => {
|
|
2514
|
+
const ctx = createContext({
|
|
2515
|
+
imageMetadataMap: new Map([
|
|
2516
|
+
['/photos/x.jpg', { width: 800, height: 600, srcset: '/photos/x-400.webp 400w' }],
|
|
2517
|
+
]) as any,
|
|
2518
|
+
});
|
|
2519
|
+
const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
|
|
2520
|
+
const result = nodeToAstro(node, ctx);
|
|
2521
|
+
expect(result).toContain('<img');
|
|
2522
|
+
expect(result).not.toContain('<picture');
|
|
2523
|
+
expect(result).toContain('width="800"');
|
|
2524
|
+
expect(result).toContain('height="600"');
|
|
2525
|
+
expect(result).toContain('srcset="/photos/x-400.webp 400w"');
|
|
2526
|
+
expect(result).toContain('sizes="100vw"');
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
test('metadata with avif emits picture with both sources', () => {
|
|
2530
|
+
const ctx = createContext({
|
|
2531
|
+
imageMetadataMap: new Map([
|
|
2532
|
+
[
|
|
2533
|
+
'/photos/x.jpg',
|
|
2534
|
+
{ width: 800, height: 600, srcset: '/photos/x.webp 400w', avifSrcset: '/photos/x.avif 400w' },
|
|
2535
|
+
],
|
|
2536
|
+
]) as any,
|
|
2537
|
+
});
|
|
2538
|
+
const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
|
|
2539
|
+
const result = nodeToAstro(node, ctx);
|
|
2540
|
+
expect(result).toContain('<picture');
|
|
2541
|
+
expect(result).toContain('type="image/avif"');
|
|
2542
|
+
expect(result).toContain('type="image/webp"');
|
|
2543
|
+
expect(result).toContain('class="block w-full h-full"');
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
test('metadata with blur but no avif wraps img in picture with blur background', () => {
|
|
2547
|
+
const ctx = createContext({
|
|
2548
|
+
imageMetadataMap: new Map([
|
|
2549
|
+
[
|
|
2550
|
+
'/photos/x.jpg',
|
|
2551
|
+
{ width: 800, height: 600, srcset: '/photos/x.webp 400w', blurHash: 'data:image/png;base64,Z' },
|
|
2552
|
+
],
|
|
2553
|
+
]) as any,
|
|
2554
|
+
});
|
|
2555
|
+
const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
|
|
2556
|
+
const result = nodeToAstro(node, ctx);
|
|
2557
|
+
expect(result).toContain('<picture');
|
|
2558
|
+
expect(result).toContain('background-image:url(data:image/png;base64,Z)');
|
|
2559
|
+
expect(result).toContain('onload="this.parentElement.style.backgroundImage=\'\'"');
|
|
2560
|
+
const imgLine = result.split('\n').find((l) => l.includes('<img'));
|
|
2561
|
+
expect(imgLine).toContain('srcset=');
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
test('img-specific classes split onto inner img in picture wrapper', () => {
|
|
2565
|
+
const ctx = createContext({
|
|
2566
|
+
imageMetadataMap: new Map([
|
|
2567
|
+
[
|
|
2568
|
+
'/photos/x.jpg',
|
|
2569
|
+
{ width: 800, height: 600, srcset: '/photos/x.webp 400w', avifSrcset: '/photos/x.avif 400w' },
|
|
2570
|
+
],
|
|
2571
|
+
]) as any,
|
|
2572
|
+
});
|
|
2573
|
+
const node = {
|
|
2574
|
+
type: 'image',
|
|
2575
|
+
src: '/photos/x.jpg',
|
|
2576
|
+
alt: 'X',
|
|
2577
|
+
style: { display: 'flex', objectFit: 'cover' },
|
|
2578
|
+
} as any;
|
|
2579
|
+
const result = nodeToAstro(node, ctx);
|
|
2580
|
+
const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
|
|
2581
|
+
expect(pictureLine).toContain('flex');
|
|
2582
|
+
expect(pictureLine).not.toContain('object-cover');
|
|
2583
|
+
const imgLine = result.split('\n').find((l) => l.includes('<img'));
|
|
2584
|
+
expect(imgLine).toContain('object-cover');
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
test('image type node with interactive styles registers element class', () => {
|
|
2588
|
+
const collected = new Map();
|
|
2589
|
+
const ctx = createContext({ collectedInteractiveStyles: collected as any });
|
|
2590
|
+
const node = {
|
|
2591
|
+
type: 'image',
|
|
2592
|
+
src: '/photos/x.jpg',
|
|
2593
|
+
alt: 'X',
|
|
2594
|
+
label: 'Pic',
|
|
2595
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
2596
|
+
} as any;
|
|
2597
|
+
const result = nodeToAstro(node, ctx);
|
|
2598
|
+
expect(result).toContain('p_test-page_pic');
|
|
2599
|
+
expect(collected.has('p_test-page_pic')).toBe(true);
|
|
2600
|
+
});
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
// =========================================================================
|
|
2604
|
+
// Static <Picture> with dynamic class:list fallbacks
|
|
2605
|
+
// =========================================================================
|
|
2606
|
+
|
|
2607
|
+
describe('static Picture with dynamic classes', () => {
|
|
2608
|
+
const alignMapping = {
|
|
2609
|
+
_mapping: true,
|
|
2610
|
+
prop: 'align',
|
|
2611
|
+
values: { left: 'left', center: 'center' },
|
|
2612
|
+
};
|
|
2613
|
+
|
|
2614
|
+
test('dynamic class:list without blur wraps Picture in div', () => {
|
|
2615
|
+
const ctx = createContext({
|
|
2616
|
+
isComponentDef: true,
|
|
2617
|
+
imageMetadataMap: new Map([
|
|
2618
|
+
['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
|
|
2619
|
+
]) as any,
|
|
2620
|
+
});
|
|
2621
|
+
const node: ComponentNode = {
|
|
2622
|
+
type: 'node',
|
|
2623
|
+
tag: 'img',
|
|
2624
|
+
style: { textAlign: alignMapping },
|
|
2625
|
+
attributes: { src: '/images/hero.jpg', alt: 'Hero' },
|
|
2626
|
+
} as any;
|
|
2627
|
+
const result = nodeToAstro(node, ctx);
|
|
2628
|
+
expect(result).toContain('<div class:list={[');
|
|
2629
|
+
expect(result).toContain('<Picture src={imgHero}');
|
|
2630
|
+
expect(result).toContain('</div>');
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
test('dynamic class:list with blur wraps Picture in blurred div', () => {
|
|
2634
|
+
const ctx = createContext({
|
|
2635
|
+
isComponentDef: true,
|
|
2636
|
+
imageMetadataMap: new Map([
|
|
2637
|
+
[
|
|
2638
|
+
'/images/hero.jpg',
|
|
2639
|
+
{
|
|
2640
|
+
width: 100,
|
|
2641
|
+
height: 100,
|
|
2642
|
+
srcset: 'a.webp 100w',
|
|
2643
|
+
avifSrcset: 'a.avif 100w',
|
|
2644
|
+
blurHash: 'data:image/png;base64,B',
|
|
2645
|
+
},
|
|
2646
|
+
],
|
|
2647
|
+
]) as any,
|
|
2648
|
+
});
|
|
2649
|
+
const node: ComponentNode = {
|
|
2650
|
+
type: 'node',
|
|
2651
|
+
tag: 'img',
|
|
2652
|
+
style: { textAlign: alignMapping },
|
|
2653
|
+
attributes: { src: '/images/hero.jpg', alt: 'Hero' },
|
|
2654
|
+
} as any;
|
|
2655
|
+
const result = nodeToAstro(node, ctx);
|
|
2656
|
+
expect(result).toContain('<div class:list={[');
|
|
2657
|
+
expect(result).toContain('background-image:url(data:image/png;base64,B)');
|
|
2658
|
+
expect(result).toContain("this.parentElement.parentElement.style.backgroundImage=''");
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
test('img-specific classes land on inner Picture class, layout on pictureAttributes', () => {
|
|
2662
|
+
const ctx = createContext({
|
|
2663
|
+
imageMetadataMap: new Map([
|
|
2664
|
+
['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
|
|
2665
|
+
]) as any,
|
|
2666
|
+
});
|
|
2667
|
+
const node: ComponentNode = {
|
|
2668
|
+
type: 'node',
|
|
2669
|
+
tag: 'img',
|
|
2670
|
+
style: { display: 'flex', objectFit: 'cover' },
|
|
2671
|
+
attributes: { src: '/images/hero.jpg', alt: 'Hero' },
|
|
2672
|
+
} as any;
|
|
2673
|
+
const result = nodeToAstro(node, ctx);
|
|
2674
|
+
expect(result).toContain('pictureAttributes={{class: "flex"}}');
|
|
2675
|
+
expect(result).toContain('class="object-cover"');
|
|
2676
|
+
});
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
// =========================================================================
|
|
2680
|
+
// HTML img advanced (element class, dynamic class:list, dynamic styles)
|
|
2681
|
+
// =========================================================================
|
|
2682
|
+
|
|
2683
|
+
describe('HTML img advanced', () => {
|
|
2684
|
+
test('img with interactive styles registers element class on picture', () => {
|
|
2685
|
+
const collected = new Map();
|
|
2686
|
+
const ctx = createContext({
|
|
2687
|
+
collectedInteractiveStyles: collected as any,
|
|
2688
|
+
imageMetadataMap: new Map([
|
|
2689
|
+
['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
|
|
2690
|
+
]) as any,
|
|
2691
|
+
});
|
|
2692
|
+
const node: ComponentNode = {
|
|
2693
|
+
type: 'node',
|
|
2694
|
+
tag: 'img',
|
|
2695
|
+
label: 'HeroImg',
|
|
2696
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
2697
|
+
attributes: { src: '/test.jpg', alt: 'Hero' },
|
|
2698
|
+
} as any;
|
|
2699
|
+
const result = nodeToAstro(node, ctx);
|
|
2700
|
+
expect(result).toContain('p_test-page_heroimg');
|
|
2701
|
+
expect(collected.has('p_test-page_heroimg')).toBe(true);
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
test('dynamic class:list goes on picture wrapper, img gets fill classes', () => {
|
|
2705
|
+
const ctx = createContext({
|
|
2706
|
+
isComponentDef: true,
|
|
2707
|
+
imageMetadataMap: new Map([
|
|
2708
|
+
['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
|
|
2709
|
+
]) as any,
|
|
2710
|
+
});
|
|
2711
|
+
const node: ComponentNode = {
|
|
2712
|
+
type: 'node',
|
|
2713
|
+
tag: 'img',
|
|
2714
|
+
style: { textAlign: { _mapping: true, prop: 'align', values: { left: 'left', center: 'center' } } },
|
|
2715
|
+
attributes: { src: '/test.jpg', alt: 'T' },
|
|
2716
|
+
} as any;
|
|
2717
|
+
const result = nodeToAstro(node, ctx);
|
|
2718
|
+
const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
|
|
2719
|
+
expect(pictureLine).toContain('class:list={[');
|
|
2720
|
+
const imgLine = result.split('\n').find((l) => l.includes('<img'));
|
|
2721
|
+
expect(imgLine).toContain('class="block w-full h-full"');
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
// The blur placeholder CSS must merge into a dynamic (template-literal)
|
|
2725
|
+
// style attr too — previously injectInlineStyle only handled a static
|
|
2726
|
+
// `style="..."` attribute and silently dropped the blur background while
|
|
2727
|
+
// the onload clear handler was still emitted.
|
|
2728
|
+
test('blur background merges into dynamic template-literal style attr', () => {
|
|
2729
|
+
const ctx = createContext({
|
|
2730
|
+
isComponentDef: true,
|
|
2731
|
+
imageMetadataMap: new Map([
|
|
2732
|
+
['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', blurHash: 'data:image/png;base64,B' }],
|
|
2733
|
+
]) as any,
|
|
2734
|
+
});
|
|
2735
|
+
const node: ComponentNode = {
|
|
2736
|
+
type: 'node',
|
|
2737
|
+
tag: 'img',
|
|
2738
|
+
style: { width: '{{w}}' },
|
|
2739
|
+
attributes: { src: '/test.jpg', alt: 'T' },
|
|
2740
|
+
} as any;
|
|
2741
|
+
const result = nodeToAstro(node, ctx);
|
|
2742
|
+
const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
|
|
2743
|
+
expect(pictureLine).toContain(
|
|
2744
|
+
'style={`width: ${w};background-image:url(data:image/png;base64,B);background-size:cover`}',
|
|
2745
|
+
);
|
|
2746
|
+
expect(result).toContain("this.parentElement.style.backgroundImage=''");
|
|
2747
|
+
// The merged attr stays balanced: one template literal, one JSX expression
|
|
2748
|
+
const styleAttr = pictureLine!.slice(pictureLine!.indexOf('style={'));
|
|
2749
|
+
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
|
|
2750
|
+
expect(count(pictureLine!, /`/g)).toBe(2);
|
|
2751
|
+
expect(styleAttr.startsWith('style={`')).toBe(true);
|
|
2752
|
+
expect(styleAttr.trimEnd().endsWith('`}>')).toBe(true);
|
|
2753
|
+
});
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
// =========================================================================
|
|
2757
|
+
// Static image import naming
|
|
2758
|
+
// =========================================================================
|
|
2759
|
+
|
|
2760
|
+
describe('static image import naming', () => {
|
|
2761
|
+
test('nested image path camel-cases into import variable', () => {
|
|
2762
|
+
const ctx = createContext({
|
|
2763
|
+
imageMetadataMap: new Map([
|
|
2764
|
+
['/images/sub/hero-photo.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }],
|
|
2765
|
+
]) as any,
|
|
2766
|
+
});
|
|
2767
|
+
const node: ComponentNode = {
|
|
2768
|
+
type: 'node',
|
|
2769
|
+
tag: 'img',
|
|
2770
|
+
attributes: { src: '/images/sub/hero-photo.jpg', alt: 'H' },
|
|
2771
|
+
} as any;
|
|
2772
|
+
const result = nodeToAstro(node, ctx);
|
|
2773
|
+
expect(result).toContain('src={imgSubHeroPhoto}');
|
|
2774
|
+
expect(ctx.imageImports?.get('imgSubHeroPhoto')).toBe('../assets/images/sub/hero-photo.jpg');
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
test('var name collision appends numeric suffix', () => {
|
|
2778
|
+
const ctx = createContext({
|
|
2779
|
+
imageMetadataMap: new Map([
|
|
2780
|
+
['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }],
|
|
2781
|
+
['/images/hero.png', { width: 100, height: 100, srcset: 'b.webp 100w' }],
|
|
2782
|
+
]) as any,
|
|
2783
|
+
});
|
|
2784
|
+
const nodes: ComponentNode[] = [
|
|
2785
|
+
{ type: 'node', tag: 'img', attributes: { src: '/images/hero.jpg', alt: 'A' } },
|
|
2786
|
+
{ type: 'node', tag: 'img', attributes: { src: '/images/hero.png', alt: 'B' } },
|
|
2787
|
+
] as any;
|
|
2788
|
+
nodeToAstro(nodes, ctx);
|
|
2789
|
+
expect(ctx.imageImports?.get('imgHero')).toBe('../assets/images/hero.jpg');
|
|
2790
|
+
expect(ctx.imageImports?.get('imgHero2')).toBe('../assets/images/hero.png');
|
|
2791
|
+
});
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
// =========================================================================
|
|
2795
|
+
// List node slicing variants
|
|
2796
|
+
// =========================================================================
|
|
2797
|
+
|
|
2798
|
+
describe('list node slicing', () => {
|
|
2799
|
+
test('offset only uses single-arg slice', () => {
|
|
2800
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2801
|
+
const node: ComponentNode = {
|
|
2802
|
+
type: 'list',
|
|
2803
|
+
sourceType: 'prop',
|
|
2804
|
+
source: '{{items}}',
|
|
2805
|
+
offset: 2,
|
|
2806
|
+
children: [],
|
|
2807
|
+
} as any;
|
|
2808
|
+
const result = nodeToAstro(node, ctx);
|
|
2809
|
+
expect(result).toContain('items.slice(2).map(');
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
test('limit only slices from zero', () => {
|
|
2813
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2814
|
+
const node: ComponentNode = {
|
|
2815
|
+
type: 'list',
|
|
2816
|
+
sourceType: 'prop',
|
|
2817
|
+
source: '{{items}}',
|
|
2818
|
+
limit: 5,
|
|
2819
|
+
children: [],
|
|
2820
|
+
} as any;
|
|
2821
|
+
const result = nodeToAstro(node, ctx);
|
|
2822
|
+
expect(result).toContain('items.slice(0, 5).map(');
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
test('custom itemAs renames map params and rewrites item vars', () => {
|
|
2826
|
+
const ctx = createContext({ isComponentDef: true });
|
|
2827
|
+
const node: ComponentNode = {
|
|
2828
|
+
type: 'list',
|
|
2829
|
+
sourceType: 'prop',
|
|
2830
|
+
source: '{{links}}',
|
|
2831
|
+
itemAs: 'link',
|
|
2832
|
+
children: ['{{item.text}}'],
|
|
2833
|
+
} as any;
|
|
2834
|
+
const result = nodeToAstro(node, ctx);
|
|
2835
|
+
expect(result).toContain('links.map((link, linkIndex) => (');
|
|
2836
|
+
expect(result).toContain('{link.text}');
|
|
2837
|
+
});
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
// =========================================================================
|
|
2841
|
+
// Collection list query chain
|
|
2842
|
+
// =========================================================================
|
|
2843
|
+
|
|
2844
|
+
describe('collection list query chain', () => {
|
|
2845
|
+
test('filter, sort desc, offset and limit chain in order', () => {
|
|
2846
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2847
|
+
const node: ComponentNode = {
|
|
2848
|
+
type: 'list',
|
|
2849
|
+
sourceType: 'collection',
|
|
2850
|
+
source: 'posts',
|
|
2851
|
+
filter: { field: 'category', operator: 'eq', value: 'news' },
|
|
2852
|
+
sort: { field: 'date', order: 'desc' },
|
|
2853
|
+
offset: 1,
|
|
2854
|
+
limit: 3,
|
|
2855
|
+
children: [],
|
|
2856
|
+
} as any;
|
|
2857
|
+
nodeToAstro(node, ctx);
|
|
2858
|
+
const line = ctx.frontmatterLines![0];
|
|
2859
|
+
expect(line).toContain('.filter(e => e.data.category === "news")');
|
|
2860
|
+
expect(line).toContain('.sort((a, b) => a.data.date > b.data.date ? -1 : 1)');
|
|
2861
|
+
expect(line).toContain('.slice(1, 4)');
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
test('array-form sort ascending', () => {
|
|
2865
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2866
|
+
const node: ComponentNode = {
|
|
2867
|
+
type: 'list',
|
|
2868
|
+
sourceType: 'collection',
|
|
2869
|
+
source: 'posts',
|
|
2870
|
+
sort: [{ field: 'title', order: 'asc' }],
|
|
2871
|
+
children: [],
|
|
2872
|
+
} as any;
|
|
2873
|
+
nodeToAstro(node, ctx);
|
|
2874
|
+
expect(ctx.frontmatterLines![0]).toContain('.sort((a, b) => a.data.title > b.data.title ? 1 : -1)');
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
test('entries are flattened with default _url expression', () => {
|
|
2878
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2879
|
+
const node: ComponentNode = {
|
|
2880
|
+
type: 'list',
|
|
2881
|
+
sourceType: 'collection',
|
|
2882
|
+
source: 'posts',
|
|
2883
|
+
children: [],
|
|
2884
|
+
} as any;
|
|
2885
|
+
nodeToAstro(node, ctx);
|
|
2886
|
+
expect(ctx.frontmatterLines![0]).toContain(
|
|
2887
|
+
'.then(items => items.map((e) => ({ ...e.data, _id: e.id, _url: `/posts/${e.data.slug ?? e.id}` })))',
|
|
2888
|
+
);
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
test('collectionUrlExpr overrides the flattened _url expression', () => {
|
|
2892
|
+
const ctx = createContext({
|
|
2893
|
+
isComponentDef: false,
|
|
2894
|
+
collectionUrlExpr: new Map([['posts', '`/blog/${e.data.slug}`']]) as any,
|
|
2895
|
+
});
|
|
2896
|
+
const node: ComponentNode = {
|
|
2897
|
+
type: 'list',
|
|
2898
|
+
sourceType: 'collection',
|
|
2899
|
+
source: 'posts',
|
|
2900
|
+
children: [],
|
|
2901
|
+
} as any;
|
|
2902
|
+
nodeToAstro(node, ctx);
|
|
2903
|
+
expect(ctx.frontmatterLines![0]).toContain('_url: `/blog/${e.data.slug}`');
|
|
2904
|
+
});
|
|
2905
|
+
|
|
2906
|
+
test('default itemAs is singularized collection name and children bind to it', () => {
|
|
2907
|
+
const ctx = createContext({ isComponentDef: false });
|
|
2908
|
+
const node: ComponentNode = {
|
|
2909
|
+
type: 'list',
|
|
2910
|
+
sourceType: 'collection',
|
|
2911
|
+
source: 'posts',
|
|
2912
|
+
children: ['{{post.title}}'],
|
|
2913
|
+
} as any;
|
|
2914
|
+
const result = nodeToAstro(node, ctx);
|
|
2915
|
+
expect(result).toContain('postsList.map((post, postIndex) => (');
|
|
2916
|
+
expect(result).toContain('{post.title}');
|
|
2917
|
+
});
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
// =========================================================================
|
|
2921
|
+
// Locale list advanced
|
|
2922
|
+
// =========================================================================
|
|
2923
|
+
|
|
2924
|
+
describe('locale list advanced', () => {
|
|
2925
|
+
const i18nConfig = {
|
|
2926
|
+
defaultLocale: 'en',
|
|
2927
|
+
locales: [
|
|
2928
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US', icon: '/flags/en.svg' },
|
|
2929
|
+
{ code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL', icon: '/flags/pl.svg' },
|
|
2930
|
+
],
|
|
2931
|
+
} as any;
|
|
2932
|
+
const slugMap = { en: '/about', pl: '/pl/o-nas' };
|
|
2933
|
+
|
|
2934
|
+
test('displayType code renders uppercase locale codes', () => {
|
|
2935
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2936
|
+
const node = { type: 'locale-list', displayType: 'code', showFlag: false, children: [] } as any;
|
|
2937
|
+
const result = nodeToAstro(node, ctx);
|
|
2938
|
+
expect(result).toContain('<div>EN</div>');
|
|
2939
|
+
expect(result).toContain('<div>PL</div>');
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
test('displayType name renders English names', () => {
|
|
2943
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2944
|
+
const node = { type: 'locale-list', displayType: 'name', showFlag: false, children: [] } as any;
|
|
2945
|
+
const result = nodeToAstro(node, ctx);
|
|
2946
|
+
expect(result).toContain('<div>English</div>');
|
|
2947
|
+
expect(result).toContain('<div>Polish</div>');
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
test('flags render with flagStyle classes when icons configured', () => {
|
|
2951
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2952
|
+
const node = { type: 'locale-list', flagStyle: { display: 'flex' }, children: [] } as any;
|
|
2953
|
+
const result = nodeToAstro(node, ctx);
|
|
2954
|
+
expect(result).toContain('<img src="/flags/en.svg" alt="English flag" class="flex">');
|
|
2955
|
+
expect(result).toContain('<img src="/flags/pl.svg" alt="Polski flag" class="flex">');
|
|
2956
|
+
});
|
|
2957
|
+
|
|
2958
|
+
test('separator span with separatorStyle classes between links', () => {
|
|
2959
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2960
|
+
const node = { type: 'locale-list', separatorStyle: { display: 'flex' }, children: [] } as any;
|
|
2961
|
+
const result = nodeToAstro(node, ctx);
|
|
2962
|
+
expect(result).toContain('<span class="flex"></span>');
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
test('showCurrent false hides the active locale link', () => {
|
|
2966
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2967
|
+
const node = { type: 'locale-list', showCurrent: false, showFlag: false, children: [] } as any;
|
|
2968
|
+
const result = nodeToAstro(node, ctx);
|
|
2969
|
+
expect(result).not.toContain('data-locale="en"');
|
|
2970
|
+
expect(result).toContain('data-locale="pl"');
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
test('itemStyle and activeItemStyle combine on the current link', () => {
|
|
2974
|
+
const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
|
|
2975
|
+
const node = {
|
|
2976
|
+
type: 'locale-list',
|
|
2977
|
+
showFlag: false,
|
|
2978
|
+
itemStyle: { display: 'flex' },
|
|
2979
|
+
activeItemStyle: { textAlign: 'center' },
|
|
2980
|
+
children: [],
|
|
2981
|
+
} as any;
|
|
2982
|
+
const result = nodeToAstro(node, ctx);
|
|
2983
|
+
const activeLine = result.split('\n').find((l) => l.includes('data-current="true"'));
|
|
2984
|
+
expect(activeLine).toContain('class="flex text-center"');
|
|
2985
|
+
const inactiveLine = result.split('\n').find((l) => l.includes('data-current="false"'));
|
|
2986
|
+
expect(inactiveLine).toContain('class="flex"');
|
|
2987
|
+
expect(inactiveLine).not.toContain('text-center');
|
|
2988
|
+
});
|
|
2989
|
+
|
|
2990
|
+
test('locale list with interactive styles registers element class', () => {
|
|
2991
|
+
const collected = new Map();
|
|
2992
|
+
const ctx = createContext({
|
|
2993
|
+
locale: 'en',
|
|
2994
|
+
i18nConfig,
|
|
2995
|
+
currentPageSlugMap: slugMap,
|
|
2996
|
+
collectedInteractiveStyles: collected as any,
|
|
2997
|
+
});
|
|
2998
|
+
const node = {
|
|
2999
|
+
type: 'locale-list',
|
|
3000
|
+
label: 'Switcher',
|
|
3001
|
+
interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
|
|
3002
|
+
children: [],
|
|
3003
|
+
} as any;
|
|
3004
|
+
const result = nodeToAstro(node, ctx);
|
|
3005
|
+
expect(result).toContain('p_test-page_switcher');
|
|
3006
|
+
expect(collected.has('p_test-page_switcher')).toBe(true);
|
|
3007
|
+
});
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
// =========================================================================
|
|
3011
|
+
// Conditional rendering advanced
|
|
3012
|
+
// =========================================================================
|
|
3013
|
+
|
|
3014
|
+
describe('conditional rendering advanced', () => {
|
|
3015
|
+
test('boolean mapping with single true value emits equality conditional', () => {
|
|
3016
|
+
const ctx = createContext({ isComponentDef: true });
|
|
3017
|
+
const node: ComponentNode = {
|
|
3018
|
+
type: 'node',
|
|
3019
|
+
tag: 'div',
|
|
3020
|
+
if: { _mapping: true, prop: 'show', values: { yes: true, no: false } },
|
|
3021
|
+
children: ['x'],
|
|
3022
|
+
} as any;
|
|
3023
|
+
const result = nodeToAstro(node, ctx);
|
|
3024
|
+
expect(result).toContain(`{show === 'yes' && (`);
|
|
3025
|
+
expect(result).toContain(')}');
|
|
3026
|
+
});
|
|
3027
|
+
|
|
3028
|
+
test('boolean mapping with multiple true values emits includes() conditional', () => {
|
|
3029
|
+
const ctx = createContext({ isComponentDef: true });
|
|
3030
|
+
const node: ComponentNode = {
|
|
3031
|
+
type: 'node',
|
|
3032
|
+
tag: 'div',
|
|
3033
|
+
if: { _mapping: true, prop: 'show', values: { a: true, b: true, c: false } },
|
|
3034
|
+
children: ['x'],
|
|
3035
|
+
} as any;
|
|
3036
|
+
const result = nodeToAstro(node, ctx);
|
|
3037
|
+
expect(result).toContain(`{['a', 'b'].includes(show) && (`);
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
// A boolean mapping with no true values is "never shown". emitIfOpen emits
|
|
3041
|
+
// only a `{/* hidden */}` comment (no `{cond && (` opener), so emitIfClose
|
|
3042
|
+
// must emit nothing — a dangling `)}` is unparseable Astro. Matches the
|
|
3043
|
+
// static `if: false` convention (hidden comment, element still emitted).
|
|
3044
|
+
test('boolean mapping with no true values emits balanced hidden comment', () => {
|
|
3045
|
+
const ctx = createContext({ isComponentDef: true });
|
|
3046
|
+
const node: ComponentNode = {
|
|
3047
|
+
type: 'node',
|
|
3048
|
+
tag: 'div',
|
|
3049
|
+
if: { _mapping: true, prop: 'show', values: { a: false } },
|
|
3050
|
+
children: ['x'],
|
|
3051
|
+
} as any;
|
|
3052
|
+
const result = nodeToAstro(node, ctx);
|
|
3053
|
+
expect(result).toContain('{/* hidden */}');
|
|
3054
|
+
expect(result.trimEnd().endsWith(')}')).toBe(false);
|
|
3055
|
+
expect(result).not.toContain(')}');
|
|
3056
|
+
// Braces and parens balance
|
|
3057
|
+
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
|
|
3058
|
+
expect(count(result, /\{/g)).toBe(count(result, /\}/g));
|
|
3059
|
+
expect(count(result, /\(/g)).toBe(count(result, /\)/g));
|
|
3060
|
+
});
|
|
3061
|
+
|
|
3062
|
+
test('list-item meta var conditional resolves index expression', () => {
|
|
3063
|
+
const ctx = createContext({
|
|
3064
|
+
listItemBinding: 'item',
|
|
3065
|
+
listIndexVar: 'i',
|
|
3066
|
+
listSourceVar: 'items',
|
|
3067
|
+
});
|
|
3068
|
+
const node: ComponentNode = {
|
|
3069
|
+
type: 'node',
|
|
3070
|
+
tag: 'div',
|
|
3071
|
+
if: '{{itemLast}}',
|
|
3072
|
+
children: ['x'],
|
|
3073
|
+
} as any;
|
|
3074
|
+
const result = nodeToAstro(node, ctx);
|
|
3075
|
+
expect(result).toContain('{(i === items.length - 1) && (');
|
|
3076
|
+
expect(result).toContain(')}');
|
|
3077
|
+
});
|
|
3078
|
+
|
|
3079
|
+
test('bare identifier string condition wraps in component def', () => {
|
|
3080
|
+
const ctx = createContext({ isComponentDef: true });
|
|
3081
|
+
const node: ComponentNode = {
|
|
3082
|
+
type: 'node',
|
|
3083
|
+
tag: 'div',
|
|
3084
|
+
if: 'showX',
|
|
3085
|
+
children: ['x'],
|
|
3086
|
+
} as any;
|
|
3087
|
+
const result = nodeToAstro(node, ctx);
|
|
3088
|
+
expect(result).toContain('{showX && (');
|
|
3089
|
+
expect(result).toContain(')}');
|
|
3090
|
+
});
|
|
3091
|
+
|
|
3092
|
+
test('non-identifier plain string condition is skipped entirely', () => {
|
|
3093
|
+
const ctx = createContext({ isComponentDef: true });
|
|
3094
|
+
const node: ComponentNode = {
|
|
3095
|
+
type: 'node',
|
|
3096
|
+
tag: 'div',
|
|
3097
|
+
if: 'not an identifier!',
|
|
3098
|
+
children: ['x'],
|
|
3099
|
+
} as any;
|
|
3100
|
+
const result = nodeToAstro(node, ctx);
|
|
3101
|
+
expect(result).not.toContain('&& (');
|
|
3102
|
+
expect(result).not.toContain(')}');
|
|
3103
|
+
expect(result).toContain('<div>');
|
|
3104
|
+
});
|
|
3105
|
+
|
|
3106
|
+
test('string condition in page context emits no conditional', () => {
|
|
3107
|
+
const ctx = createContext({ isComponentDef: false });
|
|
3108
|
+
const node: ComponentNode = {
|
|
3109
|
+
type: 'node',
|
|
3110
|
+
tag: 'div',
|
|
3111
|
+
if: '{{show}}',
|
|
3112
|
+
children: ['x'],
|
|
3113
|
+
} as any;
|
|
3114
|
+
const result = nodeToAstro(node, ctx);
|
|
3115
|
+
expect(result).not.toContain('&& (');
|
|
3116
|
+
expect(result).toContain('<div>');
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
test('mixed-content CMS condition emits no conditional (pinned)', () => {
|
|
3120
|
+
// Only a full-match {{cms.field}} condition is supported; mixed strings
|
|
3121
|
+
// fall through and render unconditionally.
|
|
3122
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
|
|
3123
|
+
const node: ComponentNode = {
|
|
3124
|
+
type: 'node',
|
|
3125
|
+
tag: 'div',
|
|
3126
|
+
if: 'show {{cms.flag}}',
|
|
3127
|
+
children: ['x'],
|
|
3128
|
+
} as any;
|
|
3129
|
+
const result = nodeToAstro(node, ctx);
|
|
3130
|
+
expect(result).not.toContain('&& (');
|
|
3131
|
+
expect(result).toContain('<div>');
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
test('CMS conditional honors custom entry binding', () => {
|
|
3135
|
+
const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'post' });
|
|
3136
|
+
const node: ComponentNode = {
|
|
3137
|
+
type: 'node',
|
|
3138
|
+
tag: 'div',
|
|
3139
|
+
if: '{{cms.featured}}',
|
|
3140
|
+
children: ['x'],
|
|
3141
|
+
} as any;
|
|
3142
|
+
const result = nodeToAstro(node, ctx);
|
|
3143
|
+
expect(result).toContain('{post.data.featured && (');
|
|
3144
|
+
expect(result).toContain(')}');
|
|
3145
|
+
});
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
// =========================================================================
|
|
3149
|
+
// Children forms (string / single node)
|
|
3150
|
+
// =========================================================================
|
|
3151
|
+
|
|
3152
|
+
describe('children forms', () => {
|
|
3153
|
+
test('string children value is emitted as text', () => {
|
|
3154
|
+
const ctx = createContext();
|
|
3155
|
+
const node: ComponentNode = {
|
|
3156
|
+
type: 'node',
|
|
3157
|
+
tag: 'div',
|
|
3158
|
+
children: 'plain string child',
|
|
3159
|
+
} as any;
|
|
3160
|
+
const result = nodeToAstro(node, ctx);
|
|
3161
|
+
expect(result).toContain('plain string child');
|
|
3162
|
+
expect(result).toContain('<div>');
|
|
3163
|
+
});
|
|
3164
|
+
|
|
3165
|
+
test('single node object children is emitted', () => {
|
|
3166
|
+
const ctx = createContext();
|
|
3167
|
+
const node: ComponentNode = {
|
|
3168
|
+
type: 'node',
|
|
3169
|
+
tag: 'div',
|
|
3170
|
+
children: { type: 'node', tag: 'span', children: ['inner'] },
|
|
3171
|
+
} as any;
|
|
3172
|
+
const result = nodeToAstro(node, ctx);
|
|
3173
|
+
expect(result).toContain('<span>');
|
|
3174
|
+
expect(result).toContain('inner');
|
|
3175
|
+
});
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
// =========================================================================
|
|
3179
|
+
// Sizes attribute inheritance
|
|
3180
|
+
// =========================================================================
|
|
3181
|
+
|
|
3182
|
+
describe('sizes attribute inheritance', () => {
|
|
3183
|
+
test('missing mobile width inherits from tablet', () => {
|
|
3184
|
+
const ctx = createContext({
|
|
3185
|
+
imageMetadataMap: new Map([['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }]]) as any,
|
|
3186
|
+
});
|
|
3187
|
+
const node: ComponentNode = {
|
|
3188
|
+
type: 'node',
|
|
3189
|
+
tag: 'img',
|
|
3190
|
+
attributes: { src: '/test.jpg', alt: 'T' },
|
|
3191
|
+
style: { base: { width: '700px' }, tablet: { width: '500px' } },
|
|
3192
|
+
children: [],
|
|
3193
|
+
} as any;
|
|
3194
|
+
const result = nodeToAstro(node, ctx);
|
|
3195
|
+
expect(result).toContain('sizes="(max-width: 540px) 500px, (max-width: 1024px) 500px, 700px"');
|
|
3196
|
+
});
|
|
3197
|
+
|
|
3198
|
+
test('responsive style with only base width uses base everywhere', () => {
|
|
3199
|
+
const ctx = createContext({
|
|
3200
|
+
imageMetadataMap: new Map([['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }]]) as any,
|
|
3201
|
+
});
|
|
3202
|
+
const node: ComponentNode = {
|
|
3203
|
+
type: 'node',
|
|
3204
|
+
tag: 'img',
|
|
3205
|
+
attributes: { src: '/test.jpg', alt: 'T' },
|
|
3206
|
+
style: { base: { width: '600px' } },
|
|
3207
|
+
children: [],
|
|
3208
|
+
} as any;
|
|
3209
|
+
const result = nodeToAstro(node, ctx);
|
|
3210
|
+
expect(result).toContain('sizes="(max-width: 540px) 600px, (max-width: 1024px) 600px, 600px"');
|
|
3211
|
+
});
|
|
3212
|
+
});
|
|
1493
3213
|
});
|