@commonpub/layer 0.22.0 → 0.23.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/LayoutSlot.vue +266 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +32 -1
- package/components/sections/SectionContentFeed.vue +160 -0
- package/components/sections/SectionDivider.vue +55 -0
- package/components/sections/SectionHeading.vue +78 -0
- package/components/sections/SectionHero.vue +164 -0
- package/components/sections/SectionImage.vue +104 -0
- package/components/sections/SectionParagraph.vue +55 -0
- package/composables/useFeatures.ts +10 -0
- package/composables/useLayout.ts +132 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +68 -20
- package/pages/admin/theme/index.vue +48 -2
- package/pages/index.vue +23 -2
- package/server/api/admin/layouts/[id]/publish.post.ts +33 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +43 -0
- package/server/api/admin/layouts/[id]/versions/index.get.ts +29 -0
- package/server/api/admin/layouts/[id].delete.ts +34 -0
- package/server/api/admin/layouts/[id].get.ts +27 -0
- package/server/api/admin/layouts/[id].put.ts +64 -0
- package/server/api/admin/layouts/index.get.ts +25 -0
- package/server/api/admin/layouts/index.post.ts +48 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +35 -0
- package/server/api/admin/themes/discover.get.ts +14 -5
- package/server/api/layouts/by-route.get.ts +87 -0
- package/server/utils/layoutCache.ts +53 -0
|
@@ -244,6 +244,49 @@ function recheckDiscovery(): void {
|
|
|
244
244
|
discovery.value = detectAppliedOverrides();
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Only show the "your site has a custom theme" banner when the detected
|
|
249
|
+
* overrides are LIKELY from a CSS file shipped by the layer app — NOT from
|
|
250
|
+
* a custom theme the admin has already saved (which would also appear as
|
|
251
|
+
* :root token overrides because the SSR middleware injects them there).
|
|
252
|
+
*
|
|
253
|
+
* Gating rules:
|
|
254
|
+
* - hide when the active default is already a cpub-custom-* theme
|
|
255
|
+
* - hide when instance-wide token overrides are set (those tokens explain
|
|
256
|
+
* the diff; the banner would confuse the admin into re-capturing them)
|
|
257
|
+
* - hide when no overrides were detected
|
|
258
|
+
*
|
|
259
|
+
* If admins want to re-capture from a fresh :root state, they can revert
|
|
260
|
+
* to the base theme, clear overrides, then the banner will reappear.
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* Show the "your site has a custom theme" banner ONLY when the detected
|
|
264
|
+
* overrides on :root are likely from a CSS file the thin layer app
|
|
265
|
+
* loaded (the deveco.io case) — NOT from the built-in theme itself or
|
|
266
|
+
* from a custom theme the admin already saved.
|
|
267
|
+
*
|
|
268
|
+
* Refined gate (5 conditions, all must pass):
|
|
269
|
+
* - count > 0 (something to capture)
|
|
270
|
+
* - active theme is NOT a custom theme (already captured)
|
|
271
|
+
* - active theme IS 'base' (any non-base built-in's tokens would
|
|
272
|
+
* dominate the diff, making "capture" produce a clone of that
|
|
273
|
+
* theme — pointless. e.g. commonpub.io's active='agora' triggers
|
|
274
|
+
* count > 0 from agora.css; banner would confuse the admin)
|
|
275
|
+
* - no instance-wide token overrides set (those explain the diff)
|
|
276
|
+
*
|
|
277
|
+
* Caveat: a thin app that registers a `themes:` entry AND sets
|
|
278
|
+
* instanceDefault to the registered slug (e.g. 'deveco') would have
|
|
279
|
+
* the banner HIDDEN even though their CSS file overrides ARE the use
|
|
280
|
+
* case the banner targets. For that case the admin can use the Fork
|
|
281
|
+
* button on the registered theme's card instead. Document.
|
|
282
|
+
*/
|
|
283
|
+
const showDiscoveryBanner = computed<boolean>(() => {
|
|
284
|
+
if (discovery.value.count === 0) return false;
|
|
285
|
+
if (instanceDefault.value !== 'base') return false; // hides built-in non-base + registered + custom
|
|
286
|
+
if (Object.keys(initialOverrides.value).length > 0) return false;
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
|
|
247
290
|
// --- Token overrides (legacy / quick tweaks) ---
|
|
248
291
|
// State + UI live in <AdminThemeOverridesPanel>; this page only persists
|
|
249
292
|
// what the panel emits.
|
|
@@ -304,9 +347,12 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
304
347
|
{{ toast.msg }}
|
|
305
348
|
</div>
|
|
306
349
|
|
|
307
|
-
<!-- Discovery banner
|
|
350
|
+
<!-- Discovery banner — only when the overrides are from CSS (not from
|
|
351
|
+
a custom theme this admin already saved or instance-wide overrides).
|
|
352
|
+
Without this gate, the banner would re-appear after capture since
|
|
353
|
+
the custom theme it created now appears as a token override on :root. -->
|
|
308
354
|
<section
|
|
309
|
-
v-if="
|
|
355
|
+
v-if="showDiscoveryBanner"
|
|
310
356
|
class="admin-theme-discovery"
|
|
311
357
|
role="region"
|
|
312
358
|
aria-label="Discovered theme tokens"
|
package/pages/index.vue
CHANGED
|
@@ -14,7 +14,7 @@ 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 } = useFeatures();
|
|
17
|
+
const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled, layoutEngine: layoutEngineEnabled } = useFeatures();
|
|
18
18
|
const { enabledTypeMeta } = useContentTypes();
|
|
19
19
|
|
|
20
20
|
const activeTab = ref(authUser.value ? 'foryou' : 'latest');
|
|
@@ -143,8 +143,29 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
143
143
|
|
|
144
144
|
<template>
|
|
145
145
|
<div>
|
|
146
|
+
<!-- ═══ 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">
|
|
156
|
+
<LayoutSlot route="/" zone="full-width" />
|
|
157
|
+
<div class="cpub-main-layout">
|
|
158
|
+
<main class="cpub-feed-col">
|
|
159
|
+
<LayoutSlot route="/" zone="main" />
|
|
160
|
+
</main>
|
|
161
|
+
<aside class="cpub-sidebar">
|
|
162
|
+
<LayoutSlot route="/" zone="sidebar" />
|
|
163
|
+
</aside>
|
|
164
|
+
</div>
|
|
165
|
+
</template>
|
|
166
|
+
|
|
146
167
|
<!-- ═══ CONFIGURABLE HOMEPAGE (section renderer) ═══ -->
|
|
147
|
-
<template v-if="hasCustomSections">
|
|
168
|
+
<template v-else-if="hasCustomSections">
|
|
148
169
|
<!-- Full-width sections (hero) -->
|
|
149
170
|
<HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
|
|
150
171
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/[id]/publish
|
|
3
|
+
*
|
|
4
|
+
* Snapshot the current layout into `layout_versions`, set
|
|
5
|
+
* `published_version_id`, transition `state` to 'published'. Returns
|
|
6
|
+
* the version record.
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
* Invalidates the layouts-by-route cache on success.
|
|
10
|
+
*/
|
|
11
|
+
import { getLayoutById, publishLayout } from '@commonpub/server';
|
|
12
|
+
import { invalidateLayoutsByRouteCache } from '../../../../utils/layoutCache';
|
|
13
|
+
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
requireFeature('admin');
|
|
16
|
+
requireFeature('layoutEngine');
|
|
17
|
+
const admin = requireAdmin(event);
|
|
18
|
+
const db = useDB();
|
|
19
|
+
|
|
20
|
+
const id = getRouterParam(event, 'id');
|
|
21
|
+
if (!id) {
|
|
22
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const existing = await getLayoutById(db, id);
|
|
26
|
+
if (!existing) {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const version = await publishLayout(db, id, { publishedBy: admin.id });
|
|
31
|
+
invalidateLayoutsByRouteCache();
|
|
32
|
+
return version;
|
|
33
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/[id]/versions/[versionId]/revert
|
|
3
|
+
*
|
|
4
|
+
* Restore a layout to a prior version snapshot. The original version
|
|
5
|
+
* row is NOT touched — snapshots are immutable. The layout's current
|
|
6
|
+
* rows + sections are rewritten from the snapshot, and the state
|
|
7
|
+
* transitions to 'draft' (admin can re-publish if desired).
|
|
8
|
+
*
|
|
9
|
+
* Admin + features.admin + features.layoutEngine.
|
|
10
|
+
* Invalidates the layouts-by-route cache on success.
|
|
11
|
+
*/
|
|
12
|
+
import { getLayoutById, revertToVersion } from '@commonpub/server';
|
|
13
|
+
import { invalidateLayoutsByRouteCache } from '../../../../../../utils/layoutCache';
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requireFeature('admin');
|
|
17
|
+
requireFeature('layoutEngine');
|
|
18
|
+
const admin = requireAdmin(event);
|
|
19
|
+
const db = useDB();
|
|
20
|
+
|
|
21
|
+
const id = getRouterParam(event, 'id');
|
|
22
|
+
const versionId = getRouterParam(event, 'versionId');
|
|
23
|
+
if (!id || !versionId) {
|
|
24
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id or versionId param' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const existing = await getLayoutById(db, id);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const reverted = await revertToVersion(db, id, versionId, { userId: admin.id });
|
|
34
|
+
invalidateLayoutsByRouteCache();
|
|
35
|
+
return reverted;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// revertToVersion throws on missing version — map to 404
|
|
38
|
+
if (e instanceof Error && e.message.includes('Version not found')) {
|
|
39
|
+
throw createError({ statusCode: 404, statusMessage: e.message });
|
|
40
|
+
}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts/[id]/versions
|
|
3
|
+
*
|
|
4
|
+
* List all immutable version snapshots for a layout, newest first.
|
|
5
|
+
* Each entry has the FULL snapshot embedded — useful for the version
|
|
6
|
+
* history UI to show a preview of "what this version looked like".
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
*/
|
|
10
|
+
import { getLayoutById, listLayoutVersions } from '@commonpub/server';
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
requireFeature('admin');
|
|
14
|
+
requireFeature('layoutEngine');
|
|
15
|
+
requireAdmin(event);
|
|
16
|
+
const db = useDB();
|
|
17
|
+
|
|
18
|
+
const id = getRouterParam(event, 'id');
|
|
19
|
+
if (!id) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const existing = await getLayoutById(db, id);
|
|
24
|
+
if (!existing) {
|
|
25
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return listLayoutVersions(db, id);
|
|
29
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DELETE /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Permanently delete a layout. Cascades through rows + sections + versions.
|
|
5
|
+
*
|
|
6
|
+
* NOT recoverable. The editor's UI surfaces a confirm modal; this API
|
|
7
|
+
* trusts the caller.
|
|
8
|
+
*
|
|
9
|
+
* Admin + features.admin + features.layoutEngine.
|
|
10
|
+
* Invalidates the layouts-by-route cache on success.
|
|
11
|
+
*/
|
|
12
|
+
import { deleteLayout, getLayoutById } from '@commonpub/server';
|
|
13
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event): Promise<{ ok: true; id: string }> => {
|
|
16
|
+
requireFeature('admin');
|
|
17
|
+
requireFeature('layoutEngine');
|
|
18
|
+
requireAdmin(event);
|
|
19
|
+
const db = useDB();
|
|
20
|
+
|
|
21
|
+
const id = getRouterParam(event, 'id');
|
|
22
|
+
if (!id) {
|
|
23
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const existing = await getLayoutById(db, id);
|
|
27
|
+
if (!existing) {
|
|
28
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await deleteLayout(db, id);
|
|
32
|
+
invalidateLayoutsByRouteCache();
|
|
33
|
+
return { ok: true, id };
|
|
34
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Fetch a layout by id. Returns the full record including draft state +
|
|
5
|
+
* version pointer. 404 if not found.
|
|
6
|
+
*
|
|
7
|
+
* Admin + features.admin + features.layoutEngine.
|
|
8
|
+
*/
|
|
9
|
+
import { getLayoutById } from '@commonpub/server';
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
requireFeature('admin');
|
|
13
|
+
requireFeature('layoutEngine');
|
|
14
|
+
requireAdmin(event);
|
|
15
|
+
|
|
16
|
+
const id = getRouterParam(event, 'id');
|
|
17
|
+
if (!id) {
|
|
18
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const db = useDB();
|
|
22
|
+
const layout = await getLayoutById(db, id);
|
|
23
|
+
if (!layout) {
|
|
24
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
25
|
+
}
|
|
26
|
+
return layout;
|
|
27
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Update a layout — auto-save target for the editor. Sends the WHOLE
|
|
5
|
+
* draft (zones → rows → sections); saveLayout diffs against current,
|
|
6
|
+
* renumbers positions, rewrites children in a transaction.
|
|
7
|
+
*
|
|
8
|
+
* Scope CANNOT be changed via PUT (it's immutable per layout). A scope
|
|
9
|
+
* change would mean a new layout — POST instead.
|
|
10
|
+
*
|
|
11
|
+
* Admin + features.admin + features.layoutEngine.
|
|
12
|
+
* Invalidates the layouts-by-route cache on success.
|
|
13
|
+
*/
|
|
14
|
+
import { layoutCreateSchema } from '@commonpub/schema';
|
|
15
|
+
import { getLayoutById, saveLayout } from '@commonpub/server';
|
|
16
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
requireFeature('admin');
|
|
20
|
+
requireFeature('layoutEngine');
|
|
21
|
+
const admin = requireAdmin(event);
|
|
22
|
+
const db = useDB();
|
|
23
|
+
|
|
24
|
+
const id = getRouterParam(event, 'id');
|
|
25
|
+
if (!id) {
|
|
26
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const existing = await getLayoutById(db, id);
|
|
30
|
+
if (!existing) {
|
|
31
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = await parseBody(event, layoutCreateSchema);
|
|
35
|
+
|
|
36
|
+
// Scope is immutable — reject if the client tries to change it. This
|
|
37
|
+
// catches an "edit the wrong layout" bug at the API surface rather
|
|
38
|
+
// than silently moving sections to a new route.
|
|
39
|
+
if (
|
|
40
|
+
body.scope.type !== existing.scope.type ||
|
|
41
|
+
('path' in body.scope ? body.scope.path : body.scope.key) !==
|
|
42
|
+
('path' in existing.scope ? existing.scope.path : existing.scope.key)
|
|
43
|
+
) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
statusMessage: 'Cannot change layout scope via PUT — POST a new layout instead',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const saved = await saveLayout(
|
|
51
|
+
db,
|
|
52
|
+
{
|
|
53
|
+
scope: body.scope,
|
|
54
|
+
name: body.name,
|
|
55
|
+
pageMeta: body.pageMeta,
|
|
56
|
+
zones: body.zones,
|
|
57
|
+
state: body.state,
|
|
58
|
+
},
|
|
59
|
+
{ id, userId: admin.id },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
invalidateLayoutsByRouteCache();
|
|
63
|
+
return saved;
|
|
64
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts
|
|
3
|
+
*
|
|
4
|
+
* List all layouts. Optional `?scope=route|virtual|custom-page` to
|
|
5
|
+
* filter by scope type. Returns the full LayoutRecord shape (nested
|
|
6
|
+
* zones → rows → sections + version + state).
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
*/
|
|
10
|
+
import { listLayouts } from '@commonpub/server';
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
requireFeature('admin');
|
|
14
|
+
requireFeature('layoutEngine');
|
|
15
|
+
requireAdmin(event);
|
|
16
|
+
|
|
17
|
+
const db = useDB();
|
|
18
|
+
const query = getQuery(event) as { scope?: string };
|
|
19
|
+
const scopeFilter =
|
|
20
|
+
query.scope === 'route' || query.scope === 'virtual' || query.scope === 'custom-page'
|
|
21
|
+
? query.scope
|
|
22
|
+
: undefined;
|
|
23
|
+
|
|
24
|
+
return listLayouts(db, scopeFilter ? { scopeType: scopeFilter } : undefined);
|
|
25
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts
|
|
3
|
+
*
|
|
4
|
+
* Create a new layout for a scope. Body is the full layout payload
|
|
5
|
+
* (validated against `layoutCreateSchema`) with client-generated UUIDs
|
|
6
|
+
* for sections/rows. Returns the created LayoutRecord.
|
|
7
|
+
*
|
|
8
|
+
* Fails with 409 if a layout already exists at the given scope (the
|
|
9
|
+
* editor should PUT to the existing one instead).
|
|
10
|
+
*
|
|
11
|
+
* Admin + features.admin + features.layoutEngine.
|
|
12
|
+
* Invalidates the layouts-by-route cache on success.
|
|
13
|
+
*/
|
|
14
|
+
import { layoutCreateSchema } from '@commonpub/schema';
|
|
15
|
+
import { getLayoutByScope, saveLayout } from '@commonpub/server';
|
|
16
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
requireFeature('admin');
|
|
20
|
+
requireFeature('layoutEngine');
|
|
21
|
+
const admin = requireAdmin(event);
|
|
22
|
+
const db = useDB();
|
|
23
|
+
|
|
24
|
+
const body = await parseBody(event, layoutCreateSchema);
|
|
25
|
+
|
|
26
|
+
const existing = await getLayoutByScope(db, body.scope);
|
|
27
|
+
if (existing) {
|
|
28
|
+
throw createError({
|
|
29
|
+
statusCode: 409,
|
|
30
|
+
statusMessage: `A layout already exists for scope ${JSON.stringify(body.scope)}; PUT to /api/admin/layouts/${existing.id} to update it`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const saved = await saveLayout(
|
|
35
|
+
db,
|
|
36
|
+
{
|
|
37
|
+
scope: body.scope,
|
|
38
|
+
name: body.name,
|
|
39
|
+
pageMeta: body.pageMeta,
|
|
40
|
+
zones: body.zones,
|
|
41
|
+
state: body.state,
|
|
42
|
+
},
|
|
43
|
+
{ userId: admin.id },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
invalidateLayoutsByRouteCache();
|
|
47
|
+
return saved;
|
|
48
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/seed-homepage
|
|
3
|
+
*
|
|
4
|
+
* Operator's bootstrap call: seeds + publishes a default homepage
|
|
5
|
+
* layout (hero + content-feed) at scope ('route', '/'). Idempotent —
|
|
6
|
+
* safe to call repeatedly.
|
|
7
|
+
*
|
|
8
|
+
* Intended flow for enabling the layout engine on the homepage:
|
|
9
|
+
* 1. Operator runs migration 0005 (if not already applied)
|
|
10
|
+
* 2. Operator POSTs to this endpoint → layout exists, published v1
|
|
11
|
+
* 3. Operator flips `features.layoutEngine` ON
|
|
12
|
+
* 4. Homepage renders via `<LayoutSlot>` (v-if branch in pages/index.vue)
|
|
13
|
+
*
|
|
14
|
+
* NOT the full legacy `homepage.sections` migration — that needs the
|
|
15
|
+
* remaining sections from Phase 6b. Doc'd at
|
|
16
|
+
* `docs/reference/guides/layout-engine.md`.
|
|
17
|
+
*
|
|
18
|
+
* Admin + features.admin + features.layoutEngine.
|
|
19
|
+
* Invalidates the layouts-by-route cache on success.
|
|
20
|
+
*/
|
|
21
|
+
import { seedHomepageLayout } from '@commonpub/server';
|
|
22
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
23
|
+
|
|
24
|
+
export default defineEventHandler(async (event) => {
|
|
25
|
+
requireFeature('admin');
|
|
26
|
+
requireFeature('layoutEngine');
|
|
27
|
+
const admin = requireAdmin(event);
|
|
28
|
+
const db = useDB();
|
|
29
|
+
|
|
30
|
+
const result = await seedHomepageLayout(db, { adminId: admin.id });
|
|
31
|
+
if (result.created) {
|
|
32
|
+
invalidateLayoutsByRouteCache();
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/admin/themes/discover
|
|
3
3
|
*
|
|
4
|
-
* Returns the canonical default values for every known token.
|
|
5
|
-
* uses this to diff against `getComputedStyle(:root)` and surface a
|
|
6
|
-
* "your site has a custom theme — capture it?" CTA when the runtime values
|
|
7
|
-
* differ from the built-in defaults.
|
|
4
|
+
* Returns the canonical default values for every known token.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* **CURRENTLY UNUSED** (as of session 157). The client-side
|
|
7
|
+
* `detectAppliedOverrides()` in `utils/themeDiscovery.ts` uses
|
|
8
|
+
* `TOKEN_SPECS` directly (same data source, no HTTP). The endpoint is
|
|
9
|
+
* kept because:
|
|
10
|
+
* 1. It's documented in `docs/reference/guides/theme-editor.md` as
|
|
11
|
+
* the source of truth for default values.
|
|
12
|
+
* 2. A future Phase (section registry adds new tokens at runtime, or
|
|
13
|
+
* the layer wants to source defaults from the server rather than
|
|
14
|
+
* the client bundle) may flip the client to use this endpoint.
|
|
15
|
+
* 3. Deleting would force a doc revision + breaking-change note for
|
|
16
|
+
* anyone who scripted against it externally.
|
|
17
|
+
*
|
|
18
|
+
* Cost is zero (no DB hit, just returns a static map). Safe to keep.
|
|
10
19
|
*/
|
|
11
20
|
import { TOKEN_SPECS } from '@commonpub/ui';
|
|
12
21
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/layouts/by-route?path=/some-path
|
|
3
|
+
*
|
|
4
|
+
* Public endpoint — resolves the active layout for a route, used by the
|
|
5
|
+
* `<LayoutSlot>` renderer on every page request. Returns the published
|
|
6
|
+
* version when available, otherwise the draft (during pre-publish
|
|
7
|
+
* editing — admins see drafts, end users get a 404 until published).
|
|
8
|
+
*
|
|
9
|
+
* Cached server-side for `LAYOUT_CACHE_TTL_MS` per path (see
|
|
10
|
+
* `server/utils/layoutCache.ts`). Invalidated on any layout write —
|
|
11
|
+
* the admin POST/PUT/DELETE/publish/revert handlers each call
|
|
12
|
+
* `invalidateLayoutsByRouteCache()` before returning.
|
|
13
|
+
*
|
|
14
|
+
* Gated by `features.layoutEngine`. Returns 404 when off so the legacy
|
|
15
|
+
* homepage section renderer keeps working.
|
|
16
|
+
*/
|
|
17
|
+
import { getLayoutByScope, type LayoutRecord, type LayoutScope } from '@commonpub/server';
|
|
18
|
+
import {
|
|
19
|
+
LAYOUT_CACHE_TTL_MS,
|
|
20
|
+
getLayoutCacheEntry,
|
|
21
|
+
setLayoutCacheEntry,
|
|
22
|
+
} from '../../utils/layoutCache';
|
|
23
|
+
|
|
24
|
+
// Re-export for callers that import the invalidator from this file
|
|
25
|
+
// (kept for backwards compat after the session-158 refactor that moved
|
|
26
|
+
// the cache into utils/). New code should import from utils/layoutCache.
|
|
27
|
+
export { invalidateLayoutsByRouteCache } from '../../utils/layoutCache';
|
|
28
|
+
|
|
29
|
+
interface PublicLayoutSlice {
|
|
30
|
+
/** Zones → rows → enabled+visible sections only. Strips draft metadata. */
|
|
31
|
+
zones: LayoutRecord['zones'];
|
|
32
|
+
/** Page meta for custom pages; null for routes. */
|
|
33
|
+
pageMeta: LayoutRecord['pageMeta'];
|
|
34
|
+
/** State so SSR can mark `noindex` on drafts. */
|
|
35
|
+
state: LayoutRecord['state'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default defineEventHandler(async (event): Promise<PublicLayoutSlice | null> => {
|
|
39
|
+
const config = useConfig();
|
|
40
|
+
if (!(config.features as unknown as Record<string, boolean>).layoutEngine) {
|
|
41
|
+
// Feature off — return 404 so the legacy renderer stays in charge
|
|
42
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout engine not enabled' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { path } = parseQueryParams(event, layoutsByRoutePathSchema);
|
|
46
|
+
const cacheKey = path;
|
|
47
|
+
const hit = getLayoutCacheEntry<PublicLayoutSlice>(cacheKey);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (hit && now - hit.at < LAYOUT_CACHE_TTL_MS) {
|
|
50
|
+
return hit.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const db = useDB();
|
|
54
|
+
// Try route scope first, then custom-page (custom pages shadow routes)
|
|
55
|
+
const scope: LayoutScope = isCustomPagePath(path)
|
|
56
|
+
? { type: 'custom-page', path }
|
|
57
|
+
: { type: 'route', path };
|
|
58
|
+
|
|
59
|
+
const layout = await getLayoutByScope(db, scope);
|
|
60
|
+
const value: PublicLayoutSlice | null = layout
|
|
61
|
+
? {
|
|
62
|
+
zones: layout.zones,
|
|
63
|
+
pageMeta: layout.pageMeta,
|
|
64
|
+
state: layout.state,
|
|
65
|
+
}
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
setLayoutCacheEntry(cacheKey, value, now);
|
|
69
|
+
return value;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Heuristic: paths NOT in the file-routes manifest are candidate
|
|
74
|
+
* custom-page paths. For Phase 1, we leave this as `false` (always
|
|
75
|
+
* resolve as route scope) — custom-page support lands in Phase 2 once
|
|
76
|
+
* the catch-all + conflict detection is in.
|
|
77
|
+
*/
|
|
78
|
+
function isCustomPagePath(_path: string): boolean {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Inline Zod since the public-API surface keeps validators close to handlers
|
|
83
|
+
// (matches the pattern used elsewhere — e.g. /api/profile/theme.put.ts).
|
|
84
|
+
import { z } from 'zod';
|
|
85
|
+
const layoutsByRoutePathSchema = z.object({
|
|
86
|
+
path: z.string().min(1).max(512).regex(/^\/[a-zA-Z0-9._~/-]*$/, 'Path must start with /'),
|
|
87
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared layout-resolution cache.
|
|
3
|
+
*
|
|
4
|
+
* The `/api/layouts/by-route` endpoint reads from this; every admin
|
|
5
|
+
* layout write handler MUST invalidate after success (otherwise SSR
|
|
6
|
+
* serves stale data for up to TTL_MS after a save).
|
|
7
|
+
*
|
|
8
|
+
* Kept here (utils/) rather than inline in `api/layouts/by-route.get.ts`
|
|
9
|
+
* so the admin write routes can import the invalidator without
|
|
10
|
+
* cross-file-handler imports (nitro's route-discovery treats `*.get.ts`
|
|
11
|
+
* + `*.post.ts` as handlers, not regular modules — utility shared state
|
|
12
|
+
* belongs in `utils/`).
|
|
13
|
+
*
|
|
14
|
+
* Per-process map → 60s TTL bounds staleness across pods. The Phase 10
|
|
15
|
+
* performance pass (`docs/plans/layout-and-pages.md` §10) replaces this
|
|
16
|
+
* with ETag-based revalidation; the in-memory cache is sufficient for
|
|
17
|
+
* Phase 1c volumes.
|
|
18
|
+
*/
|
|
19
|
+
export interface LayoutCacheEntry<T> {
|
|
20
|
+
value: T | null;
|
|
21
|
+
at: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const LAYOUT_CACHE_TTL_MS = 60_000;
|
|
25
|
+
const cache = new Map<string, LayoutCacheEntry<unknown>>();
|
|
26
|
+
|
|
27
|
+
export function getLayoutCacheEntry<T>(key: string): LayoutCacheEntry<T> | undefined {
|
|
28
|
+
return cache.get(key) as LayoutCacheEntry<T> | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setLayoutCacheEntry<T>(key: string, value: T | null, at: number = Date.now()): void {
|
|
32
|
+
cache.set(key, { value, at });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drop every cached entry. Called from every admin layout write
|
|
37
|
+
* handler (POST/PUT/DELETE on layouts, publish, revert, seed) BEFORE
|
|
38
|
+
* the response is sent. A more selective `invalidate(scopeKey)` is
|
|
39
|
+
* possible — but the cache is tiny (one entry per ever-visited route)
|
|
40
|
+
* and full-clear is the safer default while the editor is still
|
|
41
|
+
* coalescing draft saves (Phase 7+).
|
|
42
|
+
*/
|
|
43
|
+
export function invalidateLayoutsByRouteCache(): void {
|
|
44
|
+
cache.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cache size — for test-only inspection (verify a write actually
|
|
49
|
+
* cleared the cache rather than just calling a no-op function).
|
|
50
|
+
*/
|
|
51
|
+
export function _layoutCacheSize(): number {
|
|
52
|
+
return cache.size;
|
|
53
|
+
}
|