@wildo-ai/saas-website 1.1.7 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/esm/astro/assert-website-pages-have-sections.d.ts +28 -0
  2. package/dist/esm/astro/assert-website-pages-have-sections.d.ts.map +1 -0
  3. package/dist/esm/astro/assert-website-pages-have-sections.js +38 -0
  4. package/dist/esm/astro/assert-website-pages-have-sections.js.map +1 -0
  5. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  6. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  7. package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  9. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  10. package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
  11. package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
  12. package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
  13. package/dist/esm/astro/label-pack-loader.d.ts +6 -0
  14. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  15. package/dist/esm/astro/label-pack-loader.js +10 -0
  16. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  17. package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
  18. package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
  19. package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
  20. package/dist/esm/astro/robots-renderer.js.map +1 -1
  21. package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
  22. package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
  23. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
  24. package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
  25. package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
  26. package/dist/esm/astro/website-site-context.d.ts.map +1 -1
  27. package/dist/esm/astro/website-site-context.js.map +1 -1
  28. package/dist/esm/astro-island.d.ts.map +1 -1
  29. package/dist/esm/astro.d.ts +1 -0
  30. package/dist/esm/astro.d.ts.map +1 -1
  31. package/dist/esm/astro.js +1 -0
  32. package/dist/esm/astro.js.map +1 -1
  33. package/dist/esm/companion-exports.d.ts.map +1 -1
  34. package/dist/esm/companion-exports.js.map +1 -1
  35. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
  36. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
  37. package/dist/esm/config/define-website-config.d.ts.map +1 -1
  38. package/dist/esm/config/define-website-config.js.map +1 -1
  39. package/dist/esm/config/index.d.ts.map +1 -1
  40. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  41. package/dist/esm/config/load-website-config.js.map +1 -1
  42. package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -1
  43. package/dist/esm/config/load-website-pricing-catalog.js.map +1 -1
  44. package/dist/esm/config/load-website-site-context.d.ts.map +1 -1
  45. package/dist/esm/config/load-website-site-context.js.map +1 -1
  46. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  47. package/dist/esm/config-loader.d.ts.map +1 -1
  48. package/dist/esm/config-loader.js.map +1 -1
  49. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  50. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  51. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  52. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  53. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  54. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  55. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -1
  56. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -1
  57. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
  58. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
  59. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
  60. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
  61. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
  62. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
  63. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  64. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  65. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  66. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  67. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  68. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  69. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  70. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  71. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -1
  72. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -1
  73. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  74. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -1
  75. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -1
  76. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -1
  77. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -1
  78. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  79. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  80. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -1
  81. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -1
  82. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  83. package/dist/esm/core/factories/define-website-section.d.ts +2 -0
  84. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  85. package/dist/esm/core/factories/define-website-section.js +2 -0
  86. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  87. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  88. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  89. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  90. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  91. package/dist/esm/core/layouts/WebsitePageSections.d.ts +43 -0
  92. package/dist/esm/core/layouts/WebsitePageSections.d.ts.map +1 -0
  93. package/dist/esm/core/layouts/WebsitePageSections.js +13 -0
  94. package/dist/esm/core/layouts/WebsitePageSections.js.map +1 -0
  95. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  96. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  97. package/dist/esm/index.d.ts +1 -0
  98. package/dist/esm/index.d.ts.map +1 -1
  99. package/dist/esm/index.js +1 -0
  100. package/dist/esm/index.js.map +1 -1
  101. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  102. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  103. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  104. package/dist/esm/mdx/index.d.ts.map +1 -1
  105. package/dist/esm/mdx.d.ts.map +1 -1
  106. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  107. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  108. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  109. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
  110. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  111. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -1
  112. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -1
  113. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts +2 -0
  114. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  115. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.js +12 -3
  116. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.js.map +1 -1
  117. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +3 -3
  118. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  119. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  120. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  121. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  122. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  123. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +9 -0
  124. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  125. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +11 -0
  126. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  127. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -1
  128. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -1
  129. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  130. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  131. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  132. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  133. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  134. package/dist/tsconfig.build.tsbuildinfo +1 -1
  135. package/package.json +5 -5
  136. package/src/astro/__tests__/assert-website-pages-have-sections.test.ts +75 -0
  137. package/src/astro/assert-website-pages-have-sections.ts +41 -0
  138. package/src/astro/label-pack-loader.ts +10 -0
  139. package/src/astro.ts +1 -0
  140. package/src/core/__tests__/WebsitePageSections.test.tsx +57 -0
  141. package/src/core/factories/define-website-section.ts +4 -0
  142. package/src/core/layouts/WebsitePageSections.tsx +68 -0
  143. package/src/index.ts +1 -0
  144. package/src/schemas/__tests__/page-manifest.test.ts +4 -7
  145. package/src/schemas/manifests/website-page-manifest.shared.schemas.ts +12 -3
  146. package/src/schemas/sections/website-section-definition.shared.schemas.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildo-ai/saas-website",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "SaaS marketing/landing website engine — schemas, helpers, layout/primitive placeholders, Astro/MDX adapters, and SEO/AEO/GEO builder library. Consumers author all business sections themselves via the defineWebsiteSection factory.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Wildo AI",
