feat(questions): resources content block (markdown question type)

Adds a non-answerable 'resources' question type so admins can place
a markdown content block anywhere in the questions array. Renders
inline on /f/<slug> as headings, lists, and links; no answer is
collected, no CSV column, no stats. Admin Results tab filters
resources out so it never shows an empty results card.

Shape per Phase 2 registry: lib/questions/resources.ts + .input.svelte
(participant render via shared lib/markdown.ts helper) + .builder.svelte
(textarea for authoring) + .results.svelte (placeholder so the registry
slot is consistent). Schema discriminated union picks up the new type
via lib/schemas.ts + registry.ts.

Description on /f/<slug> now routes through the same renderMarkdown()
helper instead of its inlined copy.

Motivation: HL PA UPC Deadlines training 2026-05-28 — m wanted the
resources block to be its own positionable thing inside the form,
not crammed into the description above the title.
This commit is contained in:
mAi
2026-05-27 21:34:12 +02:00
parent 288dfb31e8
commit 6e334fa0f1
11 changed files with 213 additions and 9 deletions

1
.worktrees/iris Submodule

Submodule .worktrees/iris added at b49f2e0dcc

View File

@@ -14,7 +14,7 @@
{#if results.total_submissions === 0}
<div class="fb-results__empty">Noch keine Antworten.</div>
{:else}
{#each results.questions as q (q.id)}
{#each results.questions.filter((q) => q.type !== 'resources') as q (q.id)}
{@const Block = getQuestion(q.type).ResultsBlock}
<div class="fb-results__q">
<div class="fb-results__label">{q.label}</div>

6
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,6 @@
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
export function renderMarkdown(src: string): string {
return DOMPurify.sanitize(marked.parse(src, { breaks: true, gfm: true, async: false }) as string);
}

View File

@@ -18,6 +18,7 @@ import { ScaleQuestion } from './scale';
import { SingleChoiceQuestion } from './single_choice';
import { MultiChoiceQuestion } from './multi_choice';
import { DateRankedChoiceQuestion } from './date_ranked_choice';
import { ResourcesQuestion } from './resources';
// Order matters — drives the FormBuilder "+ Add" picker layout.
// The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
@@ -31,6 +32,7 @@ export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
ScaleQuestion as AnyQuestionTypeModule,
BooleanQuestion as AnyQuestionTypeModule,
DateRankedChoiceQuestion as AnyQuestionTypeModule,
ResourcesQuestion as AnyQuestionTypeModule,
];
/** Look up the module for a question type. Throws on unknown — every type

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
const md = $derived(question.type === 'resources' ? (question.markdown ?? '') : '');
</script>
<label class="fb-field">
<span class="fb-field__label">Inhalt (Markdown)</span>
<textarea
class="fb-input fb-input--ta"
rows="10"
placeholder="### Section heading&#10;- [Label](https://example.com)"
value={md}
oninput={(e) => update({ markdown: (e.target as HTMLTextAreaElement).value })}
></textarea>
<span class="fb-field__help">
Markdown wird mit Headings, Bullet-Listen und Links gerendert.
</span>
</label>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
import { renderMarkdown } from '$lib/markdown';
let { question }: ParticipantInputProps = $props();
const md = $derived(question.type === 'resources' ? (question.markdown ?? '') : '');
</script>
{#if md}
<div class="fb-resources">{@html renderMarkdown(md)}</div>
{/if}
<style>
.fb-resources :global(h1),
.fb-resources :global(h2),
.fb-resources :global(h3) {
margin: 1rem 0 0.4rem;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
color: var(--color-text-primary);
letter-spacing: -0.005em;
}
.fb-resources :global(h4),
.fb-resources :global(h5),
.fb-resources :global(h6) {
margin: 0.8rem 0 0.3rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.fb-resources :global(p) {
margin: 0 0 0.6rem;
color: var(--color-text-secondary);
line-height: 1.55;
}
.fb-resources :global(ul),
.fb-resources :global(ol) {
margin: 0.25rem 0 0.6rem;
padding-left: 1.4rem;
}
.fb-resources :global(ul) {
list-style: disc;
}
.fb-resources :global(ol) {
list-style: decimal;
}
.fb-resources :global(li) {
margin: 0.2rem 0;
color: var(--color-text-secondary);
line-height: 1.55;
}
.fb-resources :global(li > p) {
margin: 0;
}
.fb-resources :global(a) {
color: var(--color-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.fb-resources :global(a:hover) {
color: var(--color-primary-hover);
border-bottom-color: currentColor;
}
.fb-resources :global(a:focus-visible) {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
border-radius: 2px;
}
.fb-resources :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
background: var(--color-bg-tertiary);
padding: 0.1em 0.35em;
border-radius: 3px;
}
.fb-resources :global(> :first-child) {
margin-top: 0;
}
.fb-resources :global(> :last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
// Content block — no answers, no stats, no admin Results rendering.
// Results.svelte filters resources out, so this component is a placeholder
// the registry can still point at.
let { question: _q, stats: _s }: ResultsBlockProps = $props();
</script>

View File

@@ -0,0 +1,72 @@
/**
* `resources` — non-answerable content block. Renders author-provided
* markdown inline among questions on the participant page. No answer is
* collected, no stats, no CSV column. Authors place it anywhere in the
* questions array to insert a Resources / Links / context section.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import ResourcesInput from './resources.input.svelte';
import ResourcesBuilder from './resources.builder.svelte';
import ResourcesResults from './resources.results.svelte';
export const ResourcesQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('resources'),
markdown: z.string().max(5000),
});
type Q = z.infer<typeof ResourcesQuestionSchema>;
export const ResourcesQuestion: QuestionTypeModule<'resources'> = {
type: 'resources',
label: 'Resources / Markdown block',
schema: ResourcesQuestionSchema,
defaultStub() {
return {
id: 'r1',
label: 'Resources',
type: 'resources',
markdown: '### Resources\n\n- [Link](https://example.com)',
};
},
isAnswerEmpty(_q: Q, _answer: unknown): boolean {
// Content blocks never have an answer — treat as always empty so
// required-checking and aggregation skip them naturally.
return true;
},
emptyStats() {
return { type: 'resources', count: 0 };
},
ingest() {
// No-op — no answers to fold.
},
finalise() {
// No-op.
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(_q: Q): CsvColumn[] {
return [];
},
csvCellFor() {
return '';
},
adminCellSummary() {
return '';
},
ParticipantInput: ResourcesInput,
BuilderEditor: ResourcesBuilder,
ResultsBlock: ResourcesResults,
};

View File

@@ -20,6 +20,7 @@ import {
DateRankedChoiceQuestionSchema,
DateRankedOptionSchema as PerTypeDateRankedOptionSchema,
} from './questions/date_ranked_choice';
import { ResourcesQuestionSchema } from './questions/resources';
/** Re-exported for callers that need the option shape standalone. */
export const DateRankedOptionSchema = PerTypeDateRankedOptionSchema;
@@ -32,6 +33,7 @@ export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
ScaleQuestionSchema,
BooleanQuestionSchema,
DateRankedChoiceQuestionSchema,
ResourcesQuestionSchema,
]);
/** Version stamp like `0.260505` (YYMMDD) or `0.260505.b` for same-day re-edits. */

