@commonpub/layer 0.15.3 → 0.15.5
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/composables/useFeatures.ts +22 -5
- package/middleware/feature-gate.global.ts +11 -5
- package/nuxt.config.ts +14 -5
- package/package.json +4 -4
- package/pages/u/[username]/index.vue +18 -1
- package/server/{routes/hubs/[slug].ts → middleware/hub-ap.ts} +12 -7
- package/server/{routes/hubs/[slug]/posts/[postId].ts → middleware/hub-post-ap.ts} +13 -8
|
@@ -20,18 +20,35 @@ export interface FeatureFlags {
|
|
|
20
20
|
|
|
21
21
|
let hydrated = false;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Shared default shape. Exported so feature-gate middleware can use the same
|
|
24
|
+
// initializer — if the middleware runs before useFeatures() and initializes
|
|
25
|
+
// the 'feature-flags' state to a different value, useFeatures()'s own
|
|
26
|
+
// initializer is skipped (Nuxt useState only inits once per key), and any
|
|
27
|
+
// `flags.value.X` access would crash at runtime.
|
|
28
|
+
export const DEFAULT_FLAGS: FeatureFlags = {
|
|
24
29
|
content: true, social: true, hubs: true, docs: true, video: true,
|
|
25
30
|
contests: false, events: false, learning: true, explainers: true,
|
|
26
31
|
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
27
32
|
};
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
/** Build the initial flags by merging the layer's runtime config over defaults. */
|
|
35
|
+
export function getInitialFlags(): FeatureFlags {
|
|
30
36
|
const config = useRuntimeConfig();
|
|
31
|
-
const buildFlags = (config.public.features as unknown as FeatureFlags) ??
|
|
37
|
+
const buildFlags = (config.public.features as unknown as Partial<FeatureFlags> | undefined) ?? {};
|
|
38
|
+
return { ...DEFAULT_FLAGS, ...buildFlags };
|
|
39
|
+
}
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
export function useFeatures() {
|
|
42
|
+
// Shared reactive state — initialized from build-time config.
|
|
43
|
+
// Uses the shared getInitialFlags() so middleware and composable agree on
|
|
44
|
+
// the default shape (see the DEFAULT_FLAGS export note above).
|
|
45
|
+
const flags = useState<FeatureFlags>('feature-flags', getInitialFlags);
|
|
46
|
+
|
|
47
|
+
// Defensive: if another consumer poisoned the state to null/undefined
|
|
48
|
+
// before we ran, repair it here so .value.X accesses don't crash.
|
|
49
|
+
if (flags.value == null) {
|
|
50
|
+
flags.value = getInitialFlags();
|
|
51
|
+
}
|
|
35
52
|
|
|
36
53
|
// On client, fetch dynamic features once to pick up DB overrides
|
|
37
54
|
if (import.meta.client && !hydrated) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Prevents client-side navigation to feature-gated pages when the flag is disabled.
|
|
3
3
|
// Uses useState('feature-flags') which is hydrated by useFeatures() from /api/features.
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import { getInitialFlags, type FeatureFlags } from '../composables/useFeatures';
|
|
6
6
|
|
|
7
7
|
const ROUTE_FEATURE_MAP: Record<string, keyof FeatureFlags> = {
|
|
8
8
|
'/learn': 'learning',
|
|
@@ -17,10 +17,16 @@ const ROUTE_FEATURE_MAP: Record<string, keyof FeatureFlags> = {
|
|
|
17
17
|
export default defineNuxtRouteMiddleware((to) => {
|
|
18
18
|
for (const [prefix, feature] of Object.entries(ROUTE_FEATURE_MAP)) {
|
|
19
19
|
if (to.path === prefix || to.path.startsWith(prefix + '/')) {
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// IMPORTANT: use the same initializer as useFeatures(). Previously this
|
|
21
|
+
// initialized to `null`, which poisoned the shared state — Nuxt's
|
|
22
|
+
// useState only runs its initializer the first time a key is seen per
|
|
23
|
+
// request. When the layout later called useFeatures() with its own
|
|
24
|
+
// initializer, Nuxt returned the existing null state, and any
|
|
25
|
+
// flags.value.X access crashed with "Cannot read properties of null
|
|
26
|
+
// (reading '...')". That was the root cause of the commonpub.io
|
|
27
|
+
// /docs, /learn, /videos, /explainer SSR-500 bug (session 126).
|
|
28
|
+
const flags = useState<FeatureFlags>('feature-flags', getInitialFlags);
|
|
29
|
+
if (!flags.value[feature]) {
|
|
24
30
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' });
|
|
25
31
|
}
|
|
26
32
|
return;
|
package/nuxt.config.ts
CHANGED
|
@@ -95,12 +95,21 @@ export default defineNuxtConfig({
|
|
|
95
95
|
instanceCookies: [] as Array<{ name: string; category: string; description: string; duration: string; provider?: string }>,
|
|
96
96
|
},
|
|
97
97
|
},
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// NOTE: /docs/** was previously set to `prerender: true` but this caused
|
|
99
|
+
// production 500s because the Docker build stage has no DB access. During
|
|
100
|
+
// prerender, the /api/docs fetch fails → the prerenderer saves the error
|
|
101
|
+
// HTML as /docs/index.html in .output/public, and crawlLinks propagated
|
|
102
|
+
// the failure to linked routes (/learn, /videos). Runtime SSR works fine
|
|
103
|
+
// because the app then has real DB access. If caching is later needed,
|
|
104
|
+
// use `swr: 60` (stale-while-revalidate at runtime), NOT `prerender: true`.
|
|
105
|
+
routeRules: {},
|
|
103
106
|
nitro: {
|
|
104
107
|
preset: 'node-server',
|
|
108
|
+
// Disable link-crawling during prerender so any future prerender rules
|
|
109
|
+
// can't accidentally cascade failures to other routes.
|
|
110
|
+
prerender: {
|
|
111
|
+
crawlLinks: false,
|
|
112
|
+
failOnError: false,
|
|
113
|
+
},
|
|
105
114
|
},
|
|
106
115
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,11 +55,11 @@
|
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/docs": "0.6.2",
|
|
57
57
|
"@commonpub/config": "0.10.0",
|
|
58
|
+
"@commonpub/auth": "0.5.1",
|
|
59
|
+
"@commonpub/editor": "0.7.9",
|
|
58
60
|
"@commonpub/protocol": "0.9.9",
|
|
59
61
|
"@commonpub/learning": "0.5.0",
|
|
60
|
-
"@commonpub/ui": "0.8.5"
|
|
61
|
-
"@commonpub/auth": "0.5.1",
|
|
62
|
-
"@commonpub/editor": "0.7.9"
|
|
62
|
+
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -74,6 +74,23 @@ const filteredContent = computed(() => {
|
|
|
74
74
|
|
|
75
75
|
const p = computed(() => profile.value);
|
|
76
76
|
|
|
77
|
+
// Activity heatmap data: count content items published per day (YYYY-MM-DD).
|
|
78
|
+
// HeatmapGrid needs Record<string, number> keyed by ISO date; with no data
|
|
79
|
+
// all cells render as level-0 (empty) which looks broken.
|
|
80
|
+
const activityByDate = computed<Record<string, number>>(() => {
|
|
81
|
+
const items = content.value?.items;
|
|
82
|
+
if (!items?.length) return {};
|
|
83
|
+
const map: Record<string, number> = {};
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
// Prefer publishedAt; fall back to createdAt. Items without either are skipped.
|
|
86
|
+
const ts = item.publishedAt ?? item.createdAt;
|
|
87
|
+
if (!ts) continue;
|
|
88
|
+
const date = String(ts).slice(0, 10);
|
|
89
|
+
map[date] = (map[date] ?? 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
});
|
|
93
|
+
|
|
77
94
|
const { isAuthenticated, user } = useAuth();
|
|
78
95
|
const toast = useToast();
|
|
79
96
|
const isOwnProfile = computed(() => user.value?.username === username);
|
|
@@ -398,7 +415,7 @@ async function handleReport(): Promise<void> {
|
|
|
398
415
|
<!-- Activity Heatmap -->
|
|
399
416
|
<div class="cpub-sb-card">
|
|
400
417
|
<div class="cpub-sb-title">Activity</div>
|
|
401
|
-
<HeatmapGrid :weeks="20" />
|
|
418
|
+
<HeatmapGrid :data="activityByDate" :weeks="20" />
|
|
402
419
|
</div>
|
|
403
420
|
|
|
404
421
|
<!-- Featured Projects -->
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { buildHubGroupActor } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Middleware: serve ActivityPub Group actor JSON-LD for hub URIs.
|
|
5
|
+
*
|
|
6
|
+
* Matches /hubs/{slug} with AP Accept headers.
|
|
7
|
+
* Non-AP requests pass through to the Nuxt page renderer.
|
|
8
|
+
*
|
|
9
|
+
* This MUST be a middleware (not a server route) because a server route
|
|
10
|
+
* returning undefined sends HTTP 204, which prevents the Nuxt page from rendering.
|
|
7
11
|
*/
|
|
8
12
|
export default defineEventHandler(async (event) => {
|
|
9
13
|
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
@@ -11,15 +15,16 @@ export default defineEventHandler(async (event) => {
|
|
|
11
15
|
accept.includes('application/activity+json') ||
|
|
12
16
|
accept.includes('application/ld+json');
|
|
13
17
|
|
|
14
|
-
// Only handle AP requests — let browser requests fall through to the page handler
|
|
15
18
|
if (!isAPRequest) return;
|
|
16
19
|
|
|
20
|
+
const path = getRequestURL(event).pathname;
|
|
21
|
+
const match = path.match(/^\/hubs\/([a-z0-9][a-z0-9_-]*)$/);
|
|
22
|
+
if (!match) return;
|
|
23
|
+
|
|
17
24
|
const config = useConfig();
|
|
18
25
|
if (!config.features.federation || !config.features.federateHubs) return;
|
|
19
26
|
|
|
20
|
-
const slug =
|
|
21
|
-
if (!slug) return;
|
|
22
|
-
|
|
27
|
+
const slug = match[1]!;
|
|
23
28
|
const db = useDB();
|
|
24
29
|
const actor = await buildHubGroupActor(db, slug, config.instance.domain);
|
|
25
30
|
if (!actor) return;
|
|
@@ -4,9 +4,13 @@ import { hubPosts, users } from '@commonpub/schema';
|
|
|
4
4
|
import { eq } from 'drizzle-orm';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Middleware: serve ActivityPub Note JSON-LD for hub post URIs.
|
|
8
|
+
*
|
|
9
|
+
* Matches /hubs/{slug}/posts/{postId} with AP Accept headers.
|
|
10
|
+
* Non-AP requests pass through to the Nuxt page renderer.
|
|
11
|
+
*
|
|
12
|
+
* This MUST be a middleware (not a server route) because a server route
|
|
13
|
+
* returning undefined sends HTTP 204, which prevents the Nuxt page from rendering.
|
|
10
14
|
*/
|
|
11
15
|
export default defineEventHandler(async (event) => {
|
|
12
16
|
const accept = getRequestHeader(event, 'accept') ?? '';
|
|
@@ -16,13 +20,15 @@ export default defineEventHandler(async (event) => {
|
|
|
16
20
|
|
|
17
21
|
if (!isAPRequest) return;
|
|
18
22
|
|
|
23
|
+
const path = getRequestURL(event).pathname;
|
|
24
|
+
const match = path.match(/^\/hubs\/([a-z0-9][a-z0-9_-]*)\/posts\/([a-zA-Z0-9_-]+)$/);
|
|
25
|
+
if (!match) return;
|
|
26
|
+
|
|
19
27
|
const config = useConfig();
|
|
20
28
|
if (!config.features.federation || !config.features.federateHubs) return;
|
|
21
29
|
|
|
22
|
-
const slug =
|
|
23
|
-
const postId =
|
|
24
|
-
if (!slug || !postId) return;
|
|
25
|
-
|
|
30
|
+
const slug = match[1]!;
|
|
31
|
+
const postId = match[2]!;
|
|
26
32
|
const db = useDB();
|
|
27
33
|
const domain = config.instance.domain;
|
|
28
34
|
|
|
@@ -51,7 +57,6 @@ export default defineEventHandler(async (event) => {
|
|
|
51
57
|
|
|
52
58
|
setResponseHeader(event, 'content-type', 'application/activity+json');
|
|
53
59
|
|
|
54
|
-
// Build Note content — share posts need special handling
|
|
55
60
|
let noteContent = escapeHtmlForAP(post.content);
|
|
56
61
|
const ext: Record<string, unknown> = {};
|
|
57
62
|
|