@@ -65,10 +65,10 @@
65
65
  "test:watch": "vitest"
66
66
  },
67
67
  "dependencies": {
68
- "@wildo-ai/external-connectors-public": "1.1.7",
69
- "@wildo-ai/platform-config-lib": "1.1.7",
70
- "@wildo-ai/presets-components-models": "1.1.7",
71
- "@wildo-ai/saas-models": "1.1.7",
68
+ "@wildo-ai/external-connectors-public": "1.1.9",
69
+ "@wildo-ai/platform-config-lib": "1.1.9",
70
+ "@wildo-ai/presets-components-models": "1.1.9",
71
+ "@wildo-ai/saas-models": "1.1.9",
72
72
  "yaml": "^2.3.4"
73
73
  },
74
74
  "devDependencies": {
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
4
+
5
+ import { WildoMarketingWebsiteConfigSchema } from '../../config/wildo-website-config.schemas';
6
+ import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
7
+ import { WebsitePageManifestSchema } from '../../schemas/manifests/website-page-manifest.shared.schemas';
8
+ import { WebsiteRootConfigSchema, type WebsiteRootConfig } from '../../schemas/manifests/website-root-config.shared.schemas';
9
+ import { assertWebsitePagesHaveSections, listWebsitePagesWithoutSections } from '../assert-website-pages-have-sections';
10
+ import { assertLabelPacksValid } from '../label-pack-loader';
11
+ import { composeWebsiteSiteContext } from '../website-site-context';
12
+
13
+ /**
14
+ * #2205: an empty page is a valid manifest so `astro dev` can serve it, and BUILDING one is refused.
15
+ * The refusal used to sit in the manifest schema, where it crashed the dev server at module load
16
+ * until every page of the site had a section.
17
+ */
18
+ const NoopComponent = () => null;
19
+
20
+ const manifest = (ref: string, sectionRefs: readonly string[]) =>
21
+ WebsitePageManifestSchema.parse({
22
+ ref,
23
+ routePath: ref === 'home' ? '/' : `/${ref}`,
24
+ pageComponent: NoopComponent,
25
+ sectionRefs,
26
+ metaLabelKeys: {
27
+ titleKey: WebsiteLabelKeySchema.parse(`website.${ref}.meta.title`),
28
+ descriptionKey: WebsiteLabelKeySchema.parse(`website.${ref}.meta.description`),
29
+ },
30
+ });
31
+
32
+ const rootConfig = (pageManifests: readonly ReturnType<typeof manifest>[]): WebsiteRootConfig =>
33
+ WebsiteRootConfigSchema.parse({
34
+ pageManifests,
35
+ designTokens: {
36
+ colors: {
37
+ background: '#fff', foreground: '#111', primary: '#0070f3', primaryForeground: '#fff', accent: '#ff5e5b',
38
+ accentForeground: '#fff', surface: '#fafafa', surfaceForeground: '#111', border: '#e5e5e5', muted: '#f5f5f5', mutedForeground: '#737373',
39
+ },
40
+ radii: { none: '0', sm: '0.125rem', md: '0.375rem', lg: '0.75rem', xl: '1.5rem', full: '9999px' },
41
+ },
42
+ navigation: { headerComponent: NoopComponent, footerComponent: NoopComponent },
43
+ seo: { siteName: 'Example' },
44
+ skipToMainContentLabelKey: WebsiteLabelKeySchema.parse('website.chrome.layout.skip-to-main-content'),
45
+ }) as WebsiteRootConfig;
46
+
47
+ describe('assertWebsitePagesHaveSections', () => {
48
+ it('passes a site whose every page has a section', () => {
49
+ expect(() => assertWebsitePagesHaveSections(rootConfig([manifest('home', ['home-hero'])]))).not.toThrow();
50
+ });
51
+
52
+ it('refuses a site with an empty page, and names EVERY empty page in one error', () => {
53
+ const config = rootConfig([manifest('home', []), manifest('pricing', ['pricing-hero']), manifest('contact', [])]);
54
+ expect(() => assertWebsitePagesHaveSections(config)).toThrow(/2 page\(s\) declare no section and cannot be built: "home", "contact"/);
55
+ });
56
+
57
+ it('lists the empty pages without throwing, for a report that must answer what the build would refuse', () => {
58
+ const config = rootConfig([manifest('home', []), manifest('pricing', ['pricing-hero'])]);
59
+ expect(listWebsitePagesWithoutSections(config)).toEqual(['home']);
60
+ });
61
+
62
+ it('is armed by assertLabelPacksValid, the build-start hook every website already wires', () => {
63
+ const siteContext = composeWebsiteSiteContext({
64
+ rootConfig: rootConfig([manifest('home', [])]),
65
+ websiteConfig: WildoMarketingWebsiteConfigSchema.parse({
66
+ publicOrigin: 'https://example.com',
67
+ appOrigin: 'https://app.example.com',
68
+ defaultLocale: AvailableLanguage.EN,
69
+ locales: [{ locale: AvailableLanguage.EN, path: 'en' }],
70
+ }),
71
+ });
72
+ // The packs directory does not exist: the empty-page refusal runs first, so it is what surfaces.
73
+ expect(() => assertLabelPacksValid('/nonexistent/website-packs', siteContext, new Map())).toThrow(/declare no section and cannot be built: "home"/);
74
+ });
75
+ });
@@ -0,0 +1,41 @@
1
+ import type { WebsiteRootConfig } from '../schemas/manifests/website-root-config.shared.schemas';
2
+
3
+ /**
4
+ * The refs of every page that declares no section, in manifest order — the population
5
+ * {@link assertWebsitePagesHaveSections} refuses to build. Exported on its own so a report
6
+ * (`wildo website audit`) answers from the same predicate the build applies, without throwing.
7
+ */
8
+ export function listWebsitePagesWithoutSections(rootConfig: Pick<WebsiteRootConfig, 'pageManifests'>): readonly string[] {
9
+ return rootConfig.pageManifests
10
+ .filter((manifest) => manifest.sectionRefs.length === 0)
11
+ .map((manifest) => String(manifest.ref));
12
+ }
13
+
14
+ /**
15
+ * Refuse to BUILD a website while any page has no section (#2205).
16
+ *
17
+ * A page manifest may declare an empty `sectionRefs` list: that is a page whose sections are still
18
+ * being authored, and the application-creation build lane creates exactly that state (the page task
19
+ * writes the empty list, then each section task appends itself). A dev server must be able to serve
20
+ * it, so the manifest schema accepts it.
21
+ *
22
+ * Building is where the consequence is. A built site is what a real reader receives, and a page
23
+ * with nothing between its header and footer is not unfinished there, it is wrong. So the refusal
24
+ * that used to sit in the manifest schema, where it crashed `astro dev` at module load, sits here.
25
+ *
26
+ * It runs from `assertLabelPacksValid`, the `astro:build:start` hook every website already wires,
27
+ * so no application has to edit its Astro config for the gate to arm. It is exported on its own for
28
+ * any other build entry (an audit command, a custom integration).
29
+ *
30
+ * Every empty page is named in ONE error rather than the first only, for the reason the label
31
+ * validator aggregates its locales: one build run should surface everything that blocks it.
32
+ */
33
+ export function assertWebsitePagesHaveSections(rootConfig: Pick<WebsiteRootConfig, 'pageManifests'>): void {
34
+ const emptyPageRefs = listWebsitePagesWithoutSections(rootConfig);
35
+ if (emptyPageRefs.length === 0) return;
36
+ throw new Error(
37
+ `[website-build] ${emptyPageRefs.length} page(s) declare no section and cannot be built: ${emptyPageRefs.map((ref) => `"${ref}"`).join(', ')}. `
38
+ + 'A page with no section is a valid state while it is being authored (a dev server serves it), never one to publish. '
39
+ + 'Add at least one section to each page\'s sections list, or remove the page from the root config\'s page manifests.',
40
+ );
41
+ }
@@ -16,6 +16,7 @@ import {
16
16
  } from '../schemas/validators/label-pack.validator';
17
17
 
18
18
  import type { WebsiteSiteContext } from './website-site-context';
19
+ import { assertWebsitePagesHaveSections } from './assert-website-pages-have-sections';
19
20
 
20
21
  /**
21
22
  * @wildo_source:part:start saas.website.astro.label-loader facet:layer:astro facet:family:website
@@ -241,6 +242,12 @@ function composeEffectiveChromeLabelKeys(
241
242
  * `loadAndValidateAllLabelPacks` is what `wildo website audit` calls
242
243
  * when it wants programmatic access to the per-locale results.
243
244
  *
245
+ * **Empty pages**: it first refuses a page whose `sectionRefs` is empty
246
+ * (`assertWebsitePagesHaveSections`, #2205). That refusal used to live in
247
+ * the manifest schema, where it crashed `astro dev` too; this hook runs
248
+ * only at `astro:build:start`, which is where a page with nothing on it
249
+ * stops being unfinished and starts being wrong.
250
+ *
244
251
  * **Key-origin annotation**: every `missing` key is tagged with the
245
252
  * page (and, if section-scoped, the section) that declared it, so the
246
253
  * author can grep + open the right `defineWebsiteSection` /
@@ -252,6 +259,9 @@ export function assertLabelPacksValid(
252
259
  expectedKeysBySectionRef: ExpectedLabelKeysBySectionRef,
253
260
  ): void {
254
261
  const { rootConfig } = siteContext;
262
+ // The build-start hook every website wires, so the empty-page refusal arms with no config edit
263
+ // (#2205). First, because a page with no section has no label commitments to validate.
264
+ assertWebsitePagesHaveSections(rootConfig);
255
265
  const validations = loadAndValidateAllLabelPacks(
256
266
  packsDir,
257
267
  siteContext,
package/src/astro.ts CHANGED
@@ -70,6 +70,7 @@ export * from './astro/website-site-context';
70
70
  export * from './astro/i18n-routing.helper';
71
71
  export * from './astro/collect-expected-label-keys';
72
72
  export * from './astro/label-pack-loader';
73
+ export * from './astro/assert-website-pages-have-sections';
73
74
  /**
74
75
  * `pick-labels-for-page` is INTENTIONALLY NOT re-exported here.
75
76
  * It is an internal helper consumed only by `loadWebsitePageRuntime`
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+
6
+ import { WebsitePageSections } from '../layouts/WebsitePageSections';
7
+ import { defineWebsiteSection } from '../factories/define-website-section';
8
+ import { WebsiteSectionCategory } from '../../schemas/sections/website-section-category.shared';
9
+ import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
10
+
11
+ const HERO = defineWebsiteSection<Record<string, never>>({
12
+ sectionRef: 'home-hero',
13
+ category: WebsiteSectionCategory.HERO,
14
+ expectedLabelKeys: [WebsiteLabelKeySchema.parse('website.home.home-hero.headline')],
15
+ Component: () => <p>hero body</p>,
16
+ });
17
+
18
+ describe('WebsitePageSections', () => {
19
+ it('renders each section inside its WebsiteSection wrapper, in order', () => {
20
+ const { container, getByText } = render(<WebsitePageSections pageRef="home" sections={[HERO]} />);
21
+ expect(container.querySelector('section[data-section-ref="home-hero"]')).not.toBeNull();
22
+ expect(getByText('hero body')).toBeInTheDocument();
23
+ expect(container.querySelector('[data-website-pending-page]')).toBeNull();
24
+ });
25
+
26
+ it('gives a section its declared anchorId as the DOM id, so an in-page link has a target', () => {
27
+ const tiers = defineWebsiteSection<Record<string, never>>({
28
+ sectionRef: 'pricing-tiers',
29
+ category: WebsiteSectionCategory.PRICING,
30
+ expectedLabelKeys: [WebsiteLabelKeySchema.parse('website.pricing.pricing-tiers.heading')],
31
+ anchorId: 'tiers',
32
+ Component: () => <p>tiers</p>,
33
+ });
34
+ const { container } = render(<WebsitePageSections pageRef="pricing" sections={[tiers, HERO]} />);
35
+ expect(container.querySelector('section#tiers')?.getAttribute('data-section-ref')).toBe('pricing-tiers');
36
+ expect(container.querySelector('section[data-section-ref="home-hero"]')?.hasAttribute('id')).toBe(false);
37
+ });
38
+
39
+ it('refuses an anchorId that is not a fragment-safe HTML id', () => {
40
+ expect(() => defineWebsiteSection<Record<string, never>>({
41
+ sectionRef: 'pricing-tiers',
42
+ category: WebsiteSectionCategory.PRICING,
43
+ expectedLabelKeys: [],
44
+ anchorId: 'two words',
45
+ Component: () => null,
46
+ })).toThrow(/anchorId must start with a letter/);
47
+ });
48
+
49
+ it('renders a VISIBLE pending marker for a page whose sections are not authored yet (#2205)', () => {
50
+ // Mapped by hand, an empty list renders nothing, which looks exactly like a page that failed.
51
+ const { container } = render(<WebsitePageSections pageRef="home" sections={[]} />);
52
+ const marker = container.querySelector('[data-website-pending-page="home"]');
53
+ expect(marker).not.toBeNull();
54
+ expect(marker).toHaveAttribute('aria-busy', 'true');
55
+ expect(marker).toHaveTextContent('home: sections = []');
56
+ });
57
+ });
@@ -27,6 +27,8 @@ export interface DefineWebsiteSectionInput<TProps> {
27
27
  sectionRef: WebsiteSectionRef | string;
28
28
  category: WebsiteSectionCategory;
29
29
  expectedLabelKeys: readonly (WebsiteLabelKey | string)[];
30
+ /** The section's DOM id, so a link can target it (`<a href="#tiers">`). See `WebsiteSectionDefinition.anchorId`. */
31
+ anchorId?: string;
30
32
  Component: ComponentType<TProps>;
31
33
  /**
32
34
  * See `WebsiteSectionDefinition.getStructuredData?` JSDoc for the
@@ -109,6 +111,7 @@ export function defineWebsiteSection<TProps>(
109
111
  sectionRef: input.sectionRef,
110
112
  category: input.category,
111
113
  expectedLabelKeys: input.expectedLabelKeys,
114
+ anchorId: input.anchorId,
112
115
  Component: input.Component,
113
116
  getStructuredData: input.getStructuredData,
114
117
  });
@@ -121,6 +124,7 @@ export function defineWebsiteSection<TProps>(
121
124
  sectionRef: parsed.sectionRef,
122
125
  category: parsed.category,
123
126
  expectedLabelKeys: parsed.expectedLabelKeys,
127
+ ...(parsed.anchorId !== undefined ? { anchorId: parsed.anchorId } : {}),
124
128
  Component: input.Component,
125
129
  ...(input.getStructuredData ? { getStructuredData: input.getStructuredData } : {}),
126
130
  };
@@ -0,0 +1,68 @@
1
+ import React, { type ReactNode } from 'react';
2
+
3
+ import type { WebsiteSectionDefinition } from '../../schemas/sections/website-section-definition.shared.schemas';
4
+ import { WebsiteSection } from './WebsiteSection';
5
+
6
+ /**
7
+ * @wildo_source:part:start saas.website.page-sections.component facet:layer:core facet:family:website
8
+ *
9
+ * `WebsitePageSections` — renders a page's ordered sections list, each inside its `WebsiteSection`
10
+ * wrapper, and a visible PENDING marker while the list is still empty.
11
+ *
12
+ * A page component should render its sections through this rather than mapping the list by hand:
13
+ *
14
+ * ```tsx
15
+ * export function PricingPage(): ReactNode {
16
+ * return <WebsitePageSections pageRef="pricing" sections={PRICING_SECTIONS} />;
17
+ * }
18
+ * ```
19
+ *
20
+ * **Why the empty state is the point.** A page's sections list starts EMPTY when its page
21
+ * is authored before its sections — the application-creation build lane does exactly that, one task
22
+ * per page and one per section. An empty page is a valid manifest and a dev server serves it; the
23
+ * production build refuses it (`assertWebsitePagesHaveSections`). Mapped by hand, an empty list
24
+ * renders nothing at all, which is indistinguishable from a page that failed. This renders a
25
+ * marked block instead, so the page visibly says its sections are still to come.
26
+ *
27
+ * A section's `anchorId`, declared on its definition, becomes the wrapper's DOM `id`, so in-page links
28
+ * still have a target when no page places its sections by hand.
29
+ *
30
+ * **The marker carries identifiers, not prose.** It shows the page ref and the empty list as code,
31
+ * deliberately: words a person reads come from the label packs, and a label for a state no built
32
+ * page can ever reach (the build refuses an empty page) would be a key every locale must carry for
33
+ * nothing. A missing section must LOOK missing, which a raw identifier does.
34
+ */
35
+ export interface WebsitePageSectionsProps {
36
+ /** The page's ref, as its manifest declares it. Shown in the pending marker. */
37
+ pageRef: string;
38
+ /** The page's ordered sections list — the same array its manifest's `sectionRefs` derive from. */
39
+ sections: readonly WebsiteSectionDefinition[];
40
+ }
41
+
42
+ export function WebsitePageSections({ pageRef, sections }: WebsitePageSectionsProps): ReactNode {
43
+ if (sections.length === 0) {
44
+ return (
45
+ <section
46
+ data-website-pending-page={pageRef}
47
+ aria-busy="true"
48
+ style={{ margin: '2rem auto', maxWidth: '48rem', padding: '2rem', border: '2px dashed currentColor', opacity: 0.6, textAlign: 'center' }}
49
+ >
50
+ <code>{`${pageRef}: sections = []`}</code>
51
+ </section>
52
+ );
53
+ }
54
+ return (
55
+ <>
56
+ {sections.map((section) => {
57
+ const SectionComponent = section.Component as React.ComponentType;
58
+ return (
59
+ <WebsiteSection key={String(section.sectionRef)} sectionRef={section.sectionRef} category={section.category} id={section.anchorId}>
60
+ <SectionComponent />
61
+ </WebsiteSection>
62
+ );
63
+ })}
64
+ </>
65
+ );
66
+ }
67
+ WebsitePageSections.displayName = 'WebsitePageSections';
68
+ /** @wildo_source:part:end saas.website.page-sections.component */
package/src/index.ts CHANGED
@@ -78,6 +78,7 @@ export * from './core/factories/define-website-section';
78
78
  export * from './core/factories/define-website-page-manifest';
79
79
  export * from './core/layouts/WebsitePageLayout';
80
80
  export * from './core/layouts/WebsiteSection';
81
+ export * from './core/layouts/WebsitePageSections';
81
82
 
82
83
  export * from './core/consent/website-consent-storage';
83
84
  export * from './core/consent/WebsiteConsentContext';
@@ -55,12 +55,9 @@ describe('WebsitePageManifestSchema', () => {
55
55
  ).toThrow(/must be rooted under the page ref/);
56
56
  });
