@commonpub/layer 0.24.0 → 0.25.1

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 (82) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +11 -8
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -1,34 +1,32 @@
1
1
  /**
2
2
  * Built-in section definition: divider.
3
3
  *
4
- * Phase 1 proof-of-life the simplest possible registered section.
5
- * Validates the registry LayoutSlot renderer chain without any
6
- * Zod complexity, content fetches, or admin-only config.
4
+ * Stage E.1 — reuses BlockDividerView (extended to accept optional
5
+ * variant + spacingY on its `content` prop). Existing block callers
6
+ * (BlockContentRenderer) pass nothing and get the legacy 36px solid line.
7
+ * Layout-engine callers pass `{variant, spacingY}` via the propMap below
8
+ * to customise.
7
9
  *
8
- * Phase 1c adds: hero, heading, paragraph, image, content-feed —
9
- * each in its own `builtin/{type}.ts` file, registered in
10
- * `../registry.ts` alongside this one.
10
+ * Schema moved to `@commonpub/schema/sectionConfigs` in session 161 so
11
+ * the server can validate per-section configs without dragging Vue into
12
+ * the Nitro bundle. See `validateSectionConfigs.ts`.
11
13
  */
12
- import { z } from 'zod';
13
14
  import type { SectionDefinition } from '@commonpub/ui';
14
- import SectionDivider from '../../components/sections/SectionDivider.vue';
15
+ import { dividerConfigSchema, type DividerConfig } from '@commonpub/schema';
16
+ import BlockDividerView from '../../components/blocks/BlockDividerView.vue';
15
17
 
