@commonpub/layer 0.23.3 → 0.24.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.
@@ -0,0 +1,232 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: learning — grid of learning paths.
4
+ *
5
+ * Fetches `/api/learn?limit=N`, renders responsive card grid (title +
6
+ * description + difficulty + duration + enrollment). Feature-gated
7
+ * upstream on `features.learning`.
8
+ *
9
+ * Same shape as editorial / content-feed (paginated /api/* endpoint →
10
+ * responsive grid) but a distinct entity type with its own metadata.
11
+ *
12
+ * `var(--*)` only.
13
+ */
14
+ import { computed } from 'vue';
15
+ import type { SectionRenderProps } from '@commonpub/ui';
16
+
17
+ // Loose shape — full LearningPathListItem lives in @commonpub/server
18
+ // types but the section only touches a subset. Keeping local avoids a
19
+ // transient type import that test stubs would need to satisfy.
20
+ interface LearningPathItem {
21
+ id: string;
22
+ slug: string;
23
+ title: string;
24
+ description: string | null;
25
+ coverImageUrl: string | null;
26
+ difficulty: string | null;
27
+ estimatedHours: string | null;
28
+ enrollmentCount: number;
29
+ moduleCount: number;
30
+ }
31
+
32
+ interface LearningResponse {
33
+ items?: LearningPathItem[];
34
+ }
35
+
36
+ interface LearningConfig extends Record<string, unknown> {
37
+ heading: string;
38
+ limit: number;
39
+ columns: 1 | 2 | 3 | 4;
40
+ }
41
+
42
+ const props = defineProps<SectionRenderProps<LearningConfig>>();
43
+
44
+ const apiQuery = computed(() => ({
45
+ limit: Math.min(Math.max(props.config.limit, 1), 12),
46
+ }));
47
+
48
+ const { data: paths, pending } = useFetch<LearningResponse>(
49
+ '/api/learn',
50
+ {
51
+ query: apiQuery,
52
+ key: `section-learning:${JSON.stringify(apiQuery.value)}`,
53
+ },
54
+ );
55
+
56
+ const items = computed(() => paths.value?.items ?? []);
57
+ const isEmpty = computed(() => !pending.value && items.value.length === 0);
58
+ </script>
59
+
60
+ <template>
61
+ <section
62
+ class="cpub-section-learning"
63
+ :aria-labelledby="config.heading ? `section-learning-${meta.sectionId}` : undefined"
64
+ >
65
+ <h2
66
+ v-if="config.heading"
67
+ :id="`section-learning-${meta.sectionId}`"
68
+ class="cpub-section-learning-heading"
69
+ >
70
+ {{ config.heading }}
71
+ </h2>
72
+
73
+ <div v-if="pending" class="cpub-section-learning-loading">
74
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
75
+ <span>Loading…</span>
76
+ </div>
77
+
78
+ <ul
79
+ v-else-if="!isEmpty"
80
+ class="cpub-section-learning-grid"
81
+ :data-columns="config.columns"
82
+ >
83
+ <li v-for="path in items" :key="path.id" class="cpub-section-learning-card">
84
+ <NuxtLink :to="`/learn/${path.slug}`" class="cpub-section-learning-link">
85
+ <!--
86
+ Using <img> rather than background-image: (a) Vue auto-escapes
87
+ attribute bindings so a path.coverImageUrl containing `");
88
+ evil(` can't escape the url(...) context (modern browsers
89
+ ignore JS in CSS URLs but still — defence in depth), and (b)
90
+ the cover IS semantically information when present, so giving
91
+ it an `alt` of the path title is better a11y than `role=
92
+ presentation`. Empty alt would also be fine here; the title
93
+ text directly follows.
94
+ -->
95
+ <img
96
+ v-if="path.coverImageUrl"
97
+ :src="path.coverImageUrl"
98
+ :alt="''"
99
+ loading="lazy"
100
+ class="cpub-section-learning-cover"
101
+ />
102
+ <div class="cpub-section-learning-body">
103
+ <h3 class="cpub-section-learning-title">{{ path.title }}</h3>
104
+ <p v-if="path.description" class="cpub-section-learning-desc">
105
+ {{ path.description }}
106
+ </p>
107
+ <div class="cpub-section-learning-meta">
108
+ <span v-if="path.difficulty" class="cpub-section-learning-chip">
109
+ {{ path.difficulty }}
110
+ </span>
111
+ <span v-if="path.estimatedHours" class="cpub-section-learning-chip">
112
+ <i class="fa-regular fa-clock" aria-hidden="true" />
113
+ {{ path.estimatedHours }}h
114
+ </span>
115
+ <span class="cpub-section-learning-chip">
116
+ {{ path.enrollmentCount }} enrolled
117
+ </span>
118
+ </div>
119
+ </div>
120
+ </NuxtLink>
121
+ </li>
122
+ </ul>
123
+
124
+ <p v-else class="cpub-section-learning-empty">No learning paths yet.</p>
125
+ </section>
126
+ </template>
127
+
128
+ <style scoped>
129
+ .cpub-section-learning {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: var(--space-3);
133
+ }
134
+ .cpub-section-learning-heading {
135
+ font-family: var(--font-mono);
136
+ font-size: var(--text-xs);
137
+ font-weight: 700;
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.08em;
140
+ color: var(--text-faint);
141
+ margin: 0;
142
+ padding-bottom: var(--space-2);
143
+ border-bottom: var(--border-width-default) solid var(--border);
144
+ }
145
+ .cpub-section-learning-grid {
146
+ display: grid;
147
+ gap: var(--space-3);
148
+ list-style: none;
149
+ margin: 0;
150
+ padding: 0;
151
+ }
152
+ .cpub-section-learning-grid[data-columns='1'] { grid-template-columns: 1fr; }
153
+ .cpub-section-learning-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
154
+ .cpub-section-learning-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
155
+ .cpub-section-learning-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
156
+
157
+ @media (max-width: 1024px) {
158
+ .cpub-section-learning-grid[data-columns='3'],
159
+ .cpub-section-learning-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
160
+ }
161
+ @media (max-width: 640px) {
162
+ .cpub-section-learning-grid { grid-template-columns: 1fr; }
163
+ }
164
+
165
+ .cpub-section-learning-card {
166
+ background: var(--surface);
167
+ border: var(--border-width-default) solid var(--border);
168
+ overflow: hidden;
169
+ }
170
+ .cpub-section-learning-link {
171
+ display: flex;
172
+ flex-direction: column;
173
+ color: inherit;
174
+ text-decoration: none;
175
+ }
176
+ .cpub-section-learning-cover {
177
+ display: block;
178
+ width: 100%;
179
+ aspect-ratio: 16 / 9;
180
+ object-fit: cover;
181
+ background-color: var(--surface-2);
182
+ }
183
+ .cpub-section-learning-body {
184
+ padding: var(--space-3);
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: var(--space-2);
188
+ }
189
+ .cpub-section-learning-title {
190
+ font-size: var(--text-base);
191
+ font-weight: 700;
192
+ color: var(--text);
193
+ margin: 0;
194
+ }
195
+ .cpub-section-learning-link:hover .cpub-section-learning-title { color: var(--accent); }
196
+ .cpub-section-learning-desc {
197
+ font-size: var(--text-sm);
198
+ color: var(--text-soft);
199
+ margin: 0;
200
+ display: -webkit-box;
201
+ -webkit-line-clamp: 2;
202
+ -webkit-box-orient: vertical;
203
+ overflow: hidden;
204
+ }
205
+ .cpub-section-learning-meta {
206
+ display: flex;
207
+ flex-wrap: wrap;
208
+ gap: var(--space-2);
209
+ }
210
+ .cpub-section-learning-chip {
211
+ font-family: var(--font-mono);
212
+ font-size: var(--text-xxs);
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.06em;
215
+ color: var(--text-faint);
216
+ padding: var(--space-1) var(--space-2);
217
+ border: var(--border-width-default) solid var(--border-soft);
218
+ display: inline-flex;
219
+ align-items: center;
220
+ gap: var(--space-1);
221
+ }
222
+ .cpub-section-learning-loading,
223
+ .cpub-section-learning-empty {
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ gap: var(--space-2);
228
+ padding: var(--space-6);
229
+ color: var(--text-faint);
230
+ font-size: var(--text-sm);
231
+ }
232
+ </style>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: stats — a numeric grid of platform totals.
4
+ *
5
+ * Fetches `/api/stats` (returns PlatformStats), renders 2x2 grid of
6
+ * Projects / Posts / Members / Hubs. Hubs cell hides when the `hubs`
7
+ * feature flag is off — same logic as the legacy `StatsSection.vue`.
8
+ *
9
+ * Non-await useFetch per session-158 pattern; pending state surfaced
10
+ * via the template. SSR-friendly: useFetch includes the payload in the
11
+ * hydration snapshot.
12
+ *
13
+ * `var(--*)` only.
14
+ */
15
+ import { computed } from 'vue';
16
+ import type { SectionRenderProps } from '@commonpub/ui';
17
+
18
+ // Avoid `import type { PlatformStats }` at the call site so the runtime
19
+ // stub used in component tests doesn't need to satisfy the full server
20
+ // type. Loose shape with optional + numeric primitives mirrors what the
21
+ // endpoint actually emits.
22
+ interface StatsResponse {
23
+ content?: {
24
+ byType?: {
25
+ project?: number;
26
+ blog?: number;
27
+ article?: number;
28
+ };
29
+ };
30
+ users?: { total?: number };
31
+ hubs?: { total?: number };
32
+ }
33
+
34
+ interface StatsConfig extends Record<string, unknown> {
35
+ heading: string;
36
+ }
37
+
38
+ const props = defineProps<SectionRenderProps<StatsConfig>>();
39
+
40
+ const features = useFeatures();
41
+ const hubsEnabled = computed(() => features.hubs.value);
42
+
43
+ const { data: stats, pending } = useFetch<StatsResponse>(
44
+ '/api/stats',
45
+ {
46
+ key: `section-stats:${props.meta.sectionId}`,
47
+ // Lazy — sidebar metric tile, not above-the-fold content. Matches the
48
+ // legacy StatsSection.vue pattern and keeps homepage SSR fast.
49
+ lazy: true,
50
+ },
51
+ );
52
+
53
+ const projectCount = computed(() => stats.value?.content?.byType?.project ?? 0);
54
+ const postCount = computed(
55
+ () => (stats.value?.content?.byType?.blog ?? 0)
56
+ + (stats.value?.content?.byType?.article ?? 0),
57
+ );
58
+ const memberCount = computed(() => stats.value?.users?.total ?? 0);
59
+ const hubCount = computed(() => stats.value?.hubs?.total ?? 0);
60
+ </script>
61
+
62
+ <template>
63
+ <section
64
+ class="cpub-section-stats"
65
+ :aria-labelledby="config.heading ? `section-stats-${meta.sectionId}` : undefined"
66
+ >
67
+ <h2
68
+ v-if="config.heading"
69
+ :id="`section-stats-${meta.sectionId}`"
70
+ class="cpub-section-stats-heading"
71
+ >
72
+ {{ config.heading }}
73
+ </h2>
74
+
75
+ <div v-if="pending" class="cpub-section-stats-loading">
76
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
77
+ </div>
78
+
79
+ <dl v-else class="cpub-section-stats-grid" :data-with-hubs="hubsEnabled ? 'yes' : 'no'">
80
+ <div class="cpub-section-stats-block">
81
+ <dt>Projects</dt>
82
+ <dd>{{ projectCount }}</dd>
83
+ </div>
84
+ <div class="cpub-section-stats-block">
85
+ <dt>Posts</dt>
86
+ <dd>{{ postCount }}</dd>
87
+ </div>
88
+ <div class="cpub-section-stats-block">
89
+ <dt>Members</dt>
90
+ <dd>{{ memberCount }}</dd>
91
+ </div>
92
+ <div v-if="hubsEnabled" class="cpub-section-stats-block">
93
+ <dt>Hubs</dt>
94
+ <dd>{{ hubCount }}</dd>
95
+ </div>
96
+ </dl>
97
+ </section>
98
+ </template>
99
+
100
+ <style scoped>
101
+ .cpub-section-stats {
102
+ background: var(--surface);
103
+ border: var(--border-width-default) solid var(--border);
104
+ padding: var(--space-4);
105
+ }
106
+ .cpub-section-stats-heading {
107
+ font-family: var(--font-mono);
108
+ font-size: var(--text-xxs);
109
+ font-weight: 700;
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.08em;
112
+ color: var(--text-faint);
113
+ margin: 0 0 var(--space-3) 0;
114
+ padding-bottom: var(--space-2);
115
+ border-bottom: var(--border-width-default) solid var(--border-soft);
116
+ }
117
+ .cpub-section-stats-loading {
118
+ display: flex;
119
+ justify-content: center;
120
+ padding: var(--space-4);
121
+ color: var(--text-faint);
122
+ }
123
+ .cpub-section-stats-grid {
124
+ display: grid;
125
+ grid-template-columns: 1fr 1fr;
126
+ gap: var(--space-2);
127
+ margin: 0;
128
+ }
129
+ .cpub-section-stats-block {
130
+ text-align: center;
131
+ padding: var(--space-2) 0;
132
+ }
133
+ .cpub-section-stats-block dt {
134
+ display: block;
135
+ font-family: var(--font-mono);
136
+ font-size: var(--text-xxs);
137
+ text-transform: uppercase;
138
+ letter-spacing: 0.06em;
139
+ color: var(--text-faint);
140
+ order: 2; /* number above label */
141
+ }
142
+ .cpub-section-stats-block dd {
143
+ display: block;
144
+ font-family: var(--font-mono);
145
+ font-size: var(--text-lg);
146
+ font-weight: 700;
147
+ color: var(--text);
148
+ margin: 0;
149
+ order: 1;
150
+ }
151
+ </style>
@@ -64,14 +64,41 @@ export const DEFAULT_FLAGS: FeatureFlags = {
64
64
  },