57
57
 
58
- it('rejects empty section ref lists', () => {
59
- expect(() =>
60
- WebsitePageManifestSchema.parse({
61
- ...VALID_MANIFEST_INPUT,
62
- sectionRefs: [],
63
- }),
64
- ).toThrow();
58
+ it('accepts an EMPTY section ref list: a page whose sections are not authored yet (#2205)', () => {
59
+ // The build lane writes the empty list before any section task runs. Refusing it here crashed
60
+ // `astro dev` at module load; the refusal now belongs to the build (assertWebsitePagesHaveSections).
61
+ expect(WebsitePageManifestSchema.parse({ ...VALID_MANIFEST_INPUT, sectionRefs: [] }).sectionRefs).toEqual([]);
65
62
  });
66
63
  });
@@ -141,6 +141,8 @@ export type WebsitePageRoutePath = z.infer<typeof _WebsitePageRoutePathSchema>;
141
141
  * not match the directive in its `.astro` file.
142
142
  *
143
143
  * **Refinements**:
144
+ * - `sectionRefs` MAY be empty — a page whose sections are still being authored. It may be
145
+ * served by a dev server and may not be built: see `assertWebsitePagesHaveSections`.
144
146
  * - `sectionRefs` MUST contain unique values. Duplicates indicate
145
147
  * either a copy-paste bug (two sections pointing at the same
146
148
  * label-key root) or an intent to share a label-key root that
