@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.
@@ -20,18 +20,35 @@ export interface FeatureFlags {
20
20
 
21
21
  let hydrated = false;
22
22
 
23
- const DEFAULT_FLAGS: FeatureFlags = {
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
- export function useFeatures() {
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) ?? DEFAULT_FLAGS;
37
+ const buildFlags = (config.public.features as unknown as Partial<FeatureFlags> | undefined) ?? {};
38
+ return { ...DEFAULT_FLAGS, ...buildFlags };
39
+ }
32
40
 
33
- // Shared reactive state — initialized from build-time config
34
- const flags = useState<FeatureFlags>('feature-flags', () => ({ ...DEFAULT_FLAGS, ...buildFlags }));
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 { FeatureFlags } from '../composables/useFeatures';
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
- // Prefer reactive state (hydrated from /api/features), fall back to build-time config
21
- const featureState = useState<FeatureFlags | null>('feature-flags', () => null);
22
- const flags = featureState.value ?? (useRuntimeConfig().public.features as Record<string, boolean>);
23
- if (!(flags as Record<string, boolean>)[feature]) {
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
- routeRules: {
99
- ...(process.env.NUXT_PUBLIC_FEATURES_DOCS !== 'false' && {
100
- '/docs/**': { prerender: true },
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",
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
- * Hub Group actor endpoint. Returns AP Group JSON-LD for federation.
5
- * Only responds to ActivityPub clients (Accept: application/activity+json).
6
- * Browser requests pass through to the Nuxt page handler.
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 = getRouterParam(event, '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
- * Hub post AP Note endpoint.
8
- * Serves the Note JSON-LD when requested with AP Accept header.
9
- * Remote instances dereference this URI when processing Announce activities.
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 = getRouterParam(event, 'slug');
23
- const postId = getRouterParam(event, '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