@commonpub/layer 0.74.0 → 0.75.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.
@@ -11,16 +11,27 @@
11
11
  */
12
12
  import { markdownToBlockTuples } from '@commonpub/editor';
13
13
  import type { BlockTuple } from '@commonpub/editor';
14
+ import { sanitizeRichHtml } from '../composables/useSanitize';
14
15
 
15
16
  const props = defineProps<{
16
17
  /** Markdown source (may contain inline/block HTML — passed through). */
17
18
  source?: string | null;
19
+ /**
20
+ * Render mode. `markdown` (default) runs the Markdown pipeline; `html` renders
21
+ * the source as the author's raw HTML through the permissive (script-free)
22
+ * sanitizer. The HTML path is also cheaper, so large bodies render instantly
23
+ * (no synchronous Markdown parse) on both SSR and client.
24
+ */
25
+ format?: 'markdown' | 'html' | null;
18
26
  }>();
19
27
 
20
28
  const trimmed = computed(() => (props.source ?? '').trim());
29
+ const isHtml = computed(() => props.format === 'html');
30
+
31
+ const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value) : ''));
21
32
 
22
33
  const blocks = computed<BlockTuple[]>(() => {
23
- if (!trimmed.value) return [];
34
+ if (isHtml.value || !trimmed.value) return [];
24
35
  try {
25
36
  return markdownToBlockTuples(trimmed.value);
26
37
  } catch {
@@ -30,8 +41,11 @@ const blocks = computed<BlockTuple[]>(() => {
30
41
  </script>
31
42
 
32
43
  <template>
44
+ <!-- eslint-disable vue/no-v-html -- author HTML, sanitized via sanitizeRichHtml (script-free allowlist) -->
45
+ <div v-if="isHtml && richHtml" class="cpub-md cpub-md-html" v-html="richHtml" />
46
+ <!-- eslint-enable vue/no-v-html -->
33
47
  <BlocksBlockContentRenderer
34
- v-if="blocks.length"
48
+ v-else-if="blocks.length"
35
49
  :blocks="(blocks as [string, Record<string, unknown>][])"
36
50
  class="cpub-prose cpub-md"
37
51
  />
@@ -2,8 +2,9 @@
2
2
  interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
3
3
  defineProps<{
4
4
  prizes: Prize[];
5
- /** Optional Markdown intro shown above the prize cards (section-level, not per-prize). */
5
+ /** Optional intro shown above the prize cards (section-level, not per-prize). */
6
6
  description?: string | null;
7
+ format?: 'markdown' | 'html' | null;
7
8
  }>();
8
9
 
9
10
  function prizeLabel(prize: Prize): string {
@@ -37,7 +38,7 @@ function prizeIcon(prize: Prize): string {
37
38
  <div class="cpub-sec-head">
38
39
  <h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
39
40
  </div>
40
- <CpubMarkdown v-if="description" :source="description" class="cpub-prizes-intro" />
41
+ <CpubMarkdown v-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
41
42
  <div v-if="prizes.length" class="cpub-prize-grid">
42
43
  <div
43
44
  v-for="(prize, i) in prizes"
@@ -8,6 +8,7 @@
8
8
  */
9
9
  defineProps<{
10
10
  rules: string;
11
+ format?: 'markdown' | 'html' | null;
11
12
  }>();
12
13
  </script>
13
14
 
@@ -17,7 +18,7 @@ defineProps<{
17
18
  <h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
18
19
  </div>
19
20
  <div class="cpub-rules-card">
20
- <CpubMarkdown :source="rules" />
21
+ <CpubMarkdown :source="rules" :format="format" />
21
22
  </div>
22
23
  </div>
23
24
  </template>
@@ -115,7 +115,127 @@ export function sanitizeBlockHtml(html: string): string {
115
115
  return result;
116
116
  }
117
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Rich HTML mode (the opt-in "Full HTML" content format, e.g. contest pages).
120
+ //
121
+ // A deliberately PERMISSIVE but still DEFAULT-DENY allowlist: it renders an
122
+ // author's presentational HTML verbatim — layout tags, inline CSS, SVG icons —
123
+ // while never permitting script execution. Anything not on the allowlist is
124
+ // dropped, so `<script>`, `<iframe>`, `<object>`, `<style>`, `on*` handlers and
125
+ // `javascript:` URLs cannot get through. Authoring is gated to trusted
126
+ // (staff/admin) contest creators and is opt-in per contest. Tag/attr NAME case
127
+ // is preserved on output so case-sensitive SVG names (viewBox, linearGradient)
128
+ // survive. The `style` attribute is allowed but its value is scrubbed of the
129
+ // exfil/script vectors (url(), expression(), javascript:, @import, behavior).
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const RICH_ALLOWED_ELEMENTS = new Set([
133
+ 'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav', 'figure', 'figcaption', 'address',
134
+ 'p', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
135
+ 'blockquote', 'pre', 'code', 'kbd', 'samp', 'var', 'cite', 'q', 'wbr', 'small', 'mark', 'abbr', 'time', 'sub', 'sup',
136
+ 'em', 'strong', 'b', 'i', 'u', 's', 'del', 'ins',
137
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
138
+ 'a', 'img', 'picture', 'source',
139
+ 'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
140
+ 'details', 'summary', 'button',
141
+ // SVG (lowercased for the allowlist check; original case preserved on output)
142
+ 'svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'defs',
143
+ 'lineargradient', 'radialgradient', 'stop', 'text', 'tspan', 'use', 'symbol', 'clippath', 'mask', 'pattern', 'marker',
144
+ ]);
145
+
146
+ // Attributes allowed on any rich element (lowercased). `aria-*`/`data-*` are
147
+ // allowed by prefix; per-tag extras and `style` are handled separately.
148
+ const RICH_GLOBAL_ATTRS = new Set([
149
+ 'class', 'id', 'title', 'role', 'lang', 'dir', 'slot', 'tabindex', 'hidden', 'datetime', 'open',
150
+ // SVG presentation
151
+ 'viewbox', 'xmlns', 'version', 'preserveaspectratio', 'transform', 'opacity',
152
+ 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
153
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit', 'clip-rule', 'clip-path', 'mask',
154
+ 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'd', 'points', 'width', 'height',
155
+ 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', 'spreadmethod', 'patternunits',
156
+ 'text-anchor', 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'letter-spacing',
157
+ 'marker-start', 'marker-mid', 'marker-end',
158
+ ]);
159
+
160
+ const RICH_PER_TAG_ATTRS: Record<string, Set<string>> = {
161
+ a: new Set(['href', 'rel', 'target', 'name']),
162
+ img: new Set(['src', 'alt', 'loading', 'decoding']),
163
+ source: new Set(['type', 'media', 'sizes']),
164
+ td: new Set(['colspan', 'rowspan', 'headers']),
165
+ th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
166
+ ol: new Set(['start', 'type', 'reversed']),
167
+ col: new Set(['span']),
168
+ colgroup: new Set(['span']),
169
+ button: new Set(['type']),
170
+ use: new Set(['href']),
171
+ };
172
+
173
+ const RICH_URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
174
+ // CSS constructs that can fetch, exfiltrate, or execute — stripped per-declaration.
175
+ const STYLE_DECL_BLOCKLIST = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i;
176
+
177
+ function sanitizeStyleAttr(value: string): string {
178
+ return value
179
+ .split(';')
180
+ .filter((decl) => decl.trim() && !STYLE_DECL_BLOCKLIST.test(decl))
181
+ .join(';');
182
+ }
183
+
184
+ /** Sanitize author HTML for "full HTML" rendering — permissive but script-free. */
185
+ export function sanitizeRichHtml(html: string): string {
186
+ if (!html || typeof html !== 'string') return '';
187
+
188
+ let result = html.replace(/<!--[\s\S]*?-->/g, '');
189
+
190
+ // Remove raw-text / active elements WITH their contents, so script/style/embed
191
+ // bodies don't leak through as visible text or execute. The tag pass below
192
+ // also drops these by allowlist, but only the open/close tags — this strips
193
+ // the inner payload (e.g. `<script>code</script>` → '' rather than `code`).
194
+ result = result.replace(/<(script|style|noscript|template|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
195
+
196
+ result = result.replace(/<\/?([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)?\/?>/g, (match, rawTag: string, attrs?: string) => {
197
+ const tag = rawTag.toLowerCase();
198
+ if (!RICH_ALLOWED_ELEMENTS.has(tag)) return '';
199
+ if (match.startsWith('</')) return `</${rawTag}>`;
200
+
201
+ const safeAttrs: string[] = [];
202
+ if (attrs) {
203
+ const attrRegex = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
204
+ let attrMatch: RegExpExecArray | null;
205
+ while ((attrMatch = attrRegex.exec(attrs)) !== null) {
206
+ const rawName = attrMatch[1]!;
207
+ const name = rawName.toLowerCase();
208
+ const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
209
+
210
+ if (name.startsWith('on')) continue; // event handlers never allowed
211
+ const allowed =
212
+ name === 'style' ||
213
+ RICH_GLOBAL_ATTRS.has(name) ||
214
+ name.startsWith('aria-') ||
215
+ name.startsWith('data-') ||
216
+ (RICH_PER_TAG_ATTRS[tag]?.has(name) ?? false);
217
+ if (!allowed) continue;
218
+
219
+ if (RICH_URL_ATTRS.has(name) && !isSafeUrl(value)) continue;
220
+
221
+ if (name === 'style') {
222
+ const cleaned = sanitizeStyleAttr(value);
223
+ if (cleaned) safeAttrs.push(`style="${escapeAttrValue(cleaned)}"`);
224
+ continue;
225
+ }
226
+ safeAttrs.push(`${rawName}="${escapeAttrValue(value)}"`);
227
+ }
228
+ }
229
+
230
+ const attrsStr = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
231
+ const selfClose = match.endsWith('/>') ? ' /' : '';
232
+ return `<${rawTag}${attrsStr}${selfClose}>`;
233
+ });
234
+
235
+ return result;
236
+ }
237
+
118
238
  /** Composable wrapper for template use */
119
- export function useSanitize(): { sanitize: (html: string) => string } {
120
- return { sanitize: sanitizeBlockHtml };
239
+ export function useSanitize(): { sanitize: (html: string) => string; sanitizeRich: (html: string) => string } {
240
+ return { sanitize: sanitizeBlockHtml, sanitizeRich: sanitizeRichHtml };
121
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.74.0",
3
+ "version": "0.75.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,17 +53,17 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/config": "0.22.1",
57
- "@commonpub/protocol": "0.13.0",
58
- "@commonpub/explainer": "0.7.15",
59
- "@commonpub/schema": "0.41.0",
60
- "@commonpub/learning": "0.5.2",
61
- "@commonpub/ui": "0.13.1",
62
- "@commonpub/server": "2.84.1",
63
56
  "@commonpub/auth": "0.8.0",
64
57
  "@commonpub/docs": "0.6.3",
58
+ "@commonpub/learning": "0.5.2",
59
+ "@commonpub/server": "2.85.0",
65
60
  "@commonpub/theme-studio": "0.6.1",
66
- "@commonpub/editor": "0.7.12"
61
+ "@commonpub/config": "0.22.1",
62
+ "@commonpub/explainer": "0.7.15",
63
+ "@commonpub/schema": "0.42.0",
64
+ "@commonpub/ui": "0.13.1",
65
+ "@commonpub/editor": "0.7.12",
66
+ "@commonpub/protocol": "0.13.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@testing-library/jest-dom": "^6.9.1",
@@ -29,6 +29,8 @@ function slugify(s: string): string {
29
29
  const subheading = ref('');
30
30
  const description = ref('');
31
31
  const rules = ref('');
32
+ // Render mode for description/rules/prizes overview: Markdown (default) or raw HTML.
33
+ const contentFormat = ref<'markdown' | 'html'>('markdown');
32
34
  const bannerUrl = ref('');
33
35
  const coverImageUrl = ref('');
34
36
  const startDate = ref('');
@@ -86,6 +88,7 @@ watch(contest, (c) => {
86
88
  subheading.value = c.subheading ?? '';
87
89
  description.value = c.description ?? '';
88
90
  rules.value = c.rules ?? '';
91
+ contentFormat.value = (c.contentFormat as 'markdown' | 'html') ?? 'markdown';
89
92
  bannerUrl.value = c.bannerUrl ?? '';
90
93
  coverImageUrl.value = c.coverImageUrl ?? '';
91
94
  startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
@@ -124,7 +127,7 @@ watch(contest, (c) => {
124
127
  // Mark the form dirty on any post-hydration edit (gives the save bar its
125
128
  // "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
126
129
  watch(
127
- [title, slugInput, subheading, description, rules, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
130
+ [title, slugInput, subheading, description, rules, contentFormat, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
128
131
  communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
129
132
  showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
130
133
  () => { if (!hydratingForm) formDirty.value = true; },
@@ -195,6 +198,7 @@ async function handleSave(): Promise<void> {
195
198
  subheading: subheading.value || undefined,
196
199
  description: description.value || undefined,
197
200
  rules: rules.value || undefined,
201
+ contentFormat: contentFormat.value,
198
202
  bannerUrl: bannerUrl.value || undefined,
199
203
  coverImageUrl: coverImageUrl.value || undefined,
200
204
  startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
@@ -347,10 +351,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
347
351
  <input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
348
352
  <p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
349
353
  </div>
354
+ <div class="cpub-form-field">
355
+ <label class="cpub-form-label">Content format</label>
356
+ <div class="cpub-type-options" role="radiogroup" aria-label="Content format">
357
+ <label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="markdown" /> <span>Markdown</span></label>
358
+ <label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="html" /> <span>Full HTML</span></label>
359
+ </div>
360
+ <p class="cpub-form-hint">
361
+ Applies to Description, Rules, and the Prizes overview. <strong>Markdown</strong> supports headings, lists, links, and safe inline HTML.
362
+ <strong>Full HTML</strong> renders your raw HTML, CSS, and SVG as-is (scripts and event handlers are removed for safety).
363
+ </p>
364
+ </div>
350
365
  <div class="cpub-form-field">
351
366
  <label class="cpub-form-label">Description</label>
352
367
  <textarea v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" />
353
- <p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
368
+ <p class="cpub-form-hint">{{ contentFormat === 'html' ? 'Rendered as full HTML/CSS/SVG.' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
354
369
  </div>
355
370
  <div class="cpub-form-field">
356
371
  <label class="cpub-form-label">Rules</label>
@@ -7,7 +7,9 @@ const toast = useToast();
7
7
  const { extract: extractError } = useApiError();
8
8
  const { isAuthenticated, isAdmin, user } = useAuth();
9
9
 
10
- const { data: contest } = useLazyFetch(`/api/contests/${slug}`);
10
+ // Blocking fetch (not lazy) so the description/rules render server-side and are
11
+ // present on first paint — no empty flash while the client fetches + renders.
12
+ const { data: contest } = await useFetch(`/api/contests/${slug}`);
11
13
  const { data: apiEntriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
12
14
  const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
13
15
 
@@ -310,7 +312,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
310
312
  <div class="cpub-about-section">
311
313
  <div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
312
314
  <div class="cpub-about-card">
313
- <CpubMarkdown v-if="c?.description" :source="c.description" />
315
+ <CpubMarkdown v-if="c?.description" :source="c.description" :format="c?.contentFormat" />
314
316
  <p v-else>No description available for this contest.</p>
315
317
  </div>
316
318
  </div>
@@ -319,12 +321,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
319
321
 
320
322
  <!-- RULES -->
321
323
  <div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
322
- <ContestRules v-if="c?.rules" :rules="c.rules" />
324
+ <ContestRules v-if="c?.rules" :rules="c.rules" :format="c?.contentFormat" />
323
325
  </div>
324
326
 
325
327
  <!-- PRIZES -->
326
328
  <div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
327
- <ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
329
+ <ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" :format="c?.contentFormat" />
328
330
  </div>
329
331
 
330
332
  <!-- ENTRIES -->
@@ -20,6 +20,7 @@ watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
20
20
  const subheading = ref('');
21
21
  const description = ref('');
22
22
  const rules = ref('');
23
+ const contentFormat = ref<'markdown' | 'html'>('markdown');
23
24
  const bannerUrl = ref('');
24
25
  const coverImageUrl = ref('');
25
26
  const startDate = ref('');
@@ -110,6 +111,7 @@ async function handleCreate(): Promise<void> {
110
111
  subheading: subheading.value || undefined,
111
112
  description: description.value || undefined,
112
113
  rules: rules.value || undefined,
114
+ contentFormat: contentFormat.value,
113
115
  bannerUrl: bannerUrl.value || undefined,
114
116
  coverImageUrl: coverImageUrl.value || undefined,
115
117
  startDate: new Date(startDate.value).toISOString(),
@@ -185,10 +187,21 @@ function prizeLabel(prize: Prize): string {
185
187
  <input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
186
188
  <p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
187
189
  </div>
190
+ <div class="cpub-form-field">
191
+ <label class="cpub-form-label">Content format</label>
192
+ <div class="cpub-type-options" role="radiogroup" aria-label="Content format">
193
+ <label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="markdown" /> <span>Markdown</span></label>
194
+ <label class="cpub-form-check"><input v-model="contentFormat" type="radio" value="html" /> <span>Full HTML</span></label>
195
+ </div>
196
+ <p class="cpub-form-hint">
197
+ Applies to Description, Rules, and the Prizes overview. <strong>Markdown</strong> supports headings, lists, links, and safe inline HTML.
198
+ <strong>Full HTML</strong> renders your raw HTML, CSS, and SVG as-is (scripts and event handlers are removed for safety).
199
+ </p>
200
+ </div>
188
201
  <div class="cpub-form-field">
189
202
  <label for="contest-desc" class="cpub-form-label">Description</label>
190
203
  <textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
191
- <p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
204
+ <p class="cpub-form-hint">{{ contentFormat === 'html' ? 'Rendered as full HTML/CSS/SVG.' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
192
205
  </div>
193
206
  <div class="cpub-form-field">
194
207
  <label for="contest-rules" class="cpub-form-label">Rules</label>