@@ -203,9 +205,16 @@ const _WebsitePageManifestSchema = z
203
205
  ref: WebsitePageRefSchema,
204
206
  routePath: _WebsitePageRoutePathSchema,
205
207
  pageComponent: z.unknown(),
206
- sectionRefs: z.array(WebsiteSectionRefSchema).min(1, {
207
- error: 'A page manifest must declare at least one sectionRef',
208
- }),
208
+ /*
209
+ * EMPTY is a valid manifest (#2205): a page whose sections are not authored YET. The build lane
210
+ * creates that state on purpose (the page task writes the empty list, then each section task
211
+ * appends itself), and refusing it here made `astro dev` crash at module load until every page
212
+ * of the site had a section. An empty page is unfinished, not false, so it is representable and
213
+ * a dev server renders it (`WebsitePageSections` shows the pending state). What is refused is
214
+ * BUILDING one: `assertWebsitePagesHaveSections`, run from the build-start hook, because a
215
+ * published page with nothing on it is a statement to a real reader.
216
+ */
217
+ sectionRefs: z.array(WebsiteSectionRefSchema),
209
218
  metaLabelKeys: _WebsitePageMetaLabelKeysSchema,
210
219
  hydration: WebsitePageHydrationStrategySchema.optional(),
211
220
  seo: _WebsitePageSeoConfigSchema.optional(),