View File

@@ -58,7 +58,18 @@ export interface DateRankedChoiceStats {
options: DateRankedOptionStats[];
}
export type QuestionStats = ScaleStats | ChoiceStats | BooleanStats | TextStats | DateRankedChoiceStats;
export interface ResourcesStats {
type: 'resources';
count: 0;
}
export type QuestionStats =
| ScaleStats
| ChoiceStats
| BooleanStats
| TextStats
| DateRankedChoiceStats
| ResourcesStats;
export interface QuestionResult {
id: string;

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
import { renderMarkdown } from '$lib/markdown';
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas';
import type { AggregatedResults } from '$lib/server/results';
import { getQuestion } from '$lib/questions/registry';
@@ -10,10 +9,6 @@
let { data }: { data: PageData } = $props();
function renderDescription(src: string): string {
return DOMPurify.sanitize(marked.parse(src, { breaks: true, gfm: true, async: false }));
}
const isClosed = data.status === 'closed';
const formDef = data.form_definition as FeedbackFormDefinition | null;
const chatEnabled = data.chat_enabled;
@@ -508,7 +503,7 @@
<header class="fb-header">
<h1>{data.title}</h1>
{#if data.description}
<div class="fb-description">{@html renderDescription(data.description)}</div>
<div class="fb-description">{@html renderMarkdown(data.description)}</div>
{/if}
</header>