@commonpub/layer 0.18.0 → 0.18.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.
@@ -11,7 +11,13 @@ const activeContest = computed(() => {
11
11
  return items?.find((c) => c.status === 'active') ?? null;
12
12
  });
13
13
 
14
- const heroDismissed = ref(false);
14
+ // Shared via useState so the dismiss sticks across component remounts.
15
+ // HomepageSectionRenderer's v-if wrappers can remount HeroSection when the
16
+ // `sections` useFetch revalidates on hydration or when feature flags flip
17
+ // (they're async on first load). A local ref would reset on remount and
18
+ // the user would see the banner "come back" after dismissing — which also
19
+ // fails the navigation.spec.ts e2e test.
20
+ const heroDismissed = useState('cpub:hero-dismissed', () => false);
15
21
  </script>
16
22
 
17
23
  <template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.12",
32
- "@commonpub/schema": "^0.14.3",
33
- "@commonpub/server": "^2.47.0",
32
+ "@commonpub/schema": "^0.14.4",
33
+ "@commonpub/server": "^2.47.2",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,11 +53,11 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/auth": "0.5.1",
57
56
  "@commonpub/docs": "0.6.2",
57
+ "@commonpub/auth": "0.5.1",
58
58
  "@commonpub/config": "0.11.0",
59
- "@commonpub/learning": "0.5.1",
60
59
  "@commonpub/editor": "0.7.9",
60
+ "@commonpub/learning": "0.5.1",
61
61
  "@commonpub/protocol": "0.9.9",
62
62
  "@commonpub/ui": "0.8.5"
63
63
  },
