@commonpub/layer 0.17.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.
- package/components/homepage/HeroSection.vue +7 -1
- package/package.json +7 -7
- package/pages/docs/[siteSlug]/[...pagePath].vue +9 -3
- package/pages/docs/[siteSlug]/index.vue +11 -3
- package/pages/index.vue +3 -1
- package/server/api/learn/[slug]/[lessonSlug]/complete.post.ts +18 -2
- package/server/api/learn/[slug]/[lessonSlug]/index.get.ts +11 -1
- package/server/api/realtime/stream.get.ts +57 -10
- package/server/middleware/public-api-auth.ts +1 -1
- package/server/middleware/security.ts +13 -5
- package/utils/highlightSnippet.ts +29 -0
|
@@ -11,7 +11,13 @@ const activeContest = computed(() => {
|
|
|
11
11
|
return items?.find((c) => c.status === 'active') ?? null;
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
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,12 +53,12 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/editor": "0.7.9",
|
|
57
|
-
"@commonpub/learning": "0.5.0",
|
|
58
|
-
"@commonpub/auth": "0.5.1",
|
|
59
|
-
"@commonpub/protocol": "0.9.9",
|
|
60
56
|
"@commonpub/docs": "0.6.2",
|
|
57
|
+
"@commonpub/auth": "0.5.1",
|
|
61
58
|
"@commonpub/config": "0.11.0",
|
|
59
|
+
"@commonpub/editor": "0.7.9",
|
|
60
|
+
"@commonpub/learning": "0.5.1",
|
|
61
|
+
"@commonpub/protocol": "0.9.9",
|
|
62
62
|
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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,13 +1,29 @@
|
|
|
1
1
|
import { getLessonBySlug, markLessonComplete } from '@commonpub/server';
|
|
2
|
+
import { completeLessonSchema } from '@commonpub/learning';
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
4
5
|
const user = requireAuth(event);
|
|
5
6
|
const db = useDB();
|
|
6
7
|
const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
// Validate body. Strip any client-supplied quizScore/quizPassed (the schema
|
|
10
|
+
// is `.strict()` and only whitelists `answers`) — server is the source of
|
|
11
|
+
// truth for whether a quiz passed. Accept empty body for non-quiz lessons.
|
|
12
|
+
const input = await parseBody(event, completeLessonSchema);
|
|
8
13
|
|
|
9
14
|
const result = await getLessonBySlug(db, slug, lessonSlug);
|
|
10
15
|
if (!result) throw createError({ statusCode: 404, statusMessage: 'Lesson not found' });
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
try {
|
|
18
|
+
return await markLessonComplete(db, user.id, result.lesson.id, input.answers);
|
|
19
|
+
} catch (err: unknown) {
|
|
20
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
21
|
+
if (msg.includes('Quiz lessons require answers')) {
|
|
22
|
+
throw createError({ statusCode: 400, statusMessage: msg });
|
|
23
|
+
}
|
|
24
|
+
if (msg.includes('Not enrolled')) {
|
|
25
|
+
throw createError({ statusCode: 403, statusMessage: msg });
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
13
29
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLessonBySlug } from '@commonpub/server';
|
|
2
2
|
import { renderMarkdown } from '@commonpub/docs';
|
|
3
|
+
import { redactQuizAnswers } from '@commonpub/learning';
|
|
3
4
|
|
|
4
5
|
function blocksToHtml(blocks: unknown): string {
|
|
5
6
|
if (!Array.isArray(blocks)) return '';
|
|
@@ -39,6 +40,7 @@ function blocksToHtml(blocks: unknown): string {
|
|
|
39
40
|
export default defineEventHandler(async (event) => {
|
|
40
41
|
const db = useDB();
|
|
41
42
|
const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
|
|
43
|
+
const user = getOptionalUser(event);
|
|
42
44
|
|
|
43
45
|
const result = await getLessonBySlug(db, slug, lessonSlug);
|
|
44
46
|
if (!result) {
|
|
@@ -64,5 +66,13 @@ export default defineEventHandler(async (event) => {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
// Quiz lessons: strip `correctOptionId` + `explanation` from each question
|
|
70
|
+
// unless the caller is the path author. Without this, anyone enrolled (or
|
|
71
|
+
// anonymous) could fetch the answer key directly from the lesson content.
|
|
72
|
+
const isAuthor = !!user && user.id === result.pathAuthorId;
|
|
73
|
+
const safeLesson = isAuthor
|
|
74
|
+
? result.lesson
|
|
75
|
+
: { ...result.lesson, content: redactQuizAnswers(result.lesson.content as Record<string, unknown>) };
|
|
76
|
+
|
|
77
|
+
return { ...result, lesson: safeLesson, renderedHtml };
|
|
68
78
|
});
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { getUnreadCount, getUnreadMessageCount } from '@commonpub/server';
|
|
1
|
+
import { getUnreadCount, getUnreadMessageCount, subscribeSseEvents } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Unified SSE stream for notification and message counts.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Two delivery paths layered together:
|
|
7
|
+
* 1. Pub/sub — the server emits on a per-user channel whenever a
|
|
8
|
+
* notification or message is written. When `NUXT_REDIS_URL` is set,
|
|
9
|
+
* events cross Nitro processes; without Redis, fanout is in-process
|
|
10
|
+
* only (same behavior as before session 130).
|
|
11
|
+
* 2. Polling — every 30 s we re-query counts as a safety net, so a
|
|
12
|
+
* missed publish (Redis blip, connection drop) resolves itself in
|
|
13
|
+
* one poll tick instead of until the client reconnects.
|
|
14
|
+
*
|
|
15
|
+
* Both paths converge on `sendCounts()`, which fetches fresh counts from
|
|
16
|
+
* the DB. The pub/sub payload is a nudge; we never trust it as the source
|
|
17
|
+
* of truth.
|
|
7
18
|
*/
|
|
8
19
|
export default defineEventHandler(async (event) => {
|
|
9
20
|
const user = requireAuth(event);
|
|
@@ -14,20 +25,43 @@ export default defineEventHandler(async (event) => {
|
|
|
14
25
|
const stream = new ReadableStream({
|
|
15
26
|
async start(controller) {
|
|
16
27
|
let closed = false;
|
|
28
|
+
let unsubscribe: (() => void) | null = null;
|
|
29
|
+
let sending = false;
|
|
30
|
+
let pendingSend = false;
|
|
31
|
+
|
|
17
32
|
function cleanup(): void {
|
|
18
33
|
if (closed) return;
|
|
19
34
|
closed = true;
|
|
20
35
|
clearInterval(interval);
|
|
21
36
|
clearInterval(keepalive);
|
|
37
|
+
if (unsubscribe) {
|
|
38
|
+
try { unsubscribe(); } catch { /* ignore */ }
|
|
39
|
+
unsubscribe = null;
|
|
40
|
+
}
|
|
22
41
|
try { controller.close(); } catch { /* already closed */ }
|
|
23
42
|
}
|
|
24
43
|
|
|
25
44
|
async function sendCounts(): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
if (closed) return;
|
|
46
|
+
// Coalesce overlapping triggers — if a pub/sub event fires while
|
|
47
|
+
// a previous sendCounts is still resolving, set pendingSend and
|
|
48
|
+
// run one more round after the current call returns. Prevents a
|
|
49
|
+
// burst of publishes from piling up N DB queries.
|
|
50
|
+
if (sending) { pendingSend = true; return; }
|
|
51
|
+
sending = true;
|
|
52
|
+
try {
|
|
53
|
+
do {
|
|
54
|
+
pendingSend = false;
|
|
55
|
+
const [notifications, messages] = await Promise.all([
|
|
56
|
+
getUnreadCount(db, userId),
|
|
57
|
+
getUnreadMessageCount(db, userId),
|
|
58
|
+
]);
|
|
59
|
+
if (closed) return;
|
|
60
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
|
|
61
|
+
} while (pendingSend && !closed);
|
|
62
|
+
} finally {
|
|
63
|
+
sending = false;
|
|
64
|
+
}
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
// Send initial counts — if DB is unavailable, send zeros and let polling retry
|
|
@@ -37,14 +71,27 @@ export default defineEventHandler(async (event) => {
|
|
|
37
71
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications: 0, messages: 0 })}\n\n`));
|
|
38
72
|
}
|
|
39
73
|
|
|
40
|
-
//
|
|
74
|
+
// Pub/sub subscription — nudges the send path whenever a
|
|
75
|
+
// notification or message is written for this user.
|
|
76
|
+
try {
|
|
77
|
+
unsubscribe = await subscribeSseEvents(userId, () => {
|
|
78
|
+
sendCounts().catch(() => {});
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
// Pub/sub unavailable — polling alone still works, just slower.
|
|
82
|
+
unsubscribe = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Polling fallback at 30 s (was 10 s). The pub/sub path is the
|
|
86
|
+
// primary delivery mechanism; polling only guards against missed
|
|
87
|
+
// events (Redis restart, subscriber dropped).
|
|
41
88
|
const interval = setInterval(async () => {
|
|
42
89
|
try {
|
|
43
90
|
await sendCounts();
|
|
44
91
|
} catch {
|
|
45
92
|
cleanup();
|
|
46
93
|
}
|
|
47
|
-
},
|
|
94
|
+
}, 30000);
|
|
48
95
|
|
|
49
96
|
// Keepalive every 30 seconds
|
|
50
97
|
const keepalive = setInterval(() => {
|
|
@@ -77,7 +77,7 @@ export default defineEventHandler(async (event) => {
|
|
|
77
77
|
|
|
78
78
|
// Per-key rate limit (separate store from IP-based rate limit so a noisy
|
|
79
79
|
// public-API consumer can't DoS the web UI for their own home IP).
|
|
80
|
-
const rl = apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
|
|
80
|
+
const rl = await apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
|
|
81
81
|
setResponseHeader(event, 'X-RateLimit-Limit', String(rl.limit));
|
|
82
82
|
setResponseHeader(event, 'X-RateLimit-Remaining', String(rl.remaining));
|
|
83
83
|
setResponseHeader(event, 'X-RateLimit-Reset', String(rl.resetAt));
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// Security middleware — rate limiting + security headers + CSP
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
// Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
|
|
5
|
+
// in-process memory store. Unset env = byte-identical behavior to pre-0.6.
|
|
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
|
+
});
|
|
5
13
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
6
14
|
|
|
7
|
-
export default defineEventHandler((event) => {
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
8
16
|
const url = getRequestURL(event);
|
|
9
17
|
const pathname = url.pathname;
|
|
10
18
|
|
|
@@ -18,7 +26,7 @@ export default defineEventHandler((event) => {
|
|
|
18
26
|
|| 'unknown';
|
|
19
27
|
|
|
20
28
|
const userId = event.context.auth?.user?.id as string | undefined;
|
|
21
|
-
const { result, headers: rlHeaders } = checkRateLimit(store, ip, pathname, userId);
|
|
29
|
+
const { result, headers: rlHeaders } = await checkRateLimit(store, ip, pathname, userId);
|
|
22
30
|
|
|
23
31
|
for (const [key, value] of Object.entries(rlHeaders)) {
|
|
24
32
|
setResponseHeader(event, key, value);
|
|
@@ -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, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''');
|
|
25
|
+
// Restore only bare <b> / </b> (no attributes, no whitespace variants).
|
|
26
|
+
return escaped
|
|
27
|
+
.replace(/<b>/g, '<b>')
|
|
28
|
+
.replace(/<\/b>/g, '</b>');
|
|
29
|
+
}
|