@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.
- package/components/sections/SectionContests.vue +193 -0
- package/components/sections/SectionCustomHtml.vue +70 -0
- package/components/sections/SectionEditorial.vue +138 -0
- package/components/sections/SectionHubs.vue +247 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/components/sections/SectionStats.vue +151 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +12 -2
- package/nuxt.config.ts +14 -0
- package/package.json +5 -5
- package/pages/index.vue +24 -10
- package/sections/builtin/contests.ts +38 -0
- package/sections/builtin/custom-html.ts +50 -0
- package/sections/builtin/editorial.ts +39 -0
- package/sections/builtin/hubs.ts +45 -0
- package/sections/builtin/learning.ts +38 -0
- package/sections/builtin/stats.ts +36 -0
- package/sections/registry.ts +27 -7
- package/server/api/admin/layouts/migrate-homepage.post.ts +56 -0
- package/server/plugins/feature-flags-prime.ts +39 -0
|
@@ -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
|
-
/**
|
|
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,
|
package/composables/useLayout.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
};
|