65
65
  };
66
66
 
67
- /** Build the initial flags by merging the layer's runtime config over defaults. */
67
+ /**
68
+ * Build the initial flags. Two sources, in priority order:
69
+ *
70
+ * 1. **Server-side, request-scoped**: `event.context.cpubFeatureFlags` —
71
+ * DB-merged values set by the Nitro `feature-flags-prime` plugin
72
+ * (apps/reference/server/plugins/feature-flags-prime.ts). This is
73
+ * how admin-UI flag overrides take effect at SSR time. Without it,
74
+ * SSR would render with stale build-time defaults until client
75
+ * hydration replaced them — visible flash + broken curl-based canary.
76
+ *
77
+ * 2. **Build-time runtime config**: `useRuntimeConfig().public.features` —
78
+ * the legacy initialisation. Fallback for client-side, for early
79
+ * startup before the plugin's hook can fire, and for thin apps
80
+ * that haven't installed the prime plugin yet.
81
+ *
82
+ * Either way, defaults fill any unmentioned flags + identity is deep-
83
+ * merged so a partial override (e.g. `{ identity: { actingAs: true } }`)
84
+ * lands on top of the defaulted sub-flags rather than replacing the
85
+ * whole nested object.
86
+ */
68
87
  export function getInitialFlags(): FeatureFlags {
88
+ if (import.meta.server) {
89
+ // useRequestEvent is auto-imported on Nuxt server-side
90
+ const event = (typeof useRequestEvent === 'function') ? useRequestEvent() : null;
91
+ const ctxFlags = event?.context?.cpubFeatureFlags as Partial<FeatureFlags> | undefined;
92
+ if (ctxFlags && typeof ctxFlags === 'object') {
93
+ return {
94
+ ...DEFAULT_FLAGS,
95
+ ...ctxFlags,
96
+ identity: { ...DEFAULT_FLAGS.identity, ...(ctxFlags.identity ?? {}) },
97
+ };
98
+ }
99
+ }
69
100
  const config = useRuntimeConfig();
70
101
  const buildFlags = (config.public.features as unknown as Partial<FeatureFlags> | undefined) ?? {};
71
- // Merge top-level booleans, but deep-merge `identity` so a partial
72
- // runtime override (e.g., `{ identity: { actingAs: true } }`) lands on
73
- // top of the defaulted sub-flags rather than replacing the whole
74
- // nested object.
75
102
  return {
76
103
  ...DEFAULT_FLAGS,
77
104
  ...buildFlags,
@@ -17,6 +17,7 @@
17
17
  * The `<LayoutSlot>` component is the only intended caller in v1; other
18
18
  * consumers should use that instead.
19
19
  */
20
+ import { computed } from 'vue';
20
21
  import type { Ref } from 'vue';
21
22
 
22
23
  export interface LayoutSection {
@@ -96,6 +97,16 @@ export function useLayout(path: string | Ref<string> | (() => string)): UseLayou
96
97
  : path.value
97
98
  );
98
99
 
100
+ // Resolve query as a reactive computed — passing `{ path: pathGetter }`
101
+ // (a raw function value) made useFetch serialise the function reference,
102
+ // turning the request URL into ?path=undefined → 400 → null layout. The
103
+ // bug was load-bearing for the canary on commonpub.io (session 159): SSR
104
+ // saw `homepageLayout.value === null` despite a published layout existing,
105
+ // so layoutEngineActive resolved to false and pages/index.vue fell
106
+ // through to the legacy renderer. Fix: always pass a Ref/computed so
107
+ // useFetch reads the resolved value AND re-evaluates on dep change.
108
+ const queryRef = computed(() => ({ path: pathGetter() }));
109
+
99
110
  const { data, pending, error, refresh } = useFetch<LayoutPayload | null>(
100
111
  '/api/layouts/by-route',
101
112
  {
@@ -103,8 +114,7 @@ export function useLayout(path: string | Ref<string> | (() => string)): UseLayou
103
114
  // can dedupe across components on the same nav). For the reactive
104
115
  // case, omit key so useFetch derives one from the query Ref.
105
116
  key: typeof path === 'string' ? `layout:${path}` : undefined,
106
- // Functional query — useFetch re-evaluates on watched deps change.
107
- query: { path: pathGetter },
117
+ query: queryRef,
108
118
  // Watch the path getter so reactive callers refetch on change.
109
119
  // For string callers this is empty array → no extra reactivity.
110
120
  watch: typeof path === 'string' ? [] : [pathGetter],
package/nuxt.config.ts CHANGED
@@ -82,6 +82,14 @@ export default defineNuxtConfig({
82
82
  domain: 'localhost:3000',
83
83
  siteName: 'CommonPub',
84
84
  siteDescription: 'A CommonPub instance',
85
+ // Nuxt only propagates env-var overrides (NUXT_PUBLIC_FEATURES_X) for
86
+ // keys DECLARED here. Undeclared keys are ignored at runtime, so
87
+ // every flag in @commonpub/config's FeatureFlags type must appear
88
+ // below — even if its default is false — or operators can't flip
89
+ // it via env var on a per-instance basis. Drift caused
90
+ // commonpub.io's first canary attempt to silently keep
91
+ // layoutEngine:false at SSR despite NUXT_PUBLIC_FEATURES_LAYOUT_ENGINE=true
92
+ // being set on the container.
85
93
  features: {
86
94
  content: true,
87
95
  social: true,
@@ -89,12 +97,18 @@ export default defineNuxtConfig({
89
97
  docs: true,
90
98
  video: true,
91
99
  contests: false,
100
+ events: false,
92
101
  learning: true,
93
102
  explainers: true,
103
+ editorial: true,
94
104
  federation: false,
95
105
  federateHubs: false,
106
+ seamlessFederation: false,
96
107
  admin: false,
97
108
  emailNotifications: false,
109
+ publicApi: false,
110
+ contentImport: true,
111
+ layoutEngine: false,
98
112
  },
99
113
  contentTypes: 'project,blog,explainer',
100
114
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,16 +51,16 @@
51
51
  "vue": "^3.4.0",
52
52
  "vue-router": "^4.3.0",
53
53
  "zod": "^4.3.6",
54
+ "@commonpub/auth": "0.6.0",
54
55
  "@commonpub/config": "0.15.0",
55
56
  "@commonpub/docs": "0.6.3",
57
+ "@commonpub/explainer": "0.7.15",
58
+ "@commonpub/server": "2.58.0",
56
59
  "@commonpub/learning": "0.5.2",
57
- "@commonpub/editor": "0.7.11",
58
60
  "@commonpub/protocol": "0.12.0",
61
+ "@commonpub/editor": "0.7.11",
59
62
  "@commonpub/schema": "0.17.0",
60
- "@commonpub/server": "2.57.0",
61
- "@commonpub/ui": "0.9.0",
62
- "@commonpub/auth": "0.6.0",
63
- "@commonpub/explainer": "0.7.15"
63
+ "@commonpub/ui": "0.9.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Built-in section definition: contests.
3
+ *
4
+ * Phase 1c addition (session 159) — active-contests list. Server-fetches
5
+ * `/api/contests?limit=N` and renders a sidebar-style card with title,
6
+ * entry count, "Nd left" deadline, and per-row Enter CTA.
7
+ *
8
+ * Feature-gated on `features.contests`. If the flag is off the API
9
+ * returns 404; the section renders nothing (empty branch). Sidebar
10
+ * defaults (colSpan 4) match the legacy `ContestsSection.vue` placement.
11
+ */
12
+ import { z } from 'zod';
13
+ import type { SectionDefinition } from '@commonpub/ui';
14
+ import SectionContests from '../../components/sections/SectionContests.vue';
15
+
16
+ const configSchema = z.object({
17
+ heading: z.string().max(120).default('Active Contests'),
18
+ limit: z.number().int().min(1).max(10).default(3),
19
+ });
20
+
21
+ export const contestsSection: SectionDefinition<z.infer<typeof configSchema>> = {
22
+ type: 'contests',
23
+ name: 'Contests',
24
+ description: 'Active contests with deadlines (feature-gated)',
25
+ icon: 'fa-trophy',
26
+ category: 'data',
27
+ status: 'stable',
28
+ // Palette gate — see hubs.ts for the rationale.
29
+ featureGate: 'contests',
30
+ configSchema,
31
+ defaultConfig: { heading: 'Active Contests', limit: 3 },
32
+ schemaVersion: 1,
33
+ component: SectionContests,
34
+ minColSpan: 3,
35
+ maxColSpan: 12,
36
+ defaultColSpan: 4,
37
+ resizable: true,
38
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Built-in section definition: custom-html.
3
+ *
4
+ * Phase 1c addition (session 159) — admin-only raw HTML escape hatch.
5
+ *
6
+ * **SECURITY POSTURE** — important. This section renders config.html via
7
+ * `v-html` with NO runtime sanitization, intentionally matching the
8
+ * legacy `CustomHtmlSection.vue` behavior (already shipped in
9
+ * production for the configurable homepage path). The threat model:
10
+ * - Writes are gated on `requireAdmin(event)` in
11
+ * `/api/admin/layouts/*` — only trusted admin users can set HTML.
12
+ * - Even so, a compromised admin account → stored XSS on every
13
+ * visitor's homepage. Phase 6b will add server-side DOMPurify
14
+ * sanitization at admin-write time (matching the pattern in
15
+ * `packages/server/src/content/content.ts:sanitizeBlockContent`)
16
+ * and an `unsafeHtmlAllowed` instance setting that gates whether
17
+ * this section type can be saved at all.
18
+ * - For now: `status: 'beta'` flags the risk to admin-UI consumers.
19
+ * The 50KB cap is a sanity bound, not a security control.
20
+ * - Tracked in docs/plans/layout-and-pages.md §6.5 (custom-html
21
+ * sanitization roadmap).
22
+ *
23
+ * Use this only when no other section type fits.
24
+ */
25
+ import { z } from 'zod';
26
+ import type { SectionDefinition } from '@commonpub/ui';
27
+ import SectionCustomHtml from '../../components/sections/SectionCustomHtml.vue';
28
+
29
+ const configSchema = z.object({
30
+ heading: z.string().max(120).default(''),
31
+ html: z.string().max(50_000).default(''),
32
+ });
33
+
34
+ export const customHtmlSection: SectionDefinition<z.infer<typeof configSchema>> = {
35
+ type: 'custom-html',
36
+ name: 'Custom HTML',
37
+ description: 'Raw HTML escape hatch — admin-only; see security note in source',
38
+ icon: 'fa-code',
39
+ category: 'content',
40
+ // Beta marks the unsanitised render path; admin UI shows a warning chip
41
+ status: 'beta',
42
+ configSchema,
43
+ defaultConfig: { heading: '', html: '' },
44
+ schemaVersion: 1,
45
+ component: SectionCustomHtml,
46
+ minColSpan: 3,
47
+ maxColSpan: 12,
48
+ defaultColSpan: 12,
49
+ resizable: true,
50
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Built-in section definition: editorial.
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.
8
+ *
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.
12
+ */
13
+ import { z } from 'zod';
14
+ import type { SectionDefinition } from '@commonpub/ui';
15
+ import SectionEditorial from '../../components/sections/SectionEditorial.vue';
16
+
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>> = {
24
+ type: 'editorial',
25
+ name: 'Editorial',
26
+ description: 'Staff-picked content grid (editorial flag in /api/content)',
27
+ icon: 'fa-pen-fancy',
28
+ category: 'data',
29
+ status: 'stable',
30
+ configSchema,
31
+ defaultConfig: { heading: 'Staff Picks', limit: 3, columns: 3 },
32
+ schemaVersion: 1,
33
+ component: SectionEditorial,
34
+ // 3+ column ContentCard grid loses readability below half-width
35
+ minColSpan: 6,
36
+ maxColSpan: 12,
37
+ defaultColSpan: 12,
38
+ resizable: true,
39
+ };