@commonpub/layer 0.24.0 → 0.25.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.
Files changed (82) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-section config validation — runs every section's registered
3
+ * Zod schema against the user-submitted config blob.
4
+ *
5
+ * P1 security fix from session 160 audit, finally wired in session 161
6
+ * after the schemas moved to `@commonpub/schema/sectionConfigs`. The
7
+ * `layoutCreateSchema` top-level Zod only validates the SHAPE of
8
+ * `section.config` as `z.record(z.unknown())` — it doesn't enforce
9
+ * per-type rules (URL guards on hrefs, size caps on arrays, etc)
10
+ * declared in each section's `configSchema`. Without this check,
11
+ * an admin can bypass those guards by sending arbitrary config —
12
+ * limited blast radius (admin auth required) but every CMS treats
13
+ * admin-tier input as semi-trusted and validates anyway.
14
+ *
15
+ * Throws an h3-compatible 400 with a structured `data.sectionErrors`
16
+ * payload listing every invalid section + the Zod issue. Used by:
17
+ * - POST /api/admin/layouts (create)
18
+ * - PUT /api/admin/layouts/[id] (update)
19
+ *
20
+ * Unknown section types ALSO 400 — same handler — so a typo'd type
21
+ * surfaces as a validation error instead of silently rendering a
22
+ * placeholder on the public page.
23
+ *
24
+ * Server-safe because `@commonpub/schema` has zero Vue imports. The
25
+ * previous attempt to wire this (session 160 R2) imported the section
26
+ * registry, which transitively pulled `.vue` components into the Nitro
27
+ * bundle and broke the build. The proper fix — moving schemas to the
28
+ * schema package — was deferred then; this is that fix.
29
+ */
30
+ import { SECTION_CONFIG_SCHEMAS } from '@commonpub/schema';
31
+
32
+ /**
33
+ * Throw an h3/Nuxt-compatible HTTP error WITHOUT depending on h3
34
+ * directly (h3 isn't a direct layer dep + isn't resolvable from
35
+ * vitest). Nitro's error handler treats any thrown Error with
36
+ * `statusCode` + `statusMessage` + `data` as the equivalent of
37
+ * createError() — same wire format.
38
+ */
39
+ function httpError(opts: { statusCode: number; statusMessage: string; data?: unknown }): Error {
40
+ const err = new Error(opts.statusMessage) as Error & {
41
+ statusCode: number;
42
+ statusMessage: string;
43
+ data?: unknown;
44
+ };
45
+ err.statusCode = opts.statusCode;
46
+ err.statusMessage = opts.statusMessage;
47
+ err.data = opts.data;
48
+ return err;
49
+ }
50
+
51
+ interface InputZone {
52
+ zone: string;
53
+ rows: Array<{
54
+ config?: unknown;
55
+ sections: Array<{
56
+ type: string;
57
+ config: Record<string, unknown>;
58
+ }>;
59
+ }>;
60
+ }
61
+
62
+ interface SectionError {
63
+ zone: string;
64
+ rowIndex: number;
65
+ sectionIndex: number;
66
+ type: string;
67
+ // Zod 4's issue.path is PropertyKey[] (string | number | symbol);
68
+ // symbol paths in user-submitted JSON are not reachable but the type
69
+ // must accept them.
70
+ issues: Array<{ path: PropertyKey[]; message: string }>;
71
+ }
72
+
73
+ /**
74
+ * Validate every section in a layout's zones against its registered
75
+ * Zod configSchema. Throws an h3-compatible 400 on any failure.
76
+ *
77
+ * No `registry` parameter — schema lookup is done via
78
+ * `SECTION_CONFIG_SCHEMAS` from `@commonpub/schema`, which is the
79
+ * canonical type → schema map. Keep that map in sync when adding
80
+ * new section types (see `packages/schema/src/sectionConfigs.ts`).
81
+ */
82
+ export function validateSectionConfigs(zones: InputZone[]): void {
83
+ const errors: SectionError[] = [];
84
+
85
+ for (const zone of zones) {
86
+ zone.rows.forEach((row, rowIndex) => {
87
+ row.sections.forEach((section, sectionIndex) => {
88
+ const schema = SECTION_CONFIG_SCHEMAS[section.type];
89
+ if (!schema) {
90
+ errors.push({
91
+ zone: zone.zone,
92
+ rowIndex,
93
+ sectionIndex,
94
+ type: section.type,
95
+ issues: [{ path: ['type'], message: `Unknown section type: ${section.type}` }],
96
+ });
97
+ return;
98
+ }
99
+ const result = schema.safeParse(section.config);
100
+ if (!result.success) {
101
+ errors.push({
102
+ zone: zone.zone,
103
+ rowIndex,
104
+ sectionIndex,
105
+ type: section.type,
106
+ issues: result.error.issues.map((i) => ({
107
+ path: [...i.path],
108
+ message: i.message,
109
+ })),
110
+ });
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ if (errors.length > 0) {
117
+ throw httpError({
118
+ statusCode: 400,
119
+ statusMessage: `Section config validation failed (${errors.length} section${errors.length === 1 ? '' : 's'})`,
120
+ data: { code: 'SECTION_CONFIG_INVALID', sectionErrors: errors },
121
+ });
122
+ }
123
+ }
package/theme/base.css CHANGED
@@ -218,6 +218,7 @@
218
218
  --nav-height: 3rem; /* 48px topbar */
219
219
  --subnav-height: 2.75rem;
220
220
  --sidebar-width: 12.5rem; /* 200px fixed sidebar */
221
+ --sidebar-width-collapsed: 3.5rem; /* 56px icons-only — admin chrome collapsed state */
221
222
  --content-max-width: 60rem; /* 960px */
222
223
  --content-wide-max-width: 75rem;
223
224
 
@@ -1,160 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: content-feed — a data-driven grid of content cards.
4
- *
5
- * Phase 1c starter and the first DATA section. Fetches `/api/content`
6
- * with config-driven filter parameters; renders the existing
7
- * `<ContentCard>` so the visual identity matches feeds elsewhere on the
8
- * site.
9
- *
10
- * Each instance fetches independently (no global feed cache) — Nuxt's
11
- * useFetch dedupes by `key`, which we derive from the config so two
12
- * identically-configured feeds share a single request while two
13
- * differently-configured feeds (e.g. main + sidebar with different
14
- * `sort`) hit the endpoint separately.
15
- *
16
- * SSR-safe: useFetch fetches at setup() and includes the payload in the
17
- * hydration snapshot.
18
- *
19
- * `var(--*)` only.
20
- */
21
- import { computed } from 'vue';
22
- import type { PaginatedResponse, Serialized, ContentListItem } from '@commonpub/server';
23
- import type { SectionRenderProps } from '@commonpub/ui';
24
-
25
- interface ContentFeedConfig extends Record<string, unknown> {
26
- heading: string;
27
- contentType: string;
28
- sort: 'recent' | 'popular' | 'featured' | 'editorial';
29
- limit: number;
30
- columns: 1 | 2 | 3 | 4;
31
- tag: string;
32
- featured: boolean;
33
- }
34
-
35
- const props = defineProps<SectionRenderProps<ContentFeedConfig>>();
36
-
37
- // Build the API query — server expects `type` (single value or absent),
38
- // `sort`, `limit`, `tag`, `featured`. Omit empty strings so the validator
39
- // treats them as absent (vs the empty-string=match-empty trap).
40
- const apiQuery = computed(() => {
41
- const q: Record<string, unknown> = {
42
- status: 'published',
43
- sort: props.config.sort,
44
- limit: Math.min(Math.max(props.config.limit, 1), 24),
45
- };
46
- if (props.config.contentType) q.type = props.config.contentType;
47
- if (props.config.tag) q.tag = props.config.tag;
48
- if (props.config.featured) q.featured = true;
49
- return q;
50
- });
51
-
52
- // Stable key so two identical content-feed sections on the same page
53
- // share a single request, while different configurations don't collide.
54
- const fetchKey = computed(
55
- () => `section-content-feed:${JSON.stringify(apiQuery.value)}`,
56
- );
57
-
58
- // NO await — section is rendered inside <LayoutSlot> deep in the tree;
59
- // awaiting top-level here would require Suspense on every parent, which
60
- // neither the production page-render path nor the editor preview pane
61
- // is set up for. The page (`/`, `/about`, etc.) already does its own
62
- // `await useFetch` for content via the legacy renderer, so initial
63
- // load is data-ready; this section's fetch is a fresh request per
64
- // instance + config. Pending state surfaces in the template instead.
65
- const { data: feed, pending } = useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
66
- '/api/content',
67
- {
68
- query: apiQuery,
69
- key: fetchKey.value,
70
- // Empty-result handler: surface a friendly empty state in the template
71
- // rather than throwing. 404 wouldn't be a thing on /api/content anyway.
72
- },
73
- );
74
-
75
- const items = computed(() => feed.value?.items ?? []);
76
- const isEmpty = computed(() => !pending.value && items.value.length === 0);
77
- </script>
78
-
79
- <template>
80
- <section
81
- class="cpub-section-content-feed"
82
- :aria-labelledby="config.heading ? `section-feed-${meta.sectionId}` : undefined"
83
- >
84
- <h2
85
- v-if="config.heading"
86
- :id="`section-feed-${meta.sectionId}`"
87
- class="cpub-section-content-feed-heading"
88
- >
89
- {{ config.heading }}
90
- </h2>
91
-
92
- <div v-if="pending" class="cpub-section-content-feed-loading">
93
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
94
- <span>Loading…</span>
95
- </div>
96
-
97
- <div
98
- v-else-if="!isEmpty"
99
- class="cpub-section-content-feed-grid"
100
- :data-columns="config.columns"
101
- >
102
- <ContentCard
103
- v-for="item in items"
104
- :key="item.id"
105
- :item="item"
106
- />
107
- </div>
108
-
109
- <p v-else class="cpub-section-content-feed-empty">
110
- No content yet.
111
- </p>
112
- </section>
113
- </template>
114
-
115
- <style scoped>
116
- .cpub-section-content-feed {
117
- display: flex;
118
- flex-direction: column;
119
- gap: var(--space-3);
120
- }
121
- .cpub-section-content-feed-heading {
122
- font-family: var(--font-mono);
123
- font-size: var(--text-xs);
124
- font-weight: 700;
125
- text-transform: uppercase;
126
- letter-spacing: 0.08em;
127
- color: var(--text-faint);
128
- margin: 0;
129
- padding-bottom: var(--space-2);
130
- border-bottom: var(--border-width-default) solid var(--border);
131
- }
132
- .cpub-section-content-feed-grid {
133
- display: grid;
134
- gap: var(--space-3);
135
- }
136
- .cpub-section-content-feed-grid[data-columns='1'] { grid-template-columns: 1fr; }
137
- .cpub-section-content-feed-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
138
- .cpub-section-content-feed-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
139
- .cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
140
-
141
- /* Responsive collapse — multi-col grids stack on tablet/mobile */
142
- @media (max-width: 1024px) {
143
- .cpub-section-content-feed-grid[data-columns='3'],
144
- .cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
145
- }
146
- @media (max-width: 640px) {
147
- .cpub-section-content-feed-grid { grid-template-columns: 1fr; }
148
- }
149
-
150
- .cpub-section-content-feed-loading,
151
- .cpub-section-content-feed-empty {
152
- display: flex;
153
- align-items: center;
154
- justify-content: center;
155
- gap: var(--space-2);
156
- padding: var(--space-6);
157
- color: var(--text-faint);
158
- font-size: var(--text-sm);
159
- }
160
- </style>
@@ -1,193 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: contests — active-contests list with countdown.
4
- *
5
- * Fetches `/api/contests?limit=N`, renders sidebar-style card. Each
6
- * row: title (link), entry count, "Nd left" deadline, Enter CTA.
7
- * Mirrors legacy `ContestsSection.vue`.
8
- *
9
- * Feature-gated upstream — when `features.contests` is off the API
10
- * returns 404 and the section renders the empty branch.
11
- *
12
- * Non-await useFetch per session-158 pattern. `var(--*)` only.
13
- */
14
- import { computed } from 'vue';
15
- import type { SectionRenderProps } from '@commonpub/ui';
16
-
17
- interface ContestItem {
18
- id: string;
19
- slug: string;
20
- title: string;
21
- entryCount?: number | null;
22
- endDate?: string | Date | null;
23
- }
24
-
25
- interface ContestsResponse {
26
- items?: ContestItem[];
27
- }
28
-
29
- interface ContestsConfig extends Record<string, unknown> {
30
- heading: string;
31
- limit: number;
32
- }
33
-
34
- const props = defineProps<SectionRenderProps<ContestsConfig>>();
35
-
36
- const apiQuery = computed(() => ({
37
- limit: Math.min(Math.max(props.config.limit, 1), 10),
38
- }));
39
-
40
- const { data: contests, pending } = useFetch<ContestsResponse>(
41
- '/api/contests',
42
- {
43
- query: apiQuery,
44
- key: `section-contests:${JSON.stringify(apiQuery.value)}`,
45
- // Sidebar widget — lazy so initial SSR doesn't block on the contests
46
- // query (which is ~5x slower than /api/hubs in practice). Matches the
47
- // legacy ContestsSection.vue pattern.
48
- lazy: true,
49
- },
50
- );
51
-
52
- const items = computed(() => contests.value?.items ?? []);
53
- const isEmpty = computed(() => !pending.value && items.value.length === 0);
54
-
55
- function daysLeft(endDate: string | Date | null | undefined): number | null {
56
- if (!endDate) return null;
57
- const ms = new Date(endDate).getTime() - Date.now();
58
- if (Number.isNaN(ms)) return null;
59
- return Math.max(0, Math.ceil(ms / 86_400_000));
60
- }
61
- </script>
62
-
63
- <template>
64
- <section
65
- class="cpub-section-contests"
66
- :aria-labelledby="config.heading ? `section-contests-${meta.sectionId}` : undefined"
67
- >
68
- <header
69
- v-if="config.heading"
70
- class="cpub-section-contests-header"
71
- >
72
- <h2
73
- :id="`section-contests-${meta.sectionId}`"
74
- class="cpub-section-contests-heading"
75
- >
76
- {{ config.heading }}
77
- </h2>
78
- <NuxtLink to="/contests" class="cpub-section-contests-all">View all</NuxtLink>
79
- </header>
80
-
81
- <div v-if="pending" class="cpub-section-contests-loading">
82
- <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
83
- </div>
84
-
85
- <ul v-else-if="!isEmpty" class="cpub-section-contests-list">
86
- <li v-for="c in items" :key="c.id" class="cpub-section-contests-item">
87
- <NuxtLink :to="`/contests/${c.slug}`" class="cpub-section-contests-name">
88
- {{ c.title }}
89
- </NuxtLink>
90
- <div class="cpub-section-contests-meta">
91
- <span class="cpub-section-contests-entries">
92
- {{ c.entryCount ?? 0 }} entries
93
- </span>
94
- <span v-if="daysLeft(c.endDate) !== null" class="cpub-section-contests-deadline">
95
- <i class="fa-regular fa-clock" aria-hidden="true" />
96
- {{ daysLeft(c.endDate) }}d left
97
- </span>
98
- </div>
99
- <NuxtLink :to="`/contests/${c.slug}`" class="cpub-section-contests-cta">
100
- Enter Contest
101
- </NuxtLink>
102
- </li>
103
- </ul>
104
-
105
- <p v-else class="cpub-section-contests-empty">No active contests.</p>
106
- </section>
107
- </template>
108
-
109
- <style scoped>
110
- .cpub-section-contests {
111
- background: var(--surface);
112
- border: var(--border-width-default) solid var(--border);
113
- padding: var(--space-4);
114
- }
115
- .cpub-section-contests-header {
116
- display: flex;
117
- align-items: center;
118
- justify-content: space-between;
119
- padding-bottom: var(--space-2);
120
- border-bottom: var(--border-width-default) solid var(--border-soft);
121
- margin-bottom: var(--space-3);
122
- }
123
- .cpub-section-contests-heading {
124
- font-family: var(--font-mono);
125
- font-size: var(--text-xxs);
126
- font-weight: 700;
127
- text-transform: uppercase;
128
- letter-spacing: 0.08em;
129
- color: var(--text-faint);
130
- margin: 0;
131
- }
132
- .cpub-section-contests-all {
133
- font-family: var(--font-mono);
134
- font-size: var(--text-xxs);
135
- color: var(--accent);
136
- text-decoration: none;
137
- }
138
- .cpub-section-contests-list {
139
- list-style: none;
140
- margin: 0;
141
- padding: 0;
142
- }
143
- .cpub-section-contests-item {
144
- padding: var(--space-2) 0;
145
- border-bottom: var(--border-width-default) solid var(--border-soft);
146
- }
147
- .cpub-section-contests-item:last-child { border-bottom: none; }
148
- .cpub-section-contests-name {
149
- font-size: var(--text-sm);
150
- font-weight: 600;
151
- color: var(--text);
152
- text-decoration: none;
153
- display: block;
154
- margin-bottom: var(--space-1);
155
- }
156
- .cpub-section-contests-name:hover { color: var(--accent); }
157
- .cpub-section-contests-meta {
158
- display: flex;
159
- align-items: center;
160
- gap: var(--space-3);
161
- margin-bottom: var(--space-2);
162
- }
163
- .cpub-section-contests-entries,
164
- .cpub-section-contests-deadline {
165
- font-family: var(--font-mono);
166
- font-size: var(--text-xxs);
167
- color: var(--text-faint);
168
- display: inline-flex;
169
- align-items: center;
170
- gap: var(--space-1);
171
- }
172
- .cpub-section-contests-cta {
173
- font-family: var(--font-mono);
174
- font-size: var(--text-xxs);
175
- text-transform: uppercase;
176
- letter-spacing: 0.06em;
177
- padding: var(--space-1) var(--space-2);
178
- border: var(--border-width-default) solid var(--accent);
179
- color: var(--accent);
180
- text-decoration: none;
181
- display: inline-block;
182
- }
183
- .cpub-section-contests-cta:hover { background: var(--accent-bg); }
184
- .cpub-section-contests-loading,
185
- .cpub-section-contests-empty {
186
- display: flex;
187
- align-items: center;
188
- justify-content: center;
189
- padding: var(--space-4);
190
- color: var(--text-faint);
191
- font-size: var(--text-sm);
192
- }
193
- </style>
@@ -1,70 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: custom-html — admin-only raw HTML escape hatch.
4
- *
5
- * **SECURITY**: renders `config.html` via `v-html` with no runtime
6
- * sanitization. Intentional Phase-1c posture — matches the legacy
7
- * `CustomHtmlSection.vue` security baseline that already ships in
8
- * production. Threat-model + Phase 6b sanitization plan live in the
9
- * section definition file (`builtin/custom-html.ts`) and
10
- * `docs/plans/layout-and-pages.md §6.5`.
11
- *
12
- * Only trusted admin users can write to this section via
13
- * `/api/admin/layouts/*` (gated on `requireAdmin(event)`). A compromised
14
- * admin account → stored XSS — that's the gap we're documenting +
15
- * tracking, not the gap we're closing this session.
16
- *
17
- * `var(--*)` only.
18
- */
19
- import type { SectionRenderProps } from '@commonpub/ui';
20
-
21
- interface CustomHtmlConfig extends Record<string, unknown> {
22
- heading: string;
23
- html: string;
24
- }
25
-
26
- const props = defineProps<SectionRenderProps<CustomHtmlConfig>>();
27
- void props; // template uses config + meta directly via `<script setup>`
28
- </script>
29
-
30
- <template>
31
- <section
32
- v-if="config.html"
33
- class="cpub-section-custom-html"
34
- :aria-labelledby="config.heading ? `section-custom-${meta.sectionId}` : undefined"
35
- >
36
- <h2
37
- v-if="config.heading"
38
- :id="`section-custom-${meta.sectionId}`"
39
- class="cpub-section-custom-html-heading"
40
- >
41
- {{ config.heading }}
42
- </h2>
43
- <!-- v-html: see security note in source. Trusted admin input only. -->
44
- <div class="cpub-section-custom-html-body" v-html="config.html" />
45
- </section>
46
- </template>
47
-
48
- <style scoped>
49
- .cpub-section-custom-html {
50
- display: flex;
51
- flex-direction: column;
52
- gap: var(--space-3);
53
- }
54
- .cpub-section-custom-html-heading {
55
- font-family: var(--font-mono);
56
- font-size: var(--text-xs);
57
- font-weight: 700;
58
- text-transform: uppercase;
59
- letter-spacing: 0.08em;
60
- color: var(--text-faint);
61
- margin: 0;
62
- padding-bottom: var(--space-2);
63
- border-bottom: var(--border-width-default) solid var(--border);
64
- }
65
- .cpub-section-custom-html-body {
66
- font-size: var(--text-base);
67
- line-height: 1.7;
68
- color: var(--text);
69
- }
70
- </style>
@@ -1,55 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: divider — a horizontal rule.
4
- *
5
- * Phase 1 proof-of-life — the simplest possible section, validating that:
6
- * - `SectionRegistry.register()` accepts a Vue component
7
- * - `<LayoutSlot>` resolves the section type slug to a renderer
8
- * - The renderer receives `config` + `meta` and produces DOM
9
- *
10
- * Phase 1c adds the real catalogue (hero / heading / paragraph / image /
11
- * content-feed). Until then, dropping a divider into a layout is the
12
- * end-to-end smoke test of the layout engine.
13
- *
14
- * Uses `var(--*)` only (rule #3); inherits all colors / spacing from
15
- * the active theme.
16
- */
17
- import type { SectionRenderProps } from '@commonpub/ui';
18
-
19
- interface DividerConfig extends Record<string, unknown> {
20
- variant: 'solid' | 'dashed' | 'dotted' | 'accent';
21
- spacingY: 'sm' | 'md' | 'lg' | 'xl';
22
- }
23
-
24
- defineProps<SectionRenderProps<DividerConfig>>();
25
- </script>
26
-
27
- <template>
28
- <hr
29
- class="cpub-section-divider"
30
- :data-variant="config.variant"
31
- :data-spacing-y="config.spacingY"
32
- :aria-label="`section ${meta.sectionId}`"
33
- />
34
- </template>
35
-
36
- <style scoped>
37
- .cpub-section-divider {
38
- margin-block: var(--space-4);
39
- border: 0;
40
- border-top: var(--border-width-default) solid var(--border2);
41
- width: 100%;
42
- }
43
-
44
- .cpub-section-divider[data-variant='dashed'] { border-top-style: dashed; }
45
- .cpub-section-divider[data-variant='dotted'] { border-top-style: dotted; }
46
- .cpub-section-divider[data-variant='accent'] {
47
- border-top-color: var(--accent);
48
- border-top-width: var(--border-width-thick);
49
- }
50
-
51
- .cpub-section-divider[data-spacing-y='sm'] { margin-block: var(--space-2); }
52
- .cpub-section-divider[data-spacing-y='md'] { margin-block: var(--space-4); }
53
- .cpub-section-divider[data-spacing-y='lg'] { margin-block: var(--space-6); }
54
- .cpub-section-divider[data-spacing-y='xl'] { margin-block: var(--space-8); }
55
- </style>