16
- const configSchema = z.object({
17
- variant: z.enum(['solid', 'dashed', 'dotted', 'accent']).default('solid'),
18
- spacingY: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
19
- });
20
-
21
- export const dividerSection: SectionDefinition<z.infer<typeof configSchema>> = {
18
+ export const dividerSection: SectionDefinition<DividerConfig> = {
22
19
  type: 'divider',
23
20
  name: 'Divider',
24
- description: 'Horizontal rule with style + spacing options',
21
+ description: 'Horizontal rule with style + spacing (uses BlockDividerView)',
25
22
  icon: 'fa-minus',
26
23
  category: 'layout',
27
24
  status: 'stable',
28
- configSchema,
25
+ configSchema: dividerConfigSchema,
29
26
  defaultConfig: { variant: 'solid', spacingY: 'md' },
30
27
  schemaVersion: 1,
31
- component: SectionDivider,
28
+ component: BlockDividerView,
29
+ propMap: ({ config }) => ({ content: config }),
32
30
  // Dividers are always full-width; resize is meaningless for a 1px line
33
31
  minColSpan: 12,
34
32
  maxColSpan: 12,
@@ -1,37 +1,27 @@
1
1
  /**
2
2
  * Built-in section definition: editorial.
3
3
  *
4
- * Phase 1c addition (session 159) Staff Picks grid. Server-fetches
5
- * `/api/content?editorial=true&sort=editorial&limit=N` and renders a
6
- * responsive `<ContentCard>` grid. Mirrors the legacy
7
- * `EditorialSection.vue` shape; built fresh in the registry pattern.
4
+ * Stage E.4 reuses the existing EditorialSection which fetches
5
+ * `/api/content?editorial=true` and renders Staff Picks grid.
8
6
  *
9
- * Used by commonpub.io's homepage (currently the legacy renderer); this
10
- * section unblocks porting that homepage into a real layout row in the
11
- * Phase 1c canary.
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
12
8
  */
13
- import { z } from 'zod';
14
9
  import type { SectionDefinition } from '@commonpub/ui';
15
- import SectionEditorial from '../../components/sections/SectionEditorial.vue';
10
+ import { editorialConfigSchema, type EditorialConfig } from '@commonpub/schema';
11
+ import EditorialSection from '../../components/homepage/EditorialSection.vue';
16
12
 
17
- const configSchema = z.object({
18
- heading: z.string().max(120).default('Staff Picks'),
19
- limit: z.number().int().min(1).max(12).default(3),
20
- columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
21
- });
22
-
23
- export const editorialSection: SectionDefinition<z.infer<typeof configSchema>> = {
13
+ export const editorialSection: SectionDefinition<EditorialConfig> = {
24
14
  type: 'editorial',
25
15
  name: 'Editorial',
26
- description: 'Staff-picked content grid (editorial flag in /api/content)',
16
+ description: 'Staff-picked content grid (uses EditorialSection)',
27
17
  icon: 'fa-pen-fancy',
28
18
  category: 'data',
29
19
  status: 'stable',
30
- configSchema,
31
- defaultConfig: { heading: 'Staff Picks', limit: 3, columns: 3 },
20
+ configSchema: editorialConfigSchema,
21
+ defaultConfig: { limit: 3 },
32
22
  schemaVersion: 1,
33
- component: SectionEditorial,
34
- // 3+ column ContentCard grid loses readability below half-width
23
+ component: EditorialSection,
24
+ propMap: ({ config }) => ({ config }),
35
25
  minColSpan: 6,
36
26
  maxColSpan: 12,
37
27
  defaultColSpan: 12,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Built-in section definition: embed.
3
+ *
4
+ * Stage E.2 — reuses BlockEmbedView (generic-iframe renderer). Host
5
+ * allowlist + sandbox policy live in BlockEmbedView.
6
+ *
7
+ * Schema (incl. URL_HTTPS_OR_EMPTY guard — strict http(s) only, no
8
+ * relative-path branch unlike image/video/gallery) lives in
9
+ * `@commonpub/schema/sectionConfigs` (session 161 move).
10
+ */
11
+ import type { SectionDefinition } from '@commonpub/ui';
12
+ import { embedConfigSchema, type EmbedConfig } from '@commonpub/schema';
13
+ import BlockEmbedView from '../../components/blocks/BlockEmbedView.vue';
14
+
15
+ export const embedSection: SectionDefinition<EmbedConfig> = {
16
+ type: 'embed',
17
+ name: 'Embed',
18
+ description: 'Tweets, gists, CodePen, Loom, etc (uses BlockEmbedView)',
19
+ icon: 'fa-square-arrow-up-right',
20
+ category: 'content',
21
+ status: 'stable',
22
+ configSchema: embedConfigSchema,
23
+ defaultConfig: { url: '' },
24
+ schemaVersion: 1,
25
+ component: BlockEmbedView,
26
+ propMap: ({ config }) => ({ content: config }),
27
+ minColSpan: 6,
28
+ maxColSpan: 12,
29
+ defaultColSpan: 12,
30
+ resizable: true,
31
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Built-in section definition: gallery.
3
+ *
4
+ * Stage E.2 — reuses BlockGalleryView (image grid + lightbox).
5
+ *
6
+ * Schema (incl. per-image URL_MEDIA_OR_EMPTY guard + max-20 array bound)
7
+ * lives in `@commonpub/schema/sectionConfigs` (session 161 move).
8
+ */
9
+ import type { SectionDefinition } from '@commonpub/ui';
10
+ import { galleryConfigSchema, type GalleryConfig } from '@commonpub/schema';
11
+ import BlockGalleryView from '../../components/blocks/BlockGalleryView.vue';
12
+
13
+ export const gallerySection: SectionDefinition<GalleryConfig> = {
14
+ type: 'gallery',
15
+ name: 'Gallery',
16
+ description: 'Image grid (uses BlockGalleryView)',
17
+ icon: 'fa-images',
18
+ category: 'content',
19
+ status: 'stable',
20
+ configSchema: galleryConfigSchema,
21
+ defaultConfig: { images: [] },
22
+ schemaVersion: 1,
23
+ component: BlockGalleryView,
24
+ propMap: ({ config }) => ({ content: config }),
25
+ minColSpan: 6,
26
+ maxColSpan: 12,
27
+ defaultColSpan: 12,
28
+ resizable: true,
29
+ };
@@ -1,34 +1,29 @@
1
1
  /**
2
2
  * Built-in section definition: heading.
3
3
  *
4
- * Phase 1c startersingle heading with optional eyebrow + subline.
5
- * Drives the auto-form via the configSchema (Phase 3e maps Zod kinds to
6
- * controls; level is a small enum → segmented control).
4
+ * Stage E.1 (session 159) reuses the existing `BlockHeadingView`
5
+ * renderer (`layers/base/components/blocks/BlockHeadingView.vue`)
6
+ * instead of a parallel `SectionHeading.vue`.
7
+ *
8
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
9
+ * See `feedback-reuse-existing-components` memory + Stage E plan.
7
10
  */
8
- import { z } from 'zod';
9
11
  import type { SectionDefinition } from '@commonpub/ui';
10
- import SectionHeading from '../../components/sections/SectionHeading.vue';
11
-
12
- const configSchema = z.object({
13
- text: z.string().min(1).max(240).default('Section heading'),
14
- level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(2),
15
- align: z.enum(['left', 'center']).default('left'),
16
- eyebrow: z.string().max(120).default(''),
17
- subline: z.string().max(480).default(''),
18
- });
12
+ import { headingConfigSchema, type HeadingConfig } from '@commonpub/schema';
13
+ import BlockHeadingView from '../../components/blocks/BlockHeadingView.vue';
19
14
 
20
- export const headingSection: SectionDefinition<z.infer<typeof configSchema>> = {
15
+ export const headingSection: SectionDefinition<HeadingConfig> = {
21
16
  type: 'heading',
22
17
  name: 'Heading',
23
- description: 'Single h1–h4 heading with optional eyebrow + subline',
18
+ description: 'Single h1–h6 heading (uses BlockHeadingView)',
24
19
  icon: 'fa-heading',
25
20
  category: 'content',
26
21
  status: 'stable',
27
- configSchema,
28
- defaultConfig: { text: 'Section heading', level: 2, align: 'left', eyebrow: '', subline: '' },
22
+ configSchema: headingConfigSchema,
23
+ defaultConfig: { level: 2, text: 'Section heading' },
29
24
  schemaVersion: 1,
30
- component: SectionHeading,
31
- // Heading reads fine narrow; allow as small as quarter-width
25
+ component: BlockHeadingView,
26
+ propMap: ({ config }) => ({ content: config }),
32
27
  minColSpan: 3,
33
28
  maxColSpan: 12,
34
29
  defaultColSpan: 12,
@@ -1,67 +1,32 @@
1
1
  /**
2
2
  * Built-in section definition: hero.
3
3
  *
4
- * Phase 1c starter. Three variants `default` (left-aligned with grid
5
- * backdrop), `compact` (narrow with no backdrop), `centered` (centered
6
- * content). Up to two CTAs each with their own variant.
4
+ * Stage E.4 reuses the existing HeroSection which handles
5
+ * contest-aware swap + dismiss button + hardcoded fallback copy
6
+ * ("Build. Document. Share.").
7
7
  *
8
- * NOT contest-aware (the existing HomepageHeroSection has dispatch
9
- * logic for the live-contest hero — that responsibility moves to a
10
- * future `contest-feature` data section in Phase 6b).
8
+ * Schema (incl. URL_LINK_STRICT guard on CTA hrefs) lives in
9
+ * `@commonpub/schema/sectionConfigs` (session 161 move).
11
10
  */
12
- import { z } from 'zod';
13
11
  import type { SectionDefinition } from '@commonpub/ui';
14
- import SectionHero from '../../components/sections/SectionHero.vue';
12
+ import { heroConfigSchema, type HeroConfig } from '@commonpub/schema';
13
+ import HeroSection from '../../components/homepage/HeroSection.vue';
15
14
 
16
- /**
17
- * URL guard — accepts http(s), site-relative paths, hash links, mailto/tel.
18
- * Rejects javascript:, data:, vbscript:, file:, etc. — admin-set fields
19
- * render to ALL visitors, so a malicious admin (or DB corruption) could
20
- * inject a clickable XSS via `<a href="javascript:...">` without this.
21
- *
22
- * Defense at the write boundary; the renderer doesn't re-validate
23
- * (Vue's :href binding doesn't sanitize, so this IS the guard).
24
- */
25
- const SAFE_LINK_URL = /^(https?:\/\/|\/|#|mailto:|tel:)/i;
26
-
27
- const ctaSchema = z.object({
28
- label: z.string().min(1).max(80),
29
- href: z.string().min(1).max(2048).regex(SAFE_LINK_URL, {
30
- message: 'href must be http(s), relative (/), hash (#), mailto:, or tel:',
31
- }),
32
- variant: z.enum(['primary', 'secondary']).default('primary'),
33
- });
34
-
35
- const configSchema = z.object({
36
- variant: z.enum(['default', 'compact', 'centered']).default('default'),
37
- eyebrow: z.string().max(120).default(''),
38
- title: z.string().min(1).max(240).default('Welcome'),
39
- subtitle: z.string().max(800).default(''),
40
- // Capped at 2 — visual + a11y guidance: more than two competing CTAs
41
- // dilute the call-to-action and read as a button bar instead.
42
- ctas: z.array(ctaSchema).max(2).default([]),
43
- });
44
-
45
- export const heroSection: SectionDefinition<z.infer<typeof configSchema>> = {
15
+ export const heroSection: SectionDefinition<HeroConfig> = {
46
16
  type: 'hero',
47
17
  name: 'Hero',
48
- description: 'Banner with title, subtitle, and up to two CTAs',
18
+ description: 'Top-of-page banner (uses HeroSection contest-aware)',
49
19
  icon: 'fa-bullhorn',
50
20
  category: 'layout',
51
21
  status: 'stable',
52
- configSchema,
53
- defaultConfig: {
54
- variant: 'default',
55
- eyebrow: '',
56
- title: 'Welcome',
57
- subtitle: '',
58
- ctas: [],
59
- },
22
+ configSchema: heroConfigSchema,
23
+ defaultConfig: { variant: 'default', customTitle: '', customSubtitle: '', ctas: [] },
60
24
  schemaVersion: 1,
61
- component: SectionHero,
62
- // Hero breaks at <6 cols (CTA wrap looks awful); 12 is the canonical use
63
- minColSpan: 6,
25
+ component: HeroSection,
26
+ // HeroSection takes { config: HomepageSectionConfig }
27
+ propMap: ({ config }) => ({ config }),
28
+ minColSpan: 12,
64
29
  maxColSpan: 12,
65
30
  defaultColSpan: 12,
66
- resizable: true,
31
+ resizable: false,
67
32
  };
@@ -1,43 +1,28 @@
1
1
  /**
2
2
  * Built-in section definition: hubs.
3
3
  *
4
- * Phase 1c addition (session 159) trending hubs list. Server-fetches
5
- * `/api/hubs?limit=N` and renders a sidebar-style card with icon, name,
6
- * member count, and a Join CTA per hub.
4
+ * Stage E.4 reuses the existing HubsSection (Trending Hubs sidebar
5
+ * card with join CTAs).
7
6
  *
8
- * Renderer also dispatches join requests via `POST /api/hubs/:slug/join`
9
- * for authenticated visitors (mirrors legacy `HubsSection.vue`); guests
10
- * are redirected to `/auth/login?redirect=/`.
11
- *
12
- * Defaults to colSpan 4 (sidebar). Set colSpan 12 for a horizontal
13
- * sweep on a community-discovery page.
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
14
8
  */
15
- import { z } from 'zod';
16
9
  import type { SectionDefinition } from '@commonpub/ui';
17
- import SectionHubs from '../../components/sections/SectionHubs.vue';
18
-
19
- const configSchema = z.object({
20
- heading: z.string().max(120).default('Trending Hubs'),
21
- limit: z.number().int().min(1).max(20).default(4),
22
- });
10
+ import { hubsConfigSchema, type HubsConfig } from '@commonpub/schema';
11
+ import HubsSection from '../../components/homepage/HubsSection.vue';
23
12
 
24
- export const hubsSection: SectionDefinition<z.infer<typeof configSchema>> = {
13
+ export const hubsSection: SectionDefinition<HubsConfig> = {
25
14
  type: 'hubs',
26
15
  name: 'Hubs',
27
- description: 'Trending hubs list with join action (feature-gated)',
16
+ description: 'Trending hubs with join action (uses HubsSection)',
28
17
  icon: 'fa-users',
29
18
  category: 'data',
30
19
  status: 'stable',
31
- // Palette gate — admins on a hubs-disabled instance shouldn't see this
32
- // type in the section-picker. Runtime gating is separate: each placed
33
- // instance's `visibility.features` array controls render visibility
34
- // (LayoutSlot honors it). Setting both here AND in the migration script
35
- // is the belt-and-braces.
36
20
  featureGate: 'hubs',
37
- configSchema,
38
- defaultConfig: { heading: 'Trending Hubs', limit: 4 },
21
+ configSchema: hubsConfigSchema,
22
+ defaultConfig: { limit: 4 },
39
23
  schemaVersion: 1,
40
- component: SectionHubs,
24
+ component: HubsSection,
25
+ propMap: ({ config }) => ({ config }),
41
26
  minColSpan: 3,
42
27
  maxColSpan: 12,
43
28
  defaultColSpan: 4,
@@ -1,65 +1,28 @@
1
1
  /**
2
2
  * Built-in section definition: image.
3
3
  *
4
- * Phase 1c starter. Stores raw URL + alt + optional caption + optional
5
- * href. The Phase 3e auto-form will swap `src` for an ImageUpload picker
6
- * via `.describe('image')` — current Zod is a plain URL string so the
7
- * form generator still produces a usable text input today.
4
+ * Stage E.1 reuses BlockImageView (`<figure>` with size preset +
5
+ * caption + lazy loading).
8
6
  *
9
- * Security note: `src` is rendered as-is via `<img>` — sanitisation is
10
- * caller responsibility (the URL is never reflected into a script
11
- * context). Custom-HTML / iframe sections are the XSS surface and gate
12
- * differently (admin-only addRoles in Phase 6b).
7
+ * Schema (incl. URL_MEDIA_OR_EMPTY guard on `src`) lives in
8
+ * `@commonpub/schema/sectionConfigs` (session 161 move).
13
9
  */
14
- import { z } from 'zod';
15
10
  import type { SectionDefinition } from '@commonpub/ui';
16
- import SectionImage from '../../components/sections/SectionImage.vue';
11
+ import { imageConfigSchema, type ImageConfig } from '@commonpub/schema';
12
+ import BlockImageView from '../../components/blocks/BlockImageView.vue';
17
13
 
18
- /**
19
- * URL guards — separate for `src` (image fetch) vs `href` (click target).
20
- *
21
- * - `src`: http(s) or site-relative. data: rejected (large + tracking
22
- * surface; ImageUpload should be the path for inline data). javascript:
23
- * in <img src> doesn't execute in modern browsers, but disallow anyway
24
- * for consistency.
25
- * - `href`: http(s), site-relative, hash, mailto:, tel:. javascript:
26
- * would execute on click — admin-set fields render to ALL visitors so
27
- * this is a stored XSS surface without the regex.
28
- *
29
- * Both allow EMPTY string (the section's "no image yet" / "no link"
30
- * state). The `^(?:$|…)` shape matches empty-string only when the
31
- * `$` end-of-string anchor immediately follows `^` — pinned by tests
32
- * because the obvious `^(?:|…)` would have an empty alternation
33
- * branch that matches ANY input (the empty match always succeeds at
34
- * position 0).
35
- */
36
- const SAFE_IMAGE_URL = /^(?:$|https?:\/\/|\/)/i;
37
- const SAFE_LINK_URL = /^(?:$|https?:\/\/|\/|#|mailto:|tel:)/i;
38
-
39
- const configSchema = z.object({
40
- src: z.string().max(2048).regex(SAFE_IMAGE_URL, {
41
- message: 'src must be http(s) or relative (/)',
42
- }).default(''),
43
- alt: z.string().max(240).default(''),
44
- caption: z.string().max(480).default(''),
45
- href: z.string().max(2048).regex(SAFE_LINK_URL, {
46
- message: 'href must be http(s), relative (/), hash (#), mailto:, or tel:',
47
- }).default(''),
48
- fit: z.enum(['contain', 'cover']).default('contain'),
49
- aspectRatio: z.enum(['16/9', '4/3', '1/1', 'auto']).default('auto'),
50
- });
51
-
52
- export const imageSection: SectionDefinition<z.infer<typeof configSchema>> = {
14
+ export const imageSection: SectionDefinition<ImageConfig> = {
53
15
  type: 'image',
54
16
  name: 'Image',
55
- description: 'Single image with optional caption + link',
17
+ description: 'Single image with caption (uses BlockImageView)',
56
18
  icon: 'fa-image',
57
19
  category: 'content',
58
20
  status: 'stable',
59
- configSchema,
60
- defaultConfig: { src: '', alt: '', caption: '', href: '', fit: 'contain', aspectRatio: 'auto' },
21
+ configSchema: imageConfigSchema,
22
+ defaultConfig: { src: '', alt: '', caption: '', size: 'l' },
61
23
  schemaVersion: 1,
62
- component: SectionImage,
24
+ component: BlockImageView,
25
+ propMap: ({ config }) => ({ content: config }),
63
26
  minColSpan: 3,
64
27
  maxColSpan: 12,
65
28
  defaultColSpan: 12,
@@ -2,23 +2,15 @@
2
2
  * Built-in section definition: learning.
3
3
  *
4
4
  * Phase 1c addition (session 159) — learning paths grid. Server-fetches
5
- * `/api/learn?limit=N` and renders a responsive grid of cards (title,
6
- * description, difficulty + duration, enrollment count).
5
+ * `/api/learn?limit=N` and renders a responsive grid of cards.
7
6
  *
8
- * Feature-gated on `features.learning`. Behaves like editorial /
9
- * content-feed structurally — a discoverable feed of an entity type.
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
10
8
  */
11
- import { z } from 'zod';
12
9
  import type { SectionDefinition } from '@commonpub/ui';
10
+ import { learningConfigSchema, type LearningConfig } from '@commonpub/schema';
13
11
  import SectionLearning from '../../components/sections/SectionLearning.vue';
14
12
 
15
- const configSchema = z.object({
16
- heading: z.string().max(120).default('Learning Paths'),
17
- limit: z.number().int().min(1).max(12).default(6),
18
- columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
19
- });
20
-
21
- export const learningSection: SectionDefinition<z.infer<typeof configSchema>> = {
13
+ export const learningSection: SectionDefinition<LearningConfig> = {
22
14
  type: 'learning',
23
15
  name: 'Learning paths',
24
16
  description: 'Grid of learning paths with enrollment + difficulty (feature-gated)',
@@ -27,7 +19,7 @@ export const learningSection: SectionDefinition<z.infer<typeof configSchema>> =
27
19
  status: 'stable',
28
20
  // Palette gate — see hubs.ts for the rationale.
29
21
  featureGate: 'learning',
30
- configSchema,
22
+ configSchema: learningConfigSchema,
31
23
  defaultConfig: { heading: 'Learning Paths', limit: 6, columns: 3 },
32
24
  schemaVersion: 1,
33
25
  component: SectionLearning,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Built-in section definition: markdown.
3
+ *
4
+ * Stage E.2 — reuses BlockMarkdownView. Pipes source markdown through
5
+ * markdownToBlockTuples + sanitizeBlockHtml — safer than custom-html.
6
+ *
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
8
+ */
9
+ import type { SectionDefinition } from '@commonpub/ui';
10
+ import { markdownConfigSchema, type MarkdownConfig } from '@commonpub/schema';
11
+ import BlockMarkdownView from '../../components/blocks/BlockMarkdownView.vue';
12
+
13
+ export const markdownSection: SectionDefinition<MarkdownConfig> = {
14
+ type: 'markdown',
15
+ name: 'Markdown',
16
+ description: 'Markdown body — safer than custom-html (uses BlockMarkdownView)',
17
+ icon: 'fa-markdown',
18
+ category: 'content',
19
+ status: 'stable',
20
+ configSchema: markdownConfigSchema,
21
+ defaultConfig: { source: '' },
22
+ schemaVersion: 1,
23
+ component: BlockMarkdownView,
24
+ propMap: ({ config }) => ({ content: config }),
25
+ minColSpan: 3,
26
+ maxColSpan: 12,
27
+ defaultColSpan: 12,
28
+ resizable: true,
29
+ };
@@ -1,32 +1,29 @@
1
1
  /**
2
2
  * Built-in section definition: paragraph.
3
3
  *
4
- * Phase 1c starter plain prose, blank-line split into `<p>` tags.
5
- * Upgrade to TipTap-driven rich text in Phase 3e via `.describe('rich')`
6
- * + a v2 migration; this v1 keeps the storage simple so the editor work
7
- * doesn't block on TipTap integration.
4
+ * Stage E.1reuses BlockTextView (sanitised HTML body via
5
+ * `sanitizeBlockHtml`). Admin-set HTML works because BlockTextView pipes
6
+ * through sanitizeBlockHtml same XSS posture as the rest of the Block
7
+ * system.
8
+ *
9
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
8
10
  */
9
- import { z } from 'zod';
10
11
  import type { SectionDefinition } from '@commonpub/ui';
11
- import SectionParagraph from '../../components/sections/SectionParagraph.vue';
12
-
13
- const configSchema = z.object({
14
- text: z.string().max(8000).default(''),
15
- align: z.enum(['left', 'center']).default('left'),
16
- });
12
+ import { paragraphConfigSchema, type ParagraphConfig } from '@commonpub/schema';
13
+ import BlockTextView from '../../components/blocks/BlockTextView.vue';
17
14
 
18
- export const paragraphSection: SectionDefinition<z.infer<typeof configSchema>> = {
15
+ export const paragraphSection: SectionDefinition<ParagraphConfig> = {
19
16
  type: 'paragraph',
20
17
  name: 'Paragraph',
21
- description: 'Plain prose body with blank-line paragraph breaks',
18
+ description: 'Sanitised HTML body (uses BlockTextView)',
22
19
  icon: 'fa-align-left',
23
20
  category: 'content',
24
21
  status: 'stable',
25
- configSchema,
26
- defaultConfig: { text: '', align: 'left' },
22
+ configSchema: paragraphConfigSchema,
23
+ defaultConfig: { html: '<p>Paragraph body.</p>' },
27
24
  schemaVersion: 1,
28
- component: SectionParagraph,
29
- // Prose reads best at ~6/12; allow narrower for sidebars + full-width on landing pages
25
+ component: BlockTextView,
26
+ propMap: ({ config }) => ({ content: config }),
30
27
  minColSpan: 3,
31
28
  maxColSpan: 12,
32
29
  defaultColSpan: 6,
@@ -1,34 +1,33 @@
1
1
  /**
2
2
  * Built-in section definition: stats.
3
3
  *
4
- * Phase 1c addition (session 159) platform stats grid. Server-fetches
5
- * `/api/stats` (PlatformStats) and renders a small numeric grid:
6
- * Projects, Posts (blog + legacy article), Members, Hubs.
4
+ * Stage E.4 reuses the existing StatsSection (`layers/base/
5
+ * components/homepage/StatsSection.vue`) which fetches `/api/stats`
6
+ * + renders the Projects/Posts/Members/Hubs sidebar card. StatsSection
7
+ * takes no props (reads useFeatures internally for the hubs gate).
7
8
  *
8
- * Hubs metric is gated on `features.hubs` when the flag is off, the
9
- * cell hides + the grid collapses to 1×3. Sidebar-friendly by default
10
- * (colSpan 4), but works as a narrow row at 12.
9
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
10
+ * Schema is intentionally `z.object({})` so admin tooling / migrations
11
+ * can pass extra fields (e.g., legacy `heading: 'Platform Stats'`)
12
+ * without rejection; StatsSection ignores them since it takes no props.
11
13
  */
12
- import { z } from 'zod';
13
14
  import type { SectionDefinition } from '@commonpub/ui';
14
- import SectionStats from '../../components/sections/SectionStats.vue';
15
+ import { statsConfigSchema, type StatsConfig } from '@commonpub/schema';
16
+ import StatsSection from '../../components/homepage/StatsSection.vue';
15
17
 
16
- const configSchema = z.object({
17
- heading: z.string().max(120).default('Platform Stats'),
18
- });
19
-
20
- export const statsSection: SectionDefinition<z.infer<typeof configSchema>> = {
18
+ export const statsSection: SectionDefinition<StatsConfig> = {
21
19
  type: 'stats',
22
20
  name: 'Platform stats',
23
- description: 'Numeric grid of platform totals (projects, posts, members, hubs)',
21
+ description: 'Projects/Posts/Members/Hubs sidebar card (uses StatsSection)',
24
22
  icon: 'fa-chart-simple',
25
23
  category: 'data',
26
24
  status: 'stable',
27
- configSchema,
28
- defaultConfig: { heading: 'Platform Stats' },
25
+ configSchema: statsConfigSchema,
26
+ defaultConfig: {},
29
27
  schemaVersion: 1,
30
- component: SectionStats,
31
- // Stat blocks tile down to 2×2 in a quarter-width column comfortably
28
+ component: StatsSection,
29
+ // StatsSection takes no props pass empty object
30
+ propMap: () => ({}),
32
31
  minColSpan: 3,
33
32
  maxColSpan: 12,
34
33
  defaultColSpan: 4,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Built-in section definition: video.
3
+ *
4
+ * Stage E.2 — reuses BlockVideoView (YouTube/Vimeo embed routing +
5
+ * local file fallback).
6
+ *
7
+ * Schema (incl. URL_MEDIA_OR_EMPTY guard) lives in
8
+ * `@commonpub/schema/sectionConfigs` (session 161 move).
9
+ */
10
+ import type { SectionDefinition } from '@commonpub/ui';
11
+ import { videoConfigSchema, type VideoConfig } from '@commonpub/schema';
12
+ import BlockVideoView from '../../components/blocks/BlockVideoView.vue';
13
+
14
+ export const videoSection: SectionDefinition<VideoConfig> = {
15
+ type: 'video',
16
+ name: 'Video',
17
+ description: 'YouTube / Vimeo / local file (uses BlockVideoView)',
18
+ icon: 'fa-film',
19
+ category: 'content',
20
+ status: 'stable',
21
+ configSchema: videoConfigSchema,
22
+ defaultConfig: { url: '' },
23
+ schemaVersion: 1,
24
+ component: BlockVideoView,
25
+ propMap: ({ config }) => ({ content: config }),
26
+ minColSpan: 6,
27
+ maxColSpan: 12,
28
+ defaultColSpan: 12,
29
+ resizable: true,
30
+ };