@commonpub/layer 0.23.0 → 0.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -13,6 +13,7 @@
13
13
  "middleware",
14
14
  "pages",
15
15
  "plugins",
16
+ "sections",
16
17
  "server",
17
18
  "theme",
18
19
  "types",
@@ -50,16 +51,16 @@
50
51
  "vue": "^3.4.0",
51
52
  "vue-router": "^4.3.0",
52
53
  "zod": "^4.3.6",
54
+ "@commonpub/auth": "0.6.0",
53
55
  "@commonpub/config": "0.15.0",
54
- "@commonpub/explainer": "0.7.15",
55
56
  "@commonpub/editor": "0.7.11",
56
- "@commonpub/auth": "0.6.0",
57
+ "@commonpub/explainer": "0.7.15",
58
+ "@commonpub/protocol": "0.12.0",
59
+ "@commonpub/server": "2.57.0",
60
+ "@commonpub/schema": "0.17.0",
57
61
  "@commonpub/learning": "0.5.2",
58
62
  "@commonpub/ui": "0.9.0",
59
- "@commonpub/schema": "0.17.0",
60
- "@commonpub/docs": "0.6.3",
61
- "@commonpub/protocol": "0.12.0",
62
- "@commonpub/server": "2.57.0"
63
+ "@commonpub/docs": "0.6.3"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Built-in section definition: content-feed.
3
+ *
4
+ * Phase 1c starter and the first DATA section. Fetches `/api/content`
5
+ * with config-driven filters and renders a responsive grid of
6
+ * `<ContentCard>`s.
7
+ *
8
+ * Config fields split into server-filter (forwarded to `/api/content`)
9
+ * and render-only (`heading`, `columns`). Keeping the contract explicit
10
+ * here matches the auto-form mapping in Phase 3e and stops accidental
11
+ * pass-through of admin-only filter values.
12
+ */
13
+ import { z } from 'zod';
14
+ import type { SectionDefinition } from '@commonpub/ui';
15
+ import SectionContentFeed from '../../components/sections/SectionContentFeed.vue';
16
+
17
+ const configSchema = z.object({
18
+ heading: z.string().max(120).default(''),
19
+ contentType: z.string().max(64).default(''),
20
+ sort: z.enum(['recent', 'popular', 'featured', 'editorial']).default('recent'),
21
+ limit: z.number().int().min(1).max(24).default(6),
22
+ columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
23
+ tag: z.string().max(64).default(''),
24
+ featured: z.boolean().default(false),
25
+ });
26
+
27
+ export const contentFeedSection: SectionDefinition<z.infer<typeof configSchema>> = {
28
+ type: 'content-feed',
29
+ name: 'Content feed',
30
+ description: 'Grid of content cards filtered by type / tag / sort',
31
+ icon: 'fa-stream',
32
+ category: 'data',
33
+ status: 'stable',
34
+ configSchema,
35
+ defaultConfig: {
36
+ heading: '',
37
+ contentType: '',
38
+ sort: 'recent',
39
+ limit: 6,
40
+ columns: 3,
41
+ tag: '',
42
+ featured: false,
43
+ },
44
+ schemaVersion: 1,
45
+ component: SectionContentFeed,
46
+ // Multi-column grid collapses to less than half-width — readability + the
47
+ // card aspect ratio break down below 6
48
+ minColSpan: 6,
49
+ maxColSpan: 12,
50
+ defaultColSpan: 12,
51
+ resizable: true,
52
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Built-in section definition: divider.
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.
7
+ *
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.
11
+ */
12
+ import { z } from 'zod';
13
+ import type { SectionDefinition } from '@commonpub/ui';
14
+ import SectionDivider from '../../components/sections/SectionDivider.vue';
15
+
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>> = {
22
+ type: 'divider',
23
+ name: 'Divider',
24
+ description: 'Horizontal rule with style + spacing options',
25
+ icon: 'fa-minus',
26
+ category: 'layout',
27
+ status: 'stable',
28
+ configSchema,
29
+ defaultConfig: { variant: 'solid', spacingY: 'md' },
30
+ schemaVersion: 1,
31
+ component: SectionDivider,
32
+ // Dividers are always full-width; resize is meaningless for a 1px line
33
+ minColSpan: 12,
34
+ maxColSpan: 12,
35
+ defaultColSpan: 12,
36
+ resizable: false,
37
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Built-in section definition: heading.
3
+ *
4
+ * Phase 1c starter — single 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).
7
+ */
8
+ import { z } from 'zod';
9
+ 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
+ });
19
+
20
+ export const headingSection: SectionDefinition<z.infer<typeof configSchema>> = {
21
+ type: 'heading',
22
+ name: 'Heading',
23
+ description: 'Single h1–h4 heading with optional eyebrow + subline',
24
+ icon: 'fa-heading',
25
+ category: 'content',
26
+ status: 'stable',
27
+ configSchema,
28
+ defaultConfig: { text: 'Section heading', level: 2, align: 'left', eyebrow: '', subline: '' },
29
+ schemaVersion: 1,
30
+ component: SectionHeading,
31
+ // Heading reads fine narrow; allow as small as quarter-width
32
+ minColSpan: 3,
33
+ maxColSpan: 12,
34
+ defaultColSpan: 12,
35
+ resizable: true,
36
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Built-in section definition: hero.
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.
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).
11
+ */
12
+ import { z } from 'zod';
13
+ import type { SectionDefinition } from '@commonpub/ui';
14
+ import SectionHero from '../../components/sections/SectionHero.vue';
15
+
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>> = {
46
+ type: 'hero',
47
+ name: 'Hero',
48
+ description: 'Banner with title, subtitle, and up to two CTAs',
49
+ icon: 'fa-bullhorn',
50
+ category: 'layout',
51
+ status: 'stable',
52
+ configSchema,
53
+ defaultConfig: {
54
+ variant: 'default',
55
+ eyebrow: '',
56
+ title: 'Welcome',
57
+ subtitle: '',
58
+ ctas: [],
59
+ },
60
+ schemaVersion: 1,
61
+ component: SectionHero,
62
+ // Hero breaks at <6 cols (CTA wrap looks awful); 12 is the canonical use
63
+ minColSpan: 6,
64
+ maxColSpan: 12,
65
+ defaultColSpan: 12,
66
+ resizable: true,
67
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Built-in section definition: image.
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.
8
+ *
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).
13
+ */
14
+ import { z } from 'zod';
15
+ import type { SectionDefinition } from '@commonpub/ui';
16
+ import SectionImage from '../../components/sections/SectionImage.vue';
17
+
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>> = {
53
+ type: 'image',
54
+ name: 'Image',
55
+ description: 'Single image with optional caption + link',
56
+ icon: 'fa-image',
57
+ category: 'content',
58
+ status: 'stable',
59
+ configSchema,
60
+ defaultConfig: { src: '', alt: '', caption: '', href: '', fit: 'contain', aspectRatio: 'auto' },
61
+ schemaVersion: 1,
62
+ component: SectionImage,
63
+ minColSpan: 3,
64
+ maxColSpan: 12,
65
+ defaultColSpan: 12,
66
+ resizable: true,
67
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Built-in section definition: paragraph.
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.
8
+ */
9
+ import { z } from 'zod';
10
+ 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
+ });
17
+
18
+ export const paragraphSection: SectionDefinition<z.infer<typeof configSchema>> = {
19
+ type: 'paragraph',
20
+ name: 'Paragraph',
21
+ description: 'Plain prose body with blank-line paragraph breaks',
22
+ icon: 'fa-align-left',
23
+ category: 'content',
24
+ status: 'stable',
25
+ configSchema,
26
+ defaultConfig: { text: '', align: 'left' },
27
+ schemaVersion: 1,
28
+ component: SectionParagraph,
29
+ // Prose reads best at ~6/12; allow narrower for sidebars + full-width on landing pages
30
+ minColSpan: 3,
31
+ maxColSpan: 12,
32
+ defaultColSpan: 6,
33
+ resizable: true,
34
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Layer-level section registry — the Vue runtime catalog that
3
+ * `<LayoutSlot>` consults to render a section by its `type` slug.
4
+ *
5
+ * `SectionRegistry` (the class) lives in `@commonpub/ui` as Vue-aware
6
+ * types. This file:
7
+ * 1. Creates ONE shared registry instance per app process
8
+ * 2. Calls `register()` for every built-in section type the layer ships
9
+ * 3. Exports `useSectionRegistry()` so consumers (LayoutSlot + the
10
+ * admin editor's palette) can read it without circular imports
11
+ *
12
+ * **Same instance on server + client.** Vue plugins instantiate at
13
+ * setup time; section registration is synchronous + idempotent (the
14
+ * `register()` method throws on duplicate slugs — fail-fast).
15
+ *
16
+ * Adding a built-in section is THREE files (see any of `./builtin/*` for
17
+ * the template):
18
+ * 1. `./builtin/{type}.ts` — Zod schema + SectionDefinition export
19
+ * 2. `../components/sections/Section{PascalType}.vue` — renderer
20
+ * 3. One `registry.register(...)` line here
21
+ *
22
+ * To add a CUSTOM section from a thin layer app (Phase 9 — not yet
23
+ * shipped): same pattern, registered from your `commonpub.config.ts`.
24
+ */
25
+ import { SectionRegistry } from '@commonpub/ui';
26
+ import { dividerSection } from './builtin/divider';
27
+ import { headingSection } from './builtin/heading';
28
+ import { paragraphSection } from './builtin/paragraph';
29
+ import { imageSection } from './builtin/image';
30
+ import { heroSection } from './builtin/hero';
31
+ import { contentFeedSection } from './builtin/content-feed';
32
+
33
+ // Singleton — registered once at module load. Vue/Nuxt's setup() runs
34
+ // per-component, but module load is once per app process. Safe.
35
+ const registry = new SectionRegistry();
36
+
37
+ // --- Built-in registrations -----------------------------------------------
38
+ // Phase 1c starter catalog: divider (proof-of-life) + 5 sections covering
39
+ // the four leading categories — layout (hero, divider), content (heading,
40
+ // paragraph, image), and data (content-feed).
41
+ //
42
+ // Phase 6b adds the remaining 20 types (gallery, video, embed, spacer,
43
+ // cta, featured-content, content-card, contest-list, hub-list, event-list,
44
+ // member-list, stats-grid, contact-form, newsletter, announcement,
45
+ // markdown, custom-html, iframe, editorial). See docs/plans/layout-and-pages.md §3.4.
46
+ registry.register(dividerSection);
47
+ registry.register(heroSection);
48
+ registry.register(headingSection);
49
+ registry.register(paragraphSection);
50
+ registry.register(imageSection);
51
+ registry.register(contentFeedSection);
52
+
53
+ /**
54
+ * Read-only accessor — the layer's standard pattern for shared state.
55
+ * Use this everywhere instead of importing `registry` directly so we
56
+ * can swap to a Nuxt-provided instance in Phase 9 (when thin apps
57
+ * register their own sections via `commonpub.config.ts`).
58
+ */
59
+ export function useSectionRegistry(): SectionRegistry {
60
+ return registry;
61
+ }