@commonpub/layer 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/package.json +11 -8
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- package/components/sections/SectionStats.vue +0 -151
|
@@ -123,7 +123,29 @@ const columns = computed(() => props.config.columns ?? 2);
|
|
|
123
123
|
.cpub-tab:hover { color: var(--text); }
|
|
124
124
|
.cpub-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
/*
|
|
127
|
+
* Session 164 polish: cap the grid's max width so cards don't stretch
|
|
128
|
+
* into a "giant cards in mobile layout" pattern at desktop widths.
|
|
129
|
+
*
|
|
130
|
+
* Was: `grid-template-columns: repeat(2, 1fr)` with NO width cap → at
|
|
131
|
+
* 1440px window, 2 columns of ~700px each. Cards stretched far past
|
|
132
|
+
* their natural design width and read as "single column of bigs".
|
|
133
|
+
*
|
|
134
|
+
* Now: same column behavior but bounded by --content-max-width (default
|
|
135
|
+
* 1280px) — matches the tabs-inner cap above (same theme token). At
|
|
136
|
+
* 1280px and below, behaves identically to before. Above, the grid
|
|
137
|
+
* stays at 1280 centered and cards stay at their design proportions.
|
|
138
|
+
*
|
|
139
|
+
* `var(--grid-cols)` config still drives column count. Mobile drops to
|
|
140
|
+
* 1-column at <=768px (unchanged).
|
|
141
|
+
*/
|
|
142
|
+
.cpub-content-grid {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: repeat(var(--grid-cols, 2), 1fr);
|
|
145
|
+
gap: 16px;
|
|
146
|
+
max-width: var(--content-max-width, 1280px);
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
}
|
|
127
149
|
|
|
128
150
|
.cpub-load-more-row { text-align: center; padding: 24px 0; }
|
|
129
151
|
.cpub-btn-load-more { font-family: var(--font-mono); font-size: 11px; padding: 8px 20px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text-dim); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import type { HomepageSectionConfig } from '@commonpub/server';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
interface HeroCta {
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
variant?: 'primary' | 'secondary';
|
|
9
|
+
icon?: string; // Font Awesome class (e.g. 'fa-plus'); optional
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ config: HomepageSectionConfig }>();
|
|
5
13
|
|
|
6
14
|
const { contests: contestsEnabled } = useFeatures();
|
|
7
15
|
const { data: contests } = await useFetch('/api/contests', { query: { limit: 3 }, lazy: true });
|
|
@@ -11,6 +19,49 @@ const activeContest = computed(() => {
|
|
|
11
19
|
return items?.find((c) => c.status === 'active') ?? null;
|
|
12
20
|
});
|
|
13
21
|
|
|
22
|
+
// Config-driven overrides (Stage E session 159 — finally wired). The
|
|
23
|
+
// legacy admin form (/admin/homepage) has accepted `customTitle` +
|
|
24
|
+
// `customSubtitle` for a long time, but the renderer ignored them —
|
|
25
|
+
// admin would type a custom title and see no change. Now respected.
|
|
26
|
+
//
|
|
27
|
+
// Hardcoded fallbacks preserve the existing "Build. Document. Share."
|
|
28
|
+
// + Open Source eyebrow + Start Building/Explore CTAs when admin
|
|
29
|
+
// hasn't customised anything.
|
|
30
|
+
//
|
|
31
|
+
// Contest-aware swap still wins when there's an active contest +
|
|
32
|
+
// `features.contests` is on — the contest hero is intentionally not
|
|
33
|
+
// overridable because it pulls live data; admins who want fully-static
|
|
34
|
+
// copy can either disable the contests flag or set customTitle which
|
|
35
|
+
// applies in the non-contest branch.
|
|
36
|
+
const heroTitle = computed(() => {
|
|
37
|
+
const cfg = props.config as { customTitle?: string };
|
|
38
|
+
return cfg.customTitle?.trim() || 'Build. Document. Share.';
|
|
39
|
+
});
|
|
40
|
+
const heroTitleIsCustom = computed(() => {
|
|
41
|
+
const cfg = props.config as { customTitle?: string };
|
|
42
|
+
return !!cfg.customTitle?.trim();
|
|
43
|
+
});
|
|
44
|
+
const heroSubtitle = computed(() => {
|
|
45
|
+
const cfg = props.config as { customSubtitle?: string };
|
|
46
|
+
return cfg.customSubtitle?.trim() ||
|
|
47
|
+
'CommonPub is an open platform for maker communities. Document your builds with rich editors, join hubs, learn with structured paths, and share with the world.';
|
|
48
|
+
});
|
|
49
|
+
const heroEyebrow = computed(() => {
|
|
50
|
+
const cfg = props.config as { eyebrow?: string };
|
|
51
|
+
return cfg.eyebrow?.trim() || 'Open Source';
|
|
52
|
+
});
|
|
53
|
+
const heroCtas = computed<HeroCta[]>(() => {
|
|
54
|
+
const cfg = props.config as { ctas?: HeroCta[] };
|
|
55
|
+
if (Array.isArray(cfg.ctas) && cfg.ctas.length > 0) return cfg.ctas;
|
|
56
|
+
// Hardcoded fallbacks keep the legacy hero's exact look: icons +
|
|
57
|
+
// canonical labels. Admin-set ctas (no icon field in the legacy
|
|
58
|
+
// admin form) render without icons by design.
|
|
59
|
+
return [
|
|
60
|
+
{ label: 'Start Building', href: '/create', variant: 'primary', icon: 'fa-plus' },
|
|
61
|
+
{ label: 'Explore', href: '/explore', variant: 'secondary', icon: 'fa-compass' },
|
|
62
|
+
];
|
|
63
|
+
});
|
|
64
|
+
|
|
14
65
|
// Shared via useState so the dismiss sticks across component remounts.
|
|
15
66
|
// HomepageSectionRenderer's v-if wrappers can remount HeroSection when the
|
|
16
67
|
// `sections` useFetch revalidates on hydration or when feature flags flip
|
|
@@ -51,15 +102,25 @@ function dismissHero(): void {
|
|
|
51
102
|
</template>
|
|
52
103
|
<template v-else>
|
|
53
104
|
<div class="cpub-hero-eyebrow">
|
|
54
|
-
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" />
|
|
105
|
+
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> {{ heroEyebrow }}</span>
|
|
55
106
|
</div>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</
|
|
107
|
+
<!-- When admin supplied a customTitle, show as plain text. The
|
|
108
|
+
hardcoded fallback uses inline <br> + accent span for the
|
|
109
|
+
canonical "Build. Document. Share." typography. -->
|
|
110
|
+
<h1 v-if="heroTitleIsCustom" class="cpub-hero-title">{{ heroTitle }}</h1>
|
|
111
|
+
<h1 v-else class="cpub-hero-title">Build. Document.<br><span>Share.</span></h1>
|
|
112
|
+
<p class="cpub-hero-excerpt">{{ heroSubtitle }}</p>
|
|
60
113
|
<div class="cpub-hero-actions">
|
|
61
|
-
<NuxtLink
|
|
62
|
-
|
|
114
|
+
<NuxtLink
|
|
115
|
+
v-for="(cta, i) in heroCtas"
|
|
116
|
+
:key="i"
|
|
117
|
+
:to="cta.href"
|
|
118
|
+
class="cpub-btn"
|
|
119
|
+
:class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
|
|
120
|
+
>
|
|
121
|
+
<i v-if="cta.icon" :class="['fa-solid', cta.icon]" aria-hidden="true" />
|
|
122
|
+
{{ cta.label }}
|
|
123
|
+
</NuxtLink>
|
|
63
124
|
</div>
|
|
64
125
|
</template>
|
|
65
126
|
</div>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: cta — call-to-action panel.
|
|
4
|
+
*
|
|
5
|
+
* Heading + body + up to 3 buttons. Three variants:
|
|
6
|
+
* default → boxed panel with subtle border
|
|
7
|
+
* contrast → accent-background inverse
|
|
8
|
+
* minimal → no panel, just text + buttons
|
|
9
|
+
*
|
|
10
|
+
* URL safety: href values are validated at WRITE time by the section's
|
|
11
|
+
* Zod schema (SAFE_LINK_URL regex). Renderer doesn't re-validate
|
|
12
|
+
* because Vue's :href binding doesn't sanitise — the regex IS the guard.
|
|
13
|
+
*
|
|
14
|
+
* `var(--*)` only.
|
|
15
|
+
*/
|
|
16
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
17
|
+
|
|
18
|
+
interface CtaButton {
|
|
19
|
+
label: string;
|
|
20
|
+
href: string;
|
|
21
|
+
variant: 'primary' | 'secondary' | 'ghost';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CtaConfig extends Record<string, unknown> {
|
|
25
|
+
variant: 'default' | 'contrast' | 'minimal';
|
|
26
|
+
heading: string;
|
|
27
|
+
body: string;
|
|
28
|
+
buttons: CtaButton[];
|
|
29
|
+
align: 'left' | 'center';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const props = defineProps<SectionRenderProps<CtaConfig>>();
|
|
33
|
+
void props;
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<section
|
|
38
|
+
class="cpub-section-cta"
|
|
39
|
+
:data-variant="config.variant"
|
|
40
|
+
:data-align="config.align"
|
|
41
|
+
:aria-labelledby="`section-cta-${meta.sectionId}`"
|
|
42
|
+
>
|
|
43
|
+
<h2
|
|
44
|
+
:id="`section-cta-${meta.sectionId}`"
|
|
45
|
+
class="cpub-section-cta-heading"
|
|
46
|
+
>
|
|
47
|
+
{{ config.heading }}
|
|
48
|
+
</h2>
|
|
49
|
+
|
|
50
|
+
<p v-if="config.body" class="cpub-section-cta-body">
|
|
51
|
+
{{ config.body }}
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<div v-if="config.buttons.length > 0" class="cpub-section-cta-actions">
|
|
55
|
+
<a
|
|
56
|
+
v-for="(btn, idx) in config.buttons"
|
|
57
|
+
:key="idx"
|
|
58
|
+
:href="btn.href"
|
|
59
|
+
:class="`cpub-section-cta-btn cpub-section-cta-btn-${btn.variant}`"
|
|
60
|
+
>
|
|
61
|
+
{{ btn.label }}
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.cpub-section-cta {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
gap: var(--space-3);
|
|
72
|
+
padding: var(--space-5);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* default — boxed */
|
|
76
|
+
.cpub-section-cta[data-variant='default'] {
|
|
77
|
+
background: var(--surface);
|
|
78
|
+
border: var(--border-width-default) solid var(--border);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* contrast — accent inverse */
|
|
82
|
+
.cpub-section-cta[data-variant='contrast'] {
|
|
83
|
+
background: var(--accent);
|
|
84
|
+
color: var(--surface);
|
|
85
|
+
border: var(--border-width-default) solid var(--accent);
|
|
86
|
+
}
|
|
87
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-heading,
|
|
88
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-body {
|
|
89
|
+
color: inherit;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* minimal — no panel */
|
|
93
|
+
.cpub-section-cta[data-variant='minimal'] {
|
|
94
|
+
background: transparent;
|
|
95
|
+
border: none;
|
|
96
|
+
padding: var(--space-3) 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* align */
|
|
100
|
+
.cpub-section-cta[data-align='center'] {
|
|
101
|
+
text-align: center;
|
|
102
|
+
align-items: center;
|
|
103
|
+
}
|
|
104
|
+
.cpub-section-cta[data-align='center'] .cpub-section-cta-actions {
|
|
105
|
+
justify-content: center;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cpub-section-cta-heading {
|
|
109
|
+
font-size: var(--text-xl);
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
color: var(--text);
|
|
112
|
+
margin: 0;
|
|
113
|
+
}
|
|
114
|
+
.cpub-section-cta-body {
|
|
115
|
+
font-size: var(--text-base);
|
|
116
|
+
line-height: 1.7;
|
|
117
|
+
color: var(--text-soft);
|
|
118
|
+
margin: 0;
|
|
119
|
+
max-width: 60ch;
|
|
120
|
+
}
|
|
121
|
+
.cpub-section-cta-actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
gap: var(--space-2);
|
|
125
|
+
margin-top: var(--space-2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* buttons — three variants */
|
|
129
|
+
.cpub-section-cta-btn {
|
|
130
|
+
font-family: var(--font-mono);
|
|
131
|
+
font-size: var(--text-xs);
|
|
132
|
+
font-weight: 700;
|
|
133
|
+
text-transform: uppercase;
|
|
134
|
+
letter-spacing: 0.06em;
|
|
135
|
+
padding: var(--space-2) var(--space-4);
|
|
136
|
+
border: var(--border-width-default) solid var(--accent);
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
display: inline-block;
|
|
139
|
+
}
|
|
140
|
+
.cpub-section-cta-btn-primary {
|
|
141
|
+
background: var(--accent);
|
|
142
|
+
color: var(--surface);
|
|
143
|
+
}
|
|
144
|
+
.cpub-section-cta-btn-primary:hover {
|
|
145
|
+
background: var(--accent-strong, var(--accent));
|
|
146
|
+
}
|
|
147
|
+
.cpub-section-cta-btn-secondary {
|
|
148
|
+
background: transparent;
|
|
149
|
+
color: var(--accent);
|
|
150
|
+
}
|
|
151
|
+
.cpub-section-cta-btn-secondary:hover {
|
|
152
|
+
background: var(--accent-bg);
|
|
153
|
+
}
|
|
154
|
+
.cpub-section-cta-btn-ghost {
|
|
155
|
+
border-color: transparent;
|
|
156
|
+
background: transparent;
|
|
157
|
+
color: var(--text);
|
|
158
|
+
}
|
|
159
|
+
.cpub-section-cta-btn-ghost:hover {
|
|
160
|
+
color: var(--accent);
|
|
161
|
+
}
|
|
162
|
+
/* Contrast variant — invert button colours so they read on accent bg */
|
|
163
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-primary {
|
|
164
|
+
background: var(--surface);
|
|
165
|
+
color: var(--accent);
|
|
166
|
+
border-color: var(--surface);
|
|
167
|
+
}
|
|
168
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-secondary {
|
|
169
|
+
border-color: var(--surface);
|
|
170
|
+
color: var(--surface);
|
|
171
|
+
}
|
|
172
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-ghost {
|
|
173
|
+
color: var(--surface);
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autoFormSchema — pure Zod → form-field descriptor engine (Phase 3e).
|
|
3
|
+
*
|
|
4
|
+
* Converts a section's `configSchema` (a `z.ZodType`) into a normalized
|
|
5
|
+
* `AutoFormField[]` that `<AdminLayoutsInspectorSection>` /
|
|
6
|
+
* `<AdminLayoutsInspectorRow>` render as native inputs reusing the
|
|
7
|
+
* `cpub-inspector-page-*` design language.
|
|
8
|
+
*
|
|
9
|
+
* ## Why hand-rolled, not FormKit (session 167 decision)
|
|
10
|
+
*
|
|
11
|
+
* The Phase 3 plan + `feedback-phase-3-hybrid-libraries` prescribed
|
|
12
|
+
* `@formkit/zod` for this. Verified against source at session 167:
|
|
13
|
+
* 1. `@formkit/zod`'s `createZodPlugin` adds VALIDATION ONLY to a form
|
|
14
|
+
* whose inputs you hand-author — it does NOT generate fields from a
|
|
15
|
+
* schema (formkit.com/plugins/zod). It delivers none of Phase 3e's
|
|
16
|
+
* auto-gen goal.
|
|
17
|
+
* 2. `@formkit/zod@2.0.0` peer-deps `zod@^3`; the monorepo is on
|
|
18
|
+
* `zod@4.3.6` everywhere. FormKit has not shipped Zod 4 support.
|
|
19
|
+
* The plan's risk register pre-authorized this exact fallback ("fall back
|
|
20
|
+
* to a hand-rolled <AutoForm> — Phase 3e decision"). See
|
|
21
|
+
* `project-session-167-formkit-pivot` memory.
|
|
22
|
+
*
|
|
23
|
+
* ## How it works
|
|
24
|
+
*
|
|
25
|
+
* Zod 4 ships a native `z.toJSONSchema()` that emits a complete, stable
|
|
26
|
+
* JSON Schema (type/enum/minLength/maxLength/pattern/minimum/maximum/
|
|
27
|
+
* minItems/maxItems/default/nested items+properties/required). We walk
|
|
28
|
+
* THAT (a well-specified format) rather than poking Zod internals, so the
|
|
29
|
+
* engine is resilient to Zod's internal churn. The §7.10 input-mapping
|
|
30
|
+
* table maps 1:1 onto JSON Schema node kinds.
|
|
31
|
+
*
|
|
32
|
+
* The engine is intentionally framework-free (no Vue imports) so it is
|
|
33
|
+
* unit-testable in isolation — per `feedback-css-cascade-unit-test-blind-spot`,
|
|
34
|
+
* keeping the LOGIC pure means the only place a CSS-cascade bug can hide
|
|
35
|
+
* is the `.vue` view, which gets the fresh-eyes pass.
|
|
36
|
+
*/
|
|
37
|
+
import { z, type ZodType } from 'zod';
|
|
38
|
+
|
|
39
|
+
/** The native input control a field maps to. */
|
|
40
|
+
export type AutoFormControl =
|
|
41
|
+
| 'text'
|
|
42
|
+
| 'textarea'
|
|
43
|
+
| 'number'
|
|
44
|
+
| 'select'
|
|
45
|
+
| 'toggle'
|
|
46
|
+
| 'array'
|
|
47
|
+
| 'group'
|
|
48
|
+
| 'unsupported';
|
|
49
|
+
|
|
50
|
+
export interface AutoFormOption {
|
|
51
|
+
value: string | number;
|
|
52
|
+
label: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AutoFormField {
|
|
56
|
+
/** Object key in the config blob (also the `name` for refs / labels). */
|
|
57
|
+
key: string;
|
|
58
|
+
/** Humanized label derived from `key` (e.g. `customTitle` → "Custom title"). */
|
|
59
|
+
label: string;
|
|
60
|
+
/** The control to render. */
|
|
61
|
+
control: AutoFormControl;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the field is required IN FORM TERMS — i.e. present in the
|
|
64
|
+
* schema's `required` array AND has no `default`. A field with a default
|
|
65
|
+
* is pre-filled, so it never blocks the user; we don't asterisk it.
|
|
66
|
+
*/
|
|
67
|
+
required: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* True when the field can legitimately be ABSENT — not in `required` AND
|
|
70
|
+
* no `default` (e.g. the optional row-config enums). Selects use this to
|
|
71
|
+
* offer a leading "default/unset" option so a `undefined` value doesn't
|
|
72
|
+
* masquerade as the first real choice being selected.
|
|
73
|
+
*/
|
|
74
|
+
optional: boolean;
|
|
75
|
+
/** JSON Schema `description` — reserved for the §7.10 `keyword:arg`
|
|
76
|
+
* rich-field extension (rich/content-picker/image/color). Unused in v1. */
|
|
77
|
+
description?: string;
|
|
78
|
+
/** Schema default, if any. */
|
|
79
|
+
defaultValue?: unknown;
|
|
80
|
+
// --- string constraints ---
|
|
81
|
+
minLength?: number;
|
|
82
|
+
maxLength?: number;
|
|
83
|
+
/** Regex source string (e.g. URL guards). Carried for inline validation. */
|
|
84
|
+
pattern?: string;
|
|
85
|
+
// --- number constraints ---
|
|
86
|
+
min?: number;
|
|
87
|
+
max?: number;
|
|
88
|
+
/** Step granularity — 1 for integers, undefined (any) for floats. */
|
|
89
|
+
step?: number;
|
|
90
|
+
// --- select (enum + union-of-const) ---
|
|
91
|
+
options?: AutoFormOption[];
|
|
92
|
+
// --- array (repeater) ---
|
|
93
|
+
/** For `array<object>`: the per-item sub-fields. */
|
|
94
|
+
itemFields?: AutoFormField[];
|
|
95
|
+
/** A blank item to append when the user clicks "+ Add". */
|
|
96
|
+
itemDefault?: unknown;
|
|
97
|
+
minItems?: number;
|
|
98
|
+
maxItems?: number;
|
|
99
|
+
// --- group (nested object) ---
|
|
100
|
+
fields?: AutoFormField[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AutoFormModel {
|
|
104
|
+
fields: AutoFormField[];
|
|
105
|
+
/** True when the schema exposes no editable properties (e.g. `stats`). */
|
|
106
|
+
isEmpty: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* maxLength at/above which a string renders as a multi-line textarea
|
|
111
|
+
* instead of a single-line input. Tuned against the real section schemas:
|
|
112
|
+
* caption(480)/subtitle(500)/body(800)/markdown(100k)/html(8k,50k) become
|
|
113
|
+
* textareas; title(240)/heading(240)/name(255)/alt(240) stay single-line.
|
|
114
|
+
* URL-pattern strings bypass this (see the `string` branch) — they're
|
|
115
|
+
* single-line regardless of their 2048 cap.
|
|
116
|
+
*/
|
|
117
|
+
const LONG_TEXT_THRESHOLD = 480;
|
|
118
|
+
|
|
119
|
+
type JsonNode = Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
/** `#/$defs/Foo` → root.$defs.Foo. Zod inlines single-use subschemas but
|
|
122
|
+
* emits $ref/$defs when one is reused; resolve defensively either way. */
|
|
123
|
+
function resolveRef(node: JsonNode | undefined, root: JsonNode): JsonNode {
|
|
124
|
+
if (!node) return {};
|
|
125
|
+
const ref = node['$ref'];
|
|
126
|
+
if (typeof ref !== 'string') return node;
|
|
127
|
+
// Only the local "#/$defs/Name" form is produced by z.toJSONSchema.
|
|
128
|
+
const m = /^#\/\$defs\/(.+)$/.exec(ref);
|
|
129
|
+
if (!m) return node;
|
|
130
|
+
const defs = root['$defs'] as Record<string, JsonNode> | undefined;
|
|
131
|
+
const target = defs?.[m[1]!];
|
|
132
|
+
// Merge so sibling keywords on the $ref node (rare) aren't lost.
|
|
133
|
+
return target ? { ...target, ...stripRef(node) } : node;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stripRef(node: JsonNode): JsonNode {
|
|
137
|
+
const { ['$ref']: _ref, ...rest } = node;
|
|
138
|
+
return rest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `customTitle` → "Custom title"; `paddingY` → "Padding y"; `categorySlug` → "Category slug". */
|
|
142
|
+
export function humanizeKey(key: string): string {
|
|
143
|
+
const spaced = key
|
|
144
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
145
|
+
.replace(/[_-]+/g, ' ')
|
|
146
|
+
.trim()
|
|
147
|
+
.toLowerCase();
|
|
148
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Enum value → display label. Keeps short slugs as-is (they're already
|
|
152
|
+
* terse design tokens like "sm"/"primary"); just trims. Numbers stringify. */
|
|
153
|
+
function optionLabel(value: string | number): string {
|
|
154
|
+
return String(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isConstUnion(node: JsonNode): node is JsonNode & { anyOf: JsonNode[] } {
|
|
158
|
+
const anyOf = node['anyOf'];
|
|
159
|
+
return Array.isArray(anyOf) && anyOf.length > 0 && anyOf.every((o) => o && typeof o === 'object' && 'const' in o);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Walk one object node's `properties` into fields. */
|
|
163
|
+
function objectToFields(node: JsonNode, root: JsonNode): AutoFormField[] {
|
|
164
|
+
const properties = (node['properties'] as Record<string, JsonNode> | undefined) ?? {};
|
|
165
|
+
const required = Array.isArray(node['required']) ? (node['required'] as string[]) : [];
|
|
166
|
+
return Object.entries(properties).map(([key, raw]) => nodeToField(key, raw, root, required.includes(key)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nodeToField(
|
|
170
|
+
key: string,
|
|
171
|
+
rawNode: JsonNode,
|
|
172
|
+
root: JsonNode,
|
|
173
|
+
inRequiredArray: boolean,
|
|
174
|
+
): AutoFormField {
|
|
175
|
+
const node = resolveRef(rawNode, root);
|
|
176
|
+
const defaultValue = node['default'];
|
|
177
|
+
// Form-required: in the required list AND no default to pre-fill it.
|
|
178
|
+
const required = inRequiredArray && defaultValue === undefined;
|
|
179
|
+
// Optional: absent-allowed (not required) AND no default to fall back to.
|
|
180
|
+
const optional = !inRequiredArray && defaultValue === undefined;
|
|
181
|
+
const base: AutoFormField = {
|
|
182
|
+
key,
|
|
183
|
+
label: humanizeKey(key),
|
|
184
|
+
control: 'unsupported',
|
|
185
|
+
required,
|
|
186
|
+
optional,
|
|
187
|
+
defaultValue,
|
|
188
|
+
};
|
|
189
|
+
const description = node['description'];
|
|
190
|
+
if (typeof description === 'string') base.description = description;
|
|
191
|
+
|
|
192
|
+
// 1. Union of literal consts (heading.level, content-feed.columns,
|
|
193
|
+
// learning.columns) — z.union([z.literal(1), …]) → anyOf:[{const}].
|
|
194
|
+
if (isConstUnion(node)) {
|
|
195
|
+
base.control = 'select';
|
|
196
|
+
base.options = node.anyOf.map((o) => {
|
|
197
|
+
const v = o['const'] as string | number;
|
|
198
|
+
return { value: v, label: optionLabel(v) };
|
|
199
|
+
});
|
|
200
|
+
return base;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. String enum → select.
|
|
204
|
+
if (Array.isArray(node['enum'])) {
|
|
205
|
+
base.control = 'select';
|
|
206
|
+
base.options = (node['enum'] as Array<string | number>).map((v) => ({ value: v, label: optionLabel(v) }));
|
|
207
|
+
return base;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const type = node['type'];
|
|
211
|
+
switch (type) {
|
|
212
|
+
case 'boolean':
|
|
213
|
+
base.control = 'toggle';
|
|
214
|
+
return base;
|
|
215
|
+
|
|
216
|
+
case 'integer':
|
|
217
|
+
case 'number':
|
|
218
|
+
base.control = 'number';
|
|
219
|
+
if (typeof node['minimum'] === 'number') base.min = node['minimum'] as number;
|
|
220
|
+
if (typeof node['maximum'] === 'number') base.max = node['maximum'] as number;
|
|
221
|
+
base.step = type === 'integer' ? 1 : undefined;
|
|
222
|
+
return base;
|
|
223
|
+
|
|
224
|
+
case 'string': {
|
|
225
|
+
if (typeof node['maxLength'] === 'number') base.maxLength = node['maxLength'] as number;
|
|
226
|
+
if (typeof node['minLength'] === 'number') base.minLength = node['minLength'] as number;
|
|
227
|
+
// Pattern strings are URLs/paths in our schemas — single-line text,
|
|
228
|
+
// NOT <input type=url> (which would reject valid root-relative paths
|
|
229
|
+
// like `/about`). Carry the pattern for our own inline validation.
|
|
230
|
+
if (typeof node['pattern'] === 'string') {
|
|
231
|
+
base.control = 'text';
|
|
232
|
+
base.pattern = node['pattern'] as string;
|
|
233
|
+
return base;
|
|
234
|
+
}
|
|
235
|
+
base.control = base.maxLength !== undefined && base.maxLength >= LONG_TEXT_THRESHOLD ? 'textarea' : 'text';
|
|
236
|
+
return base;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'array': {
|
|
240
|
+
base.control = 'array';
|
|
241
|
+
if (typeof node['minItems'] === 'number') base.minItems = node['minItems'] as number;
|
|
242
|
+
if (typeof node['maxItems'] === 'number') base.maxItems = node['maxItems'] as number;
|
|
243
|
+
const items = resolveRef(node['items'] as JsonNode | undefined, root);
|
|
244
|
+
if (items['type'] === 'object' || items['properties']) {
|
|
245
|
+
base.itemFields = objectToFields(items, root);
|
|
246
|
+
base.itemDefault = buildDefaults(base.itemFields);
|
|
247
|
+
} else {
|
|
248
|
+
// Scalar array (none in v1 builtins, but handle generically).
|
|
249
|
+
const itemField = nodeToField('item', items, root, false);
|
|
250
|
+
base.itemFields = [itemField];
|
|
251
|
+
base.itemDefault = blankFor(itemField);
|
|
252
|
+
}
|
|
253
|
+
return base;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'object': {
|
|
257
|
+
base.control = 'group';
|
|
258
|
+
base.fields = objectToFields(node, root);
|
|
259
|
+
return base;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
default:
|
|
263
|
+
// Unknown / unrepresentable — render read-only so the admin sees the
|
|
264
|
+
// field exists but can't corrupt it. R3 ops: forward-compat for new
|
|
265
|
+
// Zod kinds a future schema introduces.
|
|
266
|
+
base.control = 'unsupported';
|
|
267
|
+
return base;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A sensible blank value for a freshly-added array item / group. */
|
|
272
|
+
function blankFor(field: AutoFormField): unknown {
|
|
273
|
+
if (field.defaultValue !== undefined) return field.defaultValue;
|
|
274
|
+
switch (field.control) {
|
|
275
|
+
case 'text':
|
|
276
|
+
case 'textarea':
|
|
277
|
+
return '';
|
|
278
|
+
case 'number':
|
|
279
|
+
return field.min ?? 0;
|
|
280
|
+
case 'toggle':
|
|
281
|
+
return false;
|
|
282
|
+
case 'select':
|
|
283
|
+
return field.options?.[0]?.value ?? '';
|
|
284
|
+
case 'array':
|
|
285
|
+
return [];
|
|
286
|
+
case 'group':
|
|
287
|
+
return field.fields ? buildDefaults(field.fields) : {};
|
|
288
|
+
default:
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Build a default object from a field list (for new array items / groups). */
|
|
294
|
+
export function buildDefaults(fields: AutoFormField[]): Record<string, unknown> {
|
|
295
|
+
const out: Record<string, unknown> = {};
|
|
296
|
+
for (const f of fields) out[f.key] = blankFor(f);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convert a Zod schema to a normalized form model. Returns `{ fields: [],
|
|
302
|
+
* isEmpty: true }` if the schema is not an object or can't be represented
|
|
303
|
+
* (degrades gracefully — never throws into the render path).
|
|
304
|
+
*/
|
|
305
|
+
export function buildAutoForm(schema: ZodType): AutoFormModel {
|
|
306
|
+
let json: JsonNode;
|
|
307
|
+
try {
|
|
308
|
+
json = z.toJSONSchema(schema) as JsonNode;
|
|
309
|
+
} catch {
|
|
310
|
+
// Unrepresentable schema (z.toJSONSchema throws by default). Degrade to
|
|
311
|
+
// an empty model; the view shows the "no options" state.
|
|
312
|
+
return { fields: [], isEmpty: true };
|
|
313
|
+
}
|
|
314
|
+
if (json['type'] !== 'object' && !json['properties']) {
|
|
315
|
+
return { fields: [], isEmpty: true };
|
|
316
|
+
}
|
|
317
|
+
const fields = objectToFields(json, json);
|
|
318
|
+
return { fields, isEmpty: fields.length === 0 };
|
|
319
|
+
}
|