@@ -46,6 +46,14 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
46
46
  * and is caught by nothing until a reviewer reads the page. The
47
47
  * build and `wildo website audit` check the pack against the
48
48
  * DECLARED keys, so declare every slot the component reads.
49
+ * - `anchorId?` — the section's DOM id, so a link (`<a href="#tiers">`)
50
+ * can target it. Belongs to the DEFINITION because a page renders its
51
+ * sections list through `WebsitePageSections`, which places every
52
+ * section itself: an anchor a page author would otherwise write on a
53
+ * hand-placed `<WebsiteSection id=…>` has nowhere else to live. A
54
+ * valid HTML id that is also URL-fragment-safe: a letter, then
55
+ * letters, digits, `-` or `_`. Uniqueness on a page is the author's
56
+ * to keep, like the id attribute it becomes.
49
57
  * - `Component` — the React component that renders the section. Typed
50
58
  * as `ComponentType<unknown>` here because schema-layer cannot lock
51
59
  * the component's prop shape (props vary per section). The factory
@@ -72,6 +80,9 @@ const _WebsiteSectionDefinitionSchema = z.object({
72
80
  sectionRef: WebsiteSectionRefSchema,
73
81
  category: WebsiteSectionCategorySchema,
74
82
  expectedLabelKeys: z.array(WebsiteLabelKeySchema).readonly(),
83
+ anchorId: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*$/, {
84
+ error: 'A section anchorId must start with a letter and contain only letters, digits, "-" or "_"',
85
+ }).optional(),
75
86
  Component: z.unknown(),
76
87
  getStructuredData: z.unknown().optional(),
77
88
  });