@commonpub/layer 0.23.2 → 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.2",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,14 +53,14 @@
53
53
  "zod": "^4.3.6",
54
54
  "@commonpub/auth": "0.6.0",
55
55
  "@commonpub/config": "0.15.0",
56
- "@commonpub/editor": "0.7.11",
57
- "@commonpub/explainer": "0.7.15",
58
56
  "@commonpub/docs": "0.6.3",
57
+ "@commonpub/explainer": "0.7.15",
58
+ "@commonpub/server": "2.58.0",
59
59
  "@commonpub/learning": "0.5.2",
60
60
  "@commonpub/protocol": "0.12.0",
61
+ "@commonpub/editor": "0.7.11",
61
62
  "@commonpub/schema": "0.17.0",
62
- "@commonpub/ui": "0.9.0",
63
- "@commonpub/server": "2.57.0"
63
+ "@commonpub/ui": "0.9.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@testing-library/jest-dom": "^6.9.1",
package/pages/index.vue CHANGED
@@ -14,9 +14,22 @@ const sortedSections = computed(() =>
14
14
  );
15
15
 
16
16
  const { user: authUser } = useAuth();
17
- const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled, layoutEngine: layoutEngineEnabled } = useFeatures();
17
+ const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled, layoutEngine: layoutEngineFlag } = useFeatures();
18
18
  const { enabledTypeMeta } = useContentTypes();
19
19
 
20
+ // Layout engine path — only active when BOTH the flag is on AND a layout
21
+ // actually exists in the DB for this route. Prevents the "operator enables
22
+ // the flag without seeding a homepage layout → page goes blank" trap
23
+ // (reported by user, session 158 follow-up). When the flag's on but
24
+ // useLayout returns null (feature off OR no published layout for route),
25
+ // we fall through to the configurable/legacy render paths so the user
26
+ // still sees content. Admin can call POST /api/admin/layouts/seed-homepage
27
+ // to populate a default and start using the layout engine for real.
28
+ const { layout: homepageLayout } = useLayout('/');
29
+ const layoutEngineActive = computed(
30
+ () => layoutEngineFlag.value && homepageLayout.value !== null,
31
+ );
32
+
20
33
  const activeTab = ref(authUser.value ? 'foryou' : 'latest');
21
34
  const tabs = computed(() => [
22
35
  { value: 'foryou', label: 'For You', icon: 'fa-solid fa-sparkles' },
@@ -144,15 +157,16 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
144
157
  <template>
145
158
  <div>
146
159
  <!-- ═══ LAYOUT ENGINE (Phase 1c — feature-flagged) ═══
147
- When `features.layoutEngine` is ON, render the homepage via
148
- <LayoutSlot> zones backed by the layouts table. Operators flip
149
- this on AFTER running POST /api/admin/layouts/seed-homepage so
150
- a default layout exists at scope ('route', '/'). If the flag is
151
- on but no layout exists, LayoutSlot renders nothing and the
152
- user sees an empty page — documented at
153
- docs/reference/guides/layout-engine.md. Falls through to the
154
- configurable section renderer when the flag is OFF (default). -->
155
- <template v-if="layoutEngineEnabled">
160
+ Renders DB-driven layout via <LayoutSlot> zones ONLY when BOTH
161
+ (a) features.layoutEngine is ON AND (b) a layout actually exists
162
+ at scope ('route', '/'). Falls through to the configurable or
163
+ legacy renderer otherwise including when the flag's ON but
164
+ no layout has been seeded yet (the "blank page" trap reported
165
+ in session 158 follow-up).
166
+
167
+ To enable for real: POST /api/admin/layouts/seed-homepage,
168
+ then flip the flag. See docs/reference/guides/layout-engine.md. -->
169
+ <template v-if="layoutEngineActive">
156
170
  <LayoutSlot route="/" zone="full-width" />
157
171
  <div class="cpub-main-layout">
158
172
  <main class="cpub-feed-col">
@@ -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
+ };