@@ -254,13 +254,15 @@ useSeoMeta({
254
254
  </div>
255
255
  <div v-if="searchOpen && searchResults?.length" class="docs-search-results">
256
256
  <NuxtLink
257
- v-for="r in (searchResults as Array<{ id: string; title: string; slug: string }>)"
257
+ v-for="r in (searchResults as Array<{ id: string; title: string; slug: string; snippet?: string | null }>)"
258
258
  :key="r.id"
259
259
  :to="`/docs/${siteSlug}/${r.slug}`"
260
260
  class="docs-search-result"
261
261
  @click="searchOpen = false; searchQuery = ''"
262
262
  >
263
- {{ r.title }}
263
+ <span class="docs-search-result-title">{{ r.title }}</span>
264
+ <!-- eslint-disable-next-line vue/no-v-html — see highlightSnippet docstring. -->
265
+ <span v-if="r.snippet" class="docs-search-result-snippet" v-html="highlightSnippet(r.snippet)" />
264
266
  </NuxtLink>
265
267
  </div>
266
268
  </div>
@@ -481,7 +483,7 @@ useSeoMeta({
481
483
  border: var(--border-width-default) solid var(--border);
482
484
  box-shadow: var(--shadow-md);
483
485
  z-index: 50;
484
- max-height: 200px;
486
+ max-height: 280px;
485
487
  overflow-y: auto;
486
488
  }
487
489
 
@@ -496,6 +498,10 @@ useSeoMeta({
496
498
 
497
499
  .docs-search-result:last-child { border-bottom: none; }
498
500
  .docs-search-result:hover { background: var(--surface2); color: var(--accent); }
501
+ .docs-search-result-title { display: block; color: var(--text); font-weight: 500; }
502
+ .docs-search-result-snippet { display: block; margin-top: 2px; color: var(--text-faint); font-size: 11px; line-height: 1.4; }
503
+ .docs-search-result-snippet :deep(b) { background: var(--accent-soft, rgba(91, 156, 246, 0.18)); color: var(--text); font-weight: 600; padding: 0 2px; border-radius: 2px; }
504
+ .docs-search-result:hover .docs-search-result-title { color: var(--accent); }
499
505
 
500
506
  /* Nav Tree */
501
507
  .docs-nav { padding: 0; }
@@ -147,13 +147,17 @@ useSeoMeta({
147
147
  </div>
148
148
  <div v-if="searchOpen && searchResults?.length" class="docs-search-results">
149
149
  <NuxtLink
150
- v-for="r in (searchResults as Array<{ id: string; title: string; slug: string }>)"
150
+ v-for="r in (searchResults as Array<{ id: string; title: string; slug: string; snippet?: string | null }>)"
151
151
  :key="r.id"
152
152
  :to="`/docs/${siteSlug}/${r.slug}`"
153
153
  class="docs-search-result"
154
154
  @click="searchOpen = false; searchQuery = ''"
155
155
  >
156
- {{ r.title }}
156
+ <span class="docs-search-result-title">{{ r.title }}</span>
157
+ <!-- eslint-disable-next-line vue/no-v-html — snippet is ts_headline
158
+ output, sanitized by highlightSnippet (escapes everything,
159
+ restores only <b> and </b>). -->
160
+ <span v-if="r.snippet" class="docs-search-result-snippet" v-html="highlightSnippet(r.snippet)" />
157
161
  </NuxtLink>
158
162
  </div>
159
163
  </div>
@@ -267,10 +271,14 @@ useSeoMeta({
267
271
  .docs-search-input { width: 100%; padding: 6px 8px 6px 26px; font-size: 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); }
268
272
  .docs-search-input::placeholder { color: var(--text-faint); }
269
273
  .docs-search-input:focus { border-color: var(--accent); outline: none; }
270
- .docs-search-results { position: absolute; top: 100%; left: 16px; right: 16px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 50; max-height: 200px; overflow-y: auto; }
274
+ .docs-search-results { position: absolute; top: 100%; left: 16px; right: 16px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 50; max-height: 280px; overflow-y: auto; }
271
275
  .docs-search-result { display: block; padding: 8px 12px; font-size: 12px; color: var(--text-dim); text-decoration: none; border-bottom: var(--border-width-default) solid var(--border2); }
272
276
  .docs-search-result:last-child { border-bottom: none; }
273
277
  .docs-search-result:hover { background: var(--surface2); color: var(--accent); }
278
+ .docs-search-result-title { display: block; color: var(--text); font-weight: 500; }
279
+ .docs-search-result-snippet { display: block; margin-top: 2px; color: var(--text-faint); font-size: 11px; line-height: 1.4; }
280
+ .docs-search-result-snippet :deep(b) { background: var(--accent-soft, rgba(91, 156, 246, 0.18)); color: var(--text); font-weight: 600; padding: 0 2px; border-radius: 2px; }
281
+ .docs-search-result:hover .docs-search-result-title { color: var(--accent); }
274
282
 
275
283
  .docs-nav { padding: 0; }
276
284
  .docs-nav-item { border-bottom: var(--border-width-default) solid var(--border2); }
package/pages/index.vue CHANGED
@@ -74,7 +74,9 @@ const { data: contests, pending: contestsPending } = await useFetch<{ items: Con
74
74
  query: { limit: 3 },
75
75
  });
76
76
 
77
- const heroDismissed = ref(false);
77
+ // Shared with HeroSection.vue via the same useState key so the dismiss
78
+ // persists across the configurable-renderer and legacy code paths.
79
+ const heroDismissed = useState('cpub:hero-dismissed', () => false);
78
80
  const joinedHubs = ref(new Set<string>());
79
81
 
80
82
  // Active contest for hero banner
@@ -1,9 +1,15 @@
1
1
  // Security middleware — rate limiting + security headers + CSP
2
- import { checkRateLimit, createRateLimitStore, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
2
+ import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
3
3
 
4
4
  // Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
5
5
  // in-process memory store. Unset env = byte-identical behavior to pre-0.6.
6
- const store = createRateLimitStore({ redisUrl: process.env.NUXT_REDIS_URL });
6
+ // `onRedisError` is rate-limited: first event logs immediately, subsequent
7
+ // events roll up into a one-per-minute summary so a Redis outage doesn't
8
+ // flood the log at real traffic.
9
+ const store = createRateLimitStore({
10
+ redisUrl: process.env.NUXT_REDIS_URL,
11
+ onRedisError: createRedisFailOpenLogger({ scope: 'ratelimit:ip' }),
12
+ });
7
13
  const isDev = process.env.NODE_ENV !== 'production';
8
14
 
9
15
  export default defineEventHandler(async (event) => {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Render a Postgres `ts_headline` snippet safely.
3
+ *
4
+ * `ts_headline` wraps matched tokens in `<b>...</b>` (and nothing else by
5
+ * default). The input text has already been HTML-tag-stripped on the
6
+ * server (see packages/server/src/docs/docs.ts `searchDocsPages` — the
7
+ * `extracted.text_content` CTE uses `regexp_replace` to pull tags out
8
+ * before tokenization). So the only HTML we should ever see in the
9
+ * returned string is the `<b>` markers ts_headline itself emits.
10
+ *
11
+ * To be safe anyway: HTML-escape the whole string, then restore exactly
12
+ * `<b>` and `</b>`. Anything else — including attributes on `<b>` or any
13
+ * other tag that somehow slipped through — becomes harmless escaped text.
14
+ *
15
+ * Return value is intended for `v-html`.
16
+ */
17
+ export function highlightSnippet(snippet: string | null | undefined): string {
18
+ if (!snippet) return '';
19
+ const escaped = snippet
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;');
25
+ // Restore only bare <b> / </b> (no attributes, no whitespace variants).
26
+ return escaped
27
+ .replace(/&lt;b&gt;/g, '<b>')
28
+ .replace(/&lt;\/b&gt;/g, '</b>');
29
+ }