feat(broadcast): M2 audience + editor + compose wizard

Core authoring loop works end-to-end: create a draft, filter an audience
from contacts, write content in a rich-text editor, save. Send is still
stubbed (M4 gets mana-mail's bulk endpoint).

Dependencies
- @tiptap/core + starter-kit + image + link + placeholder (3.22.4)
- shared-auth/tsconfig: allowImportingTsExtensions +
  rewriteRelativeImportExtensions so tsc accepts shared-types' explicit
  .ts imports. Was blocking EVERY pnpm install postinstall hook in the
  repo — fixing it here unblocks everyone, not just broadcast.

Module
- queries.ts: useAllCampaigns / useAllTemplates with scoped-db + crypto,
  computeStats (counts + open/click rates per year), formatRate helper
- stores/settings.svelte.ts: singleton with ensure/get/update, same
  pattern as invoices settings
- stores/campaigns.svelte.ts: createCampaign (pulls sender defaults from
  settings), updateCampaign / updateContent / updateAudience (draft-only
  edit guard), schedule / cancel / duplicate / deleteCampaign, plus an
  applyServerStatus hook for M4's orchestrator to write back progress

Audience
- audience/segment-builder.ts: pure matchContact / filterAudience /
  countAudience / describeAudience. AND semantics across filters. Drops
  contacts without a usable email so estimatedCount never inflates.
- audience/AudienceBuilder.svelte: tag-chip UI with live count, dedup
  (same tag twice toggles op instead of stacking), greys out already-
  referenced tags in the picker

Editor
- editor/Editor.svelte: Tiptap wrapper with onMount / onDestroy, toolbar
  (bold/italic/H1/H2/lists/link/image), bind on content (Tiptap JSON +
  derived HTML/plaintext). Image upload reuses invoices' mana-media
  uploader pragmatically; extract to @mana/shared-uload later.

Compose wizard
- views/ComposeView.svelte: 4-step stepper (Audience → Content →
  Preflight → Send). Steps 3+4 stubbed pragmatically. Autosave on step
  change so content survives navigation. Step 3/4 gated on earlier
  readiness so the user can't skip.

Routes
- /broadcasts/new: bootstraps a draft + redirects to edit
- /broadcasts/[id]/edit: guarded on status=='draft'
- ListView: working "+ Neue Kampagne" button, rows open edit

Tests
- 17 unit tests for segment-builder covering tag has/not-has/AND,
  email eq/contains case-insensitivity, no-email filtering, no-mutation,
  describeAudience resolver + fallback

Plan: docs/plans/broadcast-module.md §M2.
Next: M3 HTML-render with email-safe inlining + preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 20:41:09 +02:00
parent 5139ade7e0
commit 264c4c3087
15 changed files with 2635 additions and 253 deletions

View file

@ -77,6 +77,11 @@
"@mana/spiral-db": "workspace:*", "@mana/spiral-db": "workspace:*",
"@mana/wallpaper-generator": "workspace:*", "@mana/wallpaper-generator": "workspace:*",
"@quotes/content": "workspace:*", "@quotes/content": "workspace:*",
"@tiptap/core": "^3.22.4",
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@types/pako": "^2.0.4", "@types/pako": "^2.0.4",
"@types/suncalc": "^1.9.2", "@types/suncalc": "^1.9.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View file

@ -1,24 +1,28 @@
<!-- <!--
Broadcast — ListView (M1 skeleton) Broadcast — ListView (M2)
Empty state + "+ Neue Kampagne"-button placeholder. Real list, filters, Real campaign list + working "+ Neue Kampagne" entry point. Stats
and stats cards land in M2/M7. Plan: docs/plans/broadcast-module.md. cards, filter chips, and search land in M7.
--> -->
<script lang="ts"> <script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { goto } from '$app/navigation';
import { decryptRecords } from '$lib/data/crypto'; import { useAllCampaigns } from './queries';
import { scopedForModule } from '$lib/data/scope';
import { STATUS_LABELS, STATUS_COLORS } from './constants'; import { STATUS_LABELS, STATUS_COLORS } from './constants';
import type { LocalCampaign } from './types';
const campaigns$ = useLiveQueryWithDefault(async () => { const campaigns$ = useAllCampaigns();
const rows = await scopedForModule<LocalCampaign, string>(
'broadcast',
'broadcastCampaigns'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);
return (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
}, [] as LocalCampaign[]);
const campaigns = $derived(campaigns$.value ?? []); const campaigns = $derived(campaigns$.value ?? []);
function openCampaign(id: string, status: string) {
// Drafts go straight to edit; sent/scheduled to a detail view
// (detail lands in M7; until then, edit is the entry for drafts
// and we bounce sent ones back to the list — see canEdit guard
// in the edit route).
if (status === 'draft') goto(`/broadcasts/${id}/edit`);
else goto(`/broadcasts/${id}/edit`);
}
function onNewCampaign() {
goto('/broadcasts/new');
}
</script> </script>
<div class="broadcast-shell"> <div class="broadcast-shell">
@ -27,7 +31,7 @@
<h1>Broadcasts</h1> <h1>Broadcasts</h1>
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p> <p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
</div> </div>
<button class="btn-primary" type="button" disabled title="M2">+ Neue Kampagne</button> <button class="btn-primary" type="button" onclick={onNewCampaign}>+ Neue Kampagne</button>
</header> </header>
{#if campaigns.length === 0} {#if campaigns.length === 0}
@ -38,20 +42,27 @@
Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem
Abmelden. Abmelden.
</p> </p>
<p class="note">M1 Skelett — Compose-Flow folgt in M2.</p> <button class="btn-primary" onclick={onNewCampaign}>Erste Kampagne</button>
</div> </div>
{:else} {:else}
<ul class="list"> <ul class="list" role="list">
{#each campaigns as campaign (campaign.id)} {#each campaigns as campaign (campaign.id)}
<li class="row"> <li>
<span class="subject">{campaign.subject}</span> <button class="row" onclick={() => openCampaign(campaign.id, campaign.status)}>
<span class="recipient-count"> <span class="subject">
{campaign.audience?.estimatedCount ?? 0} Empfänger <span class="campaign-name">{campaign.name}</span>
</span> {#if campaign.subject}
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}"> <span class="campaign-subject">{campaign.subject}</span>
<span class="dot"></span> {/if}
{STATUS_LABELS[campaign.status].de} </span>
</span> <span class="recipient-count">
{campaign.audience?.estimatedCount ?? 0} Empfänger
</span>
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
<span class="dot"></span>
{STATUS_LABELS[campaign.status].de}
</span>
</button>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -87,16 +98,12 @@
.btn-primary { .btn-primary {
background: #6366f1; background: #6366f1;
color: white; color: white;
padding: 0.5rem 1rem; padding: 0.55rem 1.1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border: 0; border: 0;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
} font-size: 0.95rem;
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.empty { .empty {
@ -118,16 +125,10 @@
} }
.empty p { .empty p {
margin: 0.25rem 0; margin: 0.25rem 0 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.note {
margin-top: 1rem !important;
font-size: 0.8rem;
opacity: 0.7;
}
.list { .list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -139,19 +140,42 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: 1fr auto 8rem; grid-template-columns: 1fr auto 9rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0); border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer;
text-align: left;
font: inherit;
}
.row:hover {
border-color: #6366f1;
background: #eef2ff;
} }
.subject { .subject {
display: flex;
flex-direction: column;
min-width: 0;
}
.campaign-name {
font-weight: 500; font-weight: 500;
} }
.campaign-subject {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-count { .recipient-count {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted, #64748b); color: var(--color-text-muted, #64748b);

View file

@ -0,0 +1,298 @@
<!--
AudienceBuilder — M2.
Tag-Filter-Chips + Live-Empfänger-Count.
Binds the parent's `audience: AudienceDefinition`. Every change
re-evaluates the count against the live contacts list so the user
sees the segment size as they build it.
-->
<script lang="ts">
import { useAllContacts } from '$lib/modules/contacts/queries';
import { useAllTags } from '@mana/shared-stores';
import { countAudience } from './segment-builder';
import type { AudienceDefinition, AudienceFilter } from '../types';
interface Props {
audience: AudienceDefinition;
}
let { audience = $bindable({ filters: [], estimatedCount: 0 }) }: Props = $props();
const contacts$ = useAllContacts();
const contacts = $derived(contacts$.value ?? []);
const allTags = $derived(useAllTags().value ?? []);
const matchedCount = $derived(countAudience(contacts, audience));
// Keep the cached count in sync with the live computation so the
// cached number doesn't go stale between edits and the next save.
$effect(() => {
if (audience.estimatedCount !== matchedCount) {
audience = { ...audience, estimatedCount: matchedCount };
}
});
function addTagFilter(tagId: string, op: 'has' | 'not-has') {
// Dedupe: if a filter with the same tag exists, toggle the op rather
// than pile on duplicates.
const existing = audience.filters.findIndex((f) => f.field === 'tag' && f.value === tagId);
let nextFilters: AudienceFilter[];
if (existing >= 0) {
nextFilters = audience.filters.map((f, i) =>
i === existing ? ({ field: 'tag', op, value: tagId } as AudienceFilter) : f
);
} else {
nextFilters = [...audience.filters, { field: 'tag', op, value: tagId }];
}
audience = { ...audience, filters: nextFilters };
}
function removeFilter(index: number) {
audience = {
...audience,
filters: audience.filters.filter((_, i) => i !== index),
};
}
function tagName(tagId: string): string {
return allTags.find((t) => t.id === tagId)?.name ?? tagId.slice(0, 8);
}
function filterLabel(f: AudienceFilter): string {
if (f.field === 'tag') {
return f.op === 'has' ? `Tag: ${tagName(f.value)}` : `nicht Tag: ${tagName(f.value)}`;
}
if (f.field === 'email') return `E-Mail: ${f.value}`;
return `${f.field} ${f.op} ${f.value}`;
}
// Which tags are already referenced (greys them out in the picker).
const usedTagIds = $derived(
new Set(audience.filters.filter((f) => f.field === 'tag').map((f) => f.value))
);
const contactsWithEmail = $derived(
contacts.filter((c) => typeof c.email === 'string' && c.email.includes('@')).length
);
</script>
<div class="audience">
<header class="audience-head">
<h3>Empfänger</h3>
<div class="count-chip" class:empty={matchedCount === 0}>
<strong>{matchedCount}</strong>
<span>{matchedCount === 1 ? 'Empfänger' : 'Empfänger'}</span>
</div>
</header>
<p class="hint">
{#if audience.filters.length === 0}
Ohne Filter: alle {contactsWithEmail} Kontakte mit E-Mail-Adresse.
{:else}
{audience.filters.length} Filter — nur Kontakte, die ALLE erfüllen.
{/if}
</p>
{#if audience.filters.length > 0}
<div class="filter-chips">
{#each audience.filters as f, i (i)}
<span class="chip" class:chip-negate={f.op === 'not-has'}>
{filterLabel(f)}
<button
type="button"
class="chip-remove"
onclick={() => removeFilter(i)}
aria-label="Filter entfernen">×</button
>
</span>
{/each}
</div>
{/if}
<section class="tag-picker">
<h4>Nach Tag filtern</h4>
{#if allTags.length === 0}
<p class="empty">
Keine Tags vorhanden. Lege in Kontakten Tags an, um nach ihnen zu segmentieren.
</p>
{:else}
<div class="tag-grid">
{#each allTags as tag (tag.id)}
{@const used = usedTagIds.has(tag.id)}
<div class="tag-row">
<span class="tag-color" style="--c: {tag.color ?? '#64748b'}"></span>
<span class="tag-name">{tag.name}</span>
<button
type="button"
class="tag-btn"
disabled={used}
onclick={() => addTagFilter(tag.id, 'has')}
title="Nur Kontakte mit diesem Tag">+ hat</button
>
<button
type="button"
class="tag-btn tag-btn-negate"
disabled={used}
onclick={() => addTagFilter(tag.id, 'not-has')}
title="Nur Kontakte ohne diesen Tag"> ohne</button
>
</div>
{/each}
</div>
{/if}
</section>
</div>
<style>
.audience {
display: flex;
flex-direction: column;
gap: 1rem;
}
.audience-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.audience-head h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.count-chip {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
padding: 0.3rem 0.75rem;
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 999px;
color: #3730a3;
font-size: 0.85rem;
}
.count-chip strong {
font-size: 1.1rem;
font-weight: 600;
}
.count-chip.empty {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.6rem 0.3rem 0.75rem;
background: #e0e7ff;
border: 1px solid #c7d2fe;
border-radius: 999px;
font-size: 0.85rem;
color: #3730a3;
}
.chip.chip-negate {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.chip-remove {
background: transparent;
border: 0;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: inherit;
opacity: 0.6;
padding: 0;
}
.chip-remove:hover {
opacity: 1;
}
.tag-picker {
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.tag-picker h4 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.tag-picker .empty {
margin: 0.25rem 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.tag-grid {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 20rem;
overflow-y: auto;
}
.tag-row {
display: grid;
grid-template-columns: 14px 1fr auto auto;
gap: 0.5rem;
align-items: center;
}
.tag-color {
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--c);
}
.tag-name {
font-size: 0.9rem;
}
.tag-btn {
padding: 0.25rem 0.65rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.8rem;
}
.tag-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tag-btn-negate {
color: #b91c1c;
border-color: #fecaca;
}
</style>

View file

@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import type { Contact } from '$lib/modules/contacts/types';
import { matchContact, filterAudience, countAudience, describeAudience } from './segment-builder';
import type { AudienceDefinition, AudienceFilter } from '../types';
function makeContact(overrides: Partial<Contact> = {}): Contact {
return {
id: 'c1',
firstName: 'Test',
lastName: 'Kontakt',
displayName: 'Test Kontakt',
email: 'test@example.com',
phone: null,
mobile: null,
company: null,
jobTitle: null,
street: null,
city: null,
postalCode: null,
country: null,
latitude: null,
longitude: null,
notes: null,
photoUrl: null,
birthday: null,
website: null,
linkedin: null,
twitter: null,
instagram: null,
github: null,
isFavorite: false,
isArchived: false,
tags: [],
tagIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
} as Contact;
}
const f = (
field: AudienceFilter['field'],
op: AudienceFilter['op'],
value: string
): AudienceFilter => ({
field,
op,
value,
});
const audience = (filters: AudienceFilter[]): AudienceDefinition => ({
filters,
estimatedCount: 0,
});
describe('matchContact', () => {
it('tag has: returns true when the contact has the tag', () => {
expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'has', 't1'))).toBe(true);
});
it('tag has: returns false when the contact lacks the tag', () => {
expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'has', 't1'))).toBe(false);
});
it('tag not-has: returns true when absent', () => {
expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'not-has', 't1'))).toBe(true);
});
it('tag not-has: returns false when present', () => {
expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'not-has', 't1'))).toBe(false);
});
it('email contains: case-insensitive', () => {
expect(matchContact(makeContact({ email: 'foo@BAR.CH' }), f('email', 'contains', 'bar'))).toBe(
true
);
});
it('email eq: exact match required (after lowercasing both)', () => {
expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'A@B.CH'))).toBe(true);
expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'x@b.ch'))).toBe(false);
});
it('email has: returns true when a usable email exists', () => {
expect(matchContact(makeContact({ email: 'x@y.z' }), f('email', 'has', ''))).toBe(true);
expect(matchContact(makeContact({ email: null }), f('email', 'has', ''))).toBe(false);
});
});
describe('filterAudience', () => {
const contacts = [
makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
makeContact({ id: '2', email: 'b@x.ch', tagIds: ['kunde', 'trial'] }),
makeContact({ id: '3', email: 'c@x.ch', tagIds: [] }),
makeContact({ id: '4', email: null, tagIds: ['kunde'] }), // no email → excluded
];
it('no filters: returns all contacts with valid email', () => {
const result = filterAudience(contacts, audience([]));
expect(result.map((c) => c.id)).toEqual(['1', '2', '3']);
});
it('single tag filter: matches only contacts with the tag', () => {
const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
expect(result.map((c) => c.id)).toEqual(['1', '2']);
});
it('AND semantics: all filters must match', () => {
// Kunden OHNE trial-tag = nur Contact 1
const result = filterAudience(
contacts,
audience([f('tag', 'has', 'kunde'), f('tag', 'not-has', 'trial')])
);
expect(result.map((c) => c.id)).toEqual(['1']);
});
it('drops contacts without usable email even if filters match', () => {
// Contact 4 has 'kunde' tag but no email → excluded from result
const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
expect(result.find((c) => c.id === '4')).toBeUndefined();
});
it('returns a new array (no input mutation)', () => {
const before = contacts.slice();
filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
expect(contacts).toEqual(before);
});
});
describe('countAudience', () => {
it('matches filterAudience().length', () => {
const contacts = [
makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
makeContact({ id: '2', email: 'b@x.ch', tagIds: [] }),
];
const def = audience([f('tag', 'has', 'kunde')]);
expect(countAudience(contacts, def)).toBe(filterAudience(contacts, def).length);
});
});
describe('describeAudience', () => {
const resolver = (id: string) => {
const names: Record<string, string> = { t1: 'Kunden', t2: 'Newsletter' };
return names[id] ?? null;
};
it('no filters → "Alle Kontakte mit E-Mail"', () => {
expect(describeAudience(audience([]), resolver)).toBe('Alle Kontakte mit E-Mail');
});
it('resolves tag names via the resolver', () => {
const result = describeAudience(audience([f('tag', 'has', 't1')]), resolver);
expect(result).toContain('Kunden');
});
it('falls back to the raw value when resolver returns null', () => {
const result = describeAudience(audience([f('tag', 'has', 'unknown')]), resolver);
expect(result).toContain('unknown');
});
it('joins multiple filters with · separator', () => {
const result = describeAudience(
audience([f('tag', 'has', 't1'), f('tag', 'not-has', 't2')]),
resolver
);
expect(result).toContain('·');
expect(result).toContain('Kunden');
expect(result).toContain('Newsletter');
});
});

View file

@ -0,0 +1,96 @@
/**
* Pure audience-segment matcher.
*
* Given a contact list and a set of filters, return only the contacts
* that satisfy ALL filters (AND semantics). Filter `value` meanings:
* - field='tag' value = tag ID (not name). UI resolves names.
* - field='email' value = substring or exact email
* - field='custom' reserved for per-contact custom fields later
*
* Semantics per op:
* has the tag/value is present
* not-has the tag/value is absent
* eq exact match (for email: case-insensitive)
* contains substring match (for email: case-insensitive)
*
* Empty filter list matches every contact "keine Filter" means
* "alle Empfänger", not "niemand". Callers that need the opposite
* (default-deny) should gate the send separately.
*/
import type { Contact } from '$lib/modules/contacts/types';
import type { AudienceFilter, AudienceDefinition } from '../types';
export function matchContact(contact: Contact, filter: AudienceFilter): boolean {
switch (filter.field) {
case 'tag': {
const has = (contact.tagIds ?? []).includes(filter.value);
if (filter.op === 'has') return has;
if (filter.op === 'not-has') return !has;
// eq / contains don't apply to tags — graceful fail to `has`.
return has;
}
case 'email': {
const email = (contact.email ?? '').toLowerCase();
const v = filter.value.toLowerCase();
if (filter.op === 'has') return email.length > 0;
if (filter.op === 'not-has') return email.length === 0;
if (filter.op === 'eq') return email === v;
if (filter.op === 'contains') return email.includes(v);
return false;
}
case 'custom':
// Placeholder — M2+ ships tags + email. Custom fields land in
// Phase 2 when contact schema grows a free-form fields map.
return true;
default:
return true;
}
}
/**
* Run all filters against a contact list. Returns a copy never mutates
* the input. Contacts without a usable email address are dropped even if
* they match the filters; you can't send a newsletter to someone without
* an email, and silently counting them would inflate the estimated count.
*/
export function filterAudience(contacts: Contact[], audience: AudienceDefinition): Contact[] {
const filtered = audience.filters.length
? contacts.filter((c) => audience.filters.every((f) => matchContact(c, f)))
: contacts.slice();
return filtered.filter((c) => typeof c.email === 'string' && c.email.includes('@'));
}
/** Fast count without materialising the full array (same filter logic). */
export function countAudience(contacts: Contact[], audience: AudienceDefinition): number {
return filterAudience(contacts, audience).length;
}
/**
* Human-friendly summary of a filter AST. Used in the ListView row and
* the Preflight step to show "an: Kunden ohne trial-tag (23 Empfänger)"
* instead of just the raw number.
*/
export function describeAudience(
audience: AudienceDefinition,
tagNameResolver: (tagId: string) => string | null
): string {
if (audience.filters.length === 0) return 'Alle Kontakte mit E-Mail';
const parts = audience.filters.map((f) => {
if (f.field === 'tag') {
const name = tagNameResolver(f.value) ?? f.value;
return f.op === 'has' ? `Tag "${name}"` : `ohne Tag "${name}"`;
}
if (f.field === 'email') {
if (f.op === 'eq') return `E-Mail = ${f.value}`;
if (f.op === 'contains') return `E-Mail enthält "${f.value}"`;
if (f.op === 'has') return `mit E-Mail`;
if (f.op === 'not-has') return `ohne E-Mail`;
}
return `${f.field} ${f.op} ${f.value}`;
});
return parts.join(' · ');
}

View file

@ -0,0 +1,392 @@
<!--
Editor — Tiptap 3 Svelte wrapper for the broadcast content editor.
Minimal toolbar (bold/italic/heading/list/link) plus image upload via
mana-media. The HTML that comes out of getHTML() is the user's input
only — the send pipeline wraps it in the full email template (header,
footer, unsubscribe link) on M4.
Two-way bind on `content` (Tiptap JSON). Parent persists this via
broadcastCampaignsStore.updateContent; we don't save here.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import { Link } from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { uploadLogo } from '$lib/modules/invoices/pdf/logo';
interface Props {
content: { tiptap: object; html?: string; plainText?: string };
placeholder?: string;
}
let {
content = $bindable({ tiptap: { type: 'doc', content: [{ type: 'paragraph' }] } }),
placeholder = 'Schreib deinen Newsletter …',
}: Props = $props();
let editorEl: HTMLElement | undefined = $state();
let editor: Editor | null = null;
let uploading = $state(false);
let uploadError = $state<string | null>(null);
let linkUrl = $state('');
let showLinkInput = $state(false);
// Reactive toolbar state — updated from the editor's transactions.
let isBold = $state(false);
let isItalic = $state(false);
let isH1 = $state(false);
let isH2 = $state(false);
let isBulletList = $state(false);
let isOrderedList = $state(false);
let isLink = $state(false);
function syncToolbar() {
if (!editor) return;
isBold = editor.isActive('bold');
isItalic = editor.isActive('italic');
isH1 = editor.isActive('heading', { level: 1 });
isH2 = editor.isActive('heading', { level: 2 });
isBulletList = editor.isActive('bulletList');
isOrderedList = editor.isActive('orderedList');
isLink = editor.isActive('link');
}
onMount(() => {
editor = new Editor({
element: editorEl,
extensions: [
StarterKit,
Image.configure({ inline: false }),
Link.configure({
openOnClick: false, // users click to edit, not navigate away
autolink: true,
}),
Placeholder.configure({ placeholder }),
],
content: content.tiptap,
onUpdate: ({ editor: e }) => {
content = {
tiptap: e.getJSON(),
html: e.getHTML(),
plainText: e.getText(),
};
syncToolbar();
},
onSelectionUpdate: syncToolbar,
onTransaction: syncToolbar,
});
});
onDestroy(() => {
editor?.destroy();
editor = null;
});
// ─── Toolbar actions ─────────────────────────────────
function cmd<T>(fn: (chain: ReturnType<NonNullable<typeof editor>['chain']>) => T) {
if (!editor) return;
fn(editor.chain().focus());
}
function toggleBold() {
cmd((c) => c.toggleBold().run());
}
function toggleItalic() {
cmd((c) => c.toggleItalic().run());
}
function toggleH1() {
cmd((c) => c.toggleHeading({ level: 1 }).run());
}
function toggleH2() {
cmd((c) => c.toggleHeading({ level: 2 }).run());
}
function toggleBullet() {
cmd((c) => c.toggleBulletList().run());
}
function toggleOrdered() {
cmd((c) => c.toggleOrderedList().run());
}
function openLinkPrompt() {
if (!editor) return;
const existing = (editor.getAttributes('link') as { href?: string }).href ?? '';
linkUrl = existing;
showLinkInput = true;
}
function applyLink() {
if (!editor) return;
const url = linkUrl.trim();
if (!url) {
editor.chain().focus().unsetLink().run();
} else {
// Basic normalisation: bare domain → https://
const normalised = /^https?:\/\//i.test(url) ? url : `https://${url}`;
editor.chain().focus().extendMarkRange('link').setLink({ href: normalised }).run();
}
showLinkInput = false;
linkUrl = '';
}
async function onImageSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !editor) return;
uploading = true;
uploadError = null;
try {
// Reuse invoices' logo uploader — it's the same mana-media /upload
// endpoint with a different `app` tag. Acceptable pragmatic reuse
// until we extract a shared `uploadMedia(file, app)` helper.
const mediaId = await uploadLogo(file);
const url = `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
} catch (err) {
uploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
} finally {
uploading = false;
input.value = '';
}
}
let imageInput: HTMLInputElement | undefined = $state();
function getMediaUrl(): string {
if (typeof window !== 'undefined') {
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
.__PUBLIC_MANA_MEDIA_URL__;
if (fromWindow) return fromWindow;
}
return import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
}
</script>
<div class="editor-wrap">
<div class="toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" class="tb" class:on={isBold} onclick={toggleBold} title="Fett (⌘B)">
<strong>B</strong>
</button>
<button type="button" class="tb" class:on={isItalic} onclick={toggleItalic} title="Kursiv (⌘I)">
<em>I</em>
</button>
<span class="divider"></span>
<button type="button" class="tb" class:on={isH1} onclick={toggleH1} title="Überschrift 1">
H1
</button>
<button type="button" class="tb" class:on={isH2} onclick={toggleH2} title="Überschrift 2">
H2
</button>
<span class="divider"></span>
<button type="button" class="tb" class:on={isBulletList} onclick={toggleBullet} title="Liste">
• Liste
</button>
<button
type="button"
class="tb"
class:on={isOrderedList}
onclick={toggleOrdered}
title="Nummerierte Liste"
>
1. Liste
</button>
<span class="divider"></span>
<button type="button" class="tb" class:on={isLink} onclick={openLinkPrompt} title="Link">
🔗
</button>
<button
type="button"
class="tb"
onclick={() => imageInput?.click()}
disabled={uploading}
title="Bild hochladen"
>
{uploading ? '…' : '🖼'}
</button>
<input
bind:this={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif"
hidden
onchange={onImageSelect}
/>
</div>
{#if showLinkInput}
<div class="link-bar">
<input
type="url"
placeholder="https://…"
bind:value={linkUrl}
onkeydown={(e) => e.key === 'Enter' && applyLink()}
/>
<button type="button" class="btn-sm" onclick={applyLink}>Setzen</button>
<button
type="button"
class="btn-sm btn-sm-muted"
onclick={() => {
showLinkInput = false;
linkUrl = '';
}}
>
Abbrechen
</button>
</div>
{/if}
{#if uploadError}
<div class="error">{uploadError}</div>
{/if}
<div class="prose" bind:this={editorEl}></div>
</div>
<style>
.editor-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toolbar {
display: flex;
gap: 0.25rem;
align-items: center;
padding: 0.4rem 0.5rem;
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem 0.4rem 0 0;
flex-wrap: wrap;
}
.divider {
width: 1px;
height: 1.25rem;
background: var(--color-border, #e2e8f0);
margin: 0 0.15rem;
}
.tb {
padding: 0.3rem 0.55rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.85rem;
}
.tb:hover {
background: white;
border-color: var(--color-border, #e2e8f0);
}
.tb.on {
background: #e0e7ff;
border-color: #c7d2fe;
color: #3730a3;
}
.tb:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.link-bar {
display: flex;
gap: 0.4rem;
padding: 0.4rem 0.5rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-top: 0;
}
.link-bar input {
flex: 1;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.3rem;
font-size: 0.9rem;
}
.btn-sm {
padding: 0.35rem 0.75rem;
background: #6366f1;
color: white;
border: 0;
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.85rem;
}
.btn-sm-muted {
background: white;
color: var(--color-text-muted, #64748b);
border: 1px solid var(--color-border, #e2e8f0);
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
font-size: 0.85rem;
}
.prose {
min-height: 240px;
padding: 0.75rem 1rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0 0 0.4rem 0.4rem;
font-size: 0.95rem;
line-height: 1.55;
}
/* Tiptap ProseMirror internals — style the generated DOM. */
.prose :global(.ProseMirror) {
outline: none;
min-height: 220px;
}
.prose :global(.ProseMirror p.is-editor-empty:first-child::before) {
color: var(--color-text-muted, #94a3b8);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.prose :global(.ProseMirror h1) {
font-size: 1.5rem;
font-weight: 600;
margin: 1rem 0 0.5rem;
}
.prose :global(.ProseMirror h2) {
font-size: 1.2rem;
font-weight: 600;
margin: 0.8rem 0 0.4rem;
}
.prose :global(.ProseMirror ul),
.prose :global(.ProseMirror ol) {
padding-left: 1.5rem;
}
.prose :global(.ProseMirror a) {
color: #6366f1;
text-decoration: underline;
}
.prose :global(.ProseMirror img) {
max-width: 100%;
height: auto;
border-radius: 0.3rem;
margin: 0.5rem 0;
}
</style>

View file

@ -4,6 +4,28 @@
export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections'; export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections';
export {
useAllCampaigns,
useAllTemplates,
toCampaign,
toTemplate,
toSettings,
filterByStatus,
searchCampaigns,
computeStats,
formatRate,
} from './queries';
export {
matchContact,
filterAudience,
countAudience,
describeAudience,
} from './audience/segment-builder';
export { broadcastCampaignsStore } from './stores/campaigns.svelte';
export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
export { export {
STATUS_LABELS, STATUS_LABELS,
STATUS_COLORS, STATUS_COLORS,

View file

@ -0,0 +1,181 @@
/**
* Reactive queries and pure helpers for the Broadcast module.
*
* Live queries decrypt + map to domain types, consistent with the
* invoices / library modules. Scope-wrapping via `scopedForModule`
* matches the post-Spaces convention so lists respect the active space.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule } from '$lib/data/scope';
import { campaignTable, templateTable, settingsTable } from './collections';
import { BROADCAST_SETTINGS_ID } from './constants';
import type {
LocalCampaign,
LocalBroadcastTemplate,
LocalBroadcastSettings,
Campaign,
BroadcastTemplate,
BroadcastSettings,
CampaignStatus,
} from './types';
// ─── Type Converters ─────────────────────────────────────
export function toCampaign(local: LocalCampaign): Campaign {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
subject: local.subject,
preheader: local.preheader ?? null,
fromName: local.fromName,
fromEmail: local.fromEmail,
replyTo: local.replyTo ?? null,
content: local.content,
templateId: local.templateId ?? null,
audience: local.audience,
scheduledAt: local.scheduledAt ?? null,
sentAt: local.sentAt ?? null,
status: local.status,
serverJobId: local.serverJobId ?? null,
stats: local.stats ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toTemplate(local: LocalBroadcastTemplate): BroadcastTemplate {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description ?? null,
subject: local.subject ?? null,
content: local.content,
isBuiltIn: local.isBuiltIn,
thumbnailUrl: local.thumbnailUrl ?? null,
createdAt: local.createdAt ?? now,
};
}
export function toSettings(local: LocalBroadcastSettings): BroadcastSettings {
return {
id: local.id,
defaultFromName: local.defaultFromName ?? '',
defaultFromEmail: local.defaultFromEmail ?? '',
defaultReplyTo: local.defaultReplyTo ?? null,
defaultFooter: local.defaultFooter ?? null,
dnsCheck: local.dnsCheck ?? null,
legalAddress: local.legalAddress ?? '',
unsubscribeLandingCopy: local.unsubscribeLandingCopy ?? null,
};
}
// ─── Live Queries ────────────────────────────────────────
/** All campaigns in the active space, newest first by updatedAt. */
export function useAllCampaigns() {
return useLiveQueryWithDefault(async () => {
const rows = await scopedForModule<LocalCampaign, string>(
'broadcast',
'broadcastCampaigns'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
return decrypted.map(toCampaign).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}, [] as Campaign[]);
}
export function useAllTemplates() {
return useLiveQueryWithDefault(async () => {
const rows = await scopedForModule<LocalBroadcastTemplate, string>(
'broadcast',
'broadcastTemplates'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = (await decryptRecords(
'broadcastTemplates',
visible
)) as LocalBroadcastTemplate[];
return decrypted.map(toTemplate);
}, [] as BroadcastTemplate[]);
}
// ─── Pure Helpers ────────────────────────────────────────
export function filterByStatus(campaigns: Campaign[], status: CampaignStatus): Campaign[] {
return campaigns.filter((c) => c.status === status);
}
export function searchCampaigns(campaigns: Campaign[], query: string): Campaign[] {
const q = query.toLowerCase();
return campaigns.filter(
(c) => c.name.toLowerCase().includes(q) || c.subject.toLowerCase().includes(q)
);
}
// ─── Stats ───────────────────────────────────────────────
export interface BroadcastStats {
totalByStatus: Record<CampaignStatus, number>;
sentThisYear: number;
avgOpenRate: number | null;
avgClickRate: number | null;
totalSubscribers: number;
}
export function computeStats(campaigns: Campaign[], year: number): BroadcastStats {
const totalByStatus: Record<CampaignStatus, number> = {
draft: 0,
scheduled: 0,
sending: 0,
sent: 0,
cancelled: 0,
};
let sentThisYear = 0;
let openRateSum = 0;
let openRateCount = 0;
let clickRateSum = 0;
let clickRateCount = 0;
const yearPrefix = String(year);
for (const c of campaigns) {
totalByStatus[c.status]++;
if (c.status === 'sent' && c.sentAt?.startsWith(yearPrefix)) {
sentThisYear++;
}
if (c.stats && c.stats.sent > 0) {
const openRate = c.stats.opened / c.stats.sent;
const clickRate = c.stats.clicked / c.stats.sent;
openRateSum += openRate;
openRateCount++;
clickRateSum += clickRate;
clickRateCount++;
}
}
return {
totalByStatus,
sentThisYear,
avgOpenRate: openRateCount > 0 ? openRateSum / openRateCount : null,
avgClickRate: clickRateCount > 0 ? clickRateSum / clickRateCount : null,
totalSubscribers: 0, // TODO M7: derive from unique recipients across all campaigns
};
}
// ─── Formatting ──────────────────────────────────────────
/** Format a rate (0..1) as a percentage with one decimal, e.g. 0.234 → "23.4%". */
export function formatRate(rate: number | null): string {
if (rate === null) return '—';
return `${(rate * 100).toFixed(1)}%`;
}
// ─── Settings singleton helpers ──────────────────────────
export { BROADCAST_SETTINGS_ID };
// Re-exported so UI consumers can `await settingsTable.get(BROADCAST_SETTINGS_ID)`
// without importing from two places.
export { settingsTable, campaignTable, templateTable };

View file

@ -0,0 +1,238 @@
/**
* Campaigns store mutation-only service.
*
* Status machine enforced here:
* draft scheduled (schedule)
* draft sending (send) [set by server orchestrator, not this store]
* sending sent (server-driven)
* draft | scheduled cancelled (cancel)
*
* Only drafts are user-editable. Once a campaign starts sending, content
* and audience freeze so the recipient graph can't shift mid-flight.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { campaignTable } from '../collections';
import { broadcastSettingsStore } from './settings.svelte';
import type { LocalCampaign, CampaignContent, AudienceDefinition, CampaignStatus } from '../types';
export interface CreateCampaignInput {
name?: string;
subject?: string;
preheader?: string | null;
fromName?: string;
fromEmail?: string;
replyTo?: string | null;
content?: CampaignContent;
audience?: AudienceDefinition;
templateId?: string | null;
}
const EMPTY_TIPTAP = {
type: 'doc',
content: [{ type: 'paragraph' }],
};
const EMPTY_AUDIENCE: AudienceDefinition = {
filters: [],
estimatedCount: 0,
};
export const broadcastCampaignsStore = {
/**
* Create a new campaign in status `draft`. Sender fields + footer default
* to the user's broadcast settings so first-time use feels like "start
* typing and go" rather than "fill out ten fields before you can type".
*/
async createCampaign(input: CreateCampaignInput = {}): Promise<string> {
const defaults = await broadcastSettingsStore.getDefaults();
const now = new Date().toISOString();
const newLocal: LocalCampaign = {
id: crypto.randomUUID(),
name: input.name ?? 'Neue Kampagne',
subject: input.subject ?? '',
preheader: input.preheader ?? null,
fromName: input.fromName ?? defaults.fromName,
fromEmail: input.fromEmail ?? defaults.fromEmail,
replyTo: input.replyTo ?? defaults.replyTo,
content: input.content ?? { tiptap: EMPTY_TIPTAP },
templateId: input.templateId ?? null,
audience: input.audience ?? EMPTY_AUDIENCE,
scheduledAt: null,
sentAt: null,
status: 'draft',
serverJobId: null,
stats: null,
createdAt: now,
updatedAt: now,
};
await encryptRecord('broadcastCampaigns', newLocal);
await campaignTable.add(newLocal);
emitDomainEvent('BroadcastCampaignCreated', 'broadcast', 'broadcastCampaigns', newLocal.id, {
campaignId: newLocal.id,
name: newLocal.name,
});
return newLocal.id;
},
/**
* Generic metadata patch only valid in `draft`. Sending and onward
* freeze the row to preserve the "what you saw is what went out"
* invariant for the recipient.
*/
async updateCampaign(
id: string,
patch: Partial<
Pick<
LocalCampaign,
'name' | 'subject' | 'preheader' | 'fromName' | 'fromEmail' | 'replyTo' | 'templateId'
>
>
) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') {
throw new Error('[broadcast] only drafts can be edited; duplicate to revise a sent campaign');
}
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', wrapped);
await campaignTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
/**
* Replace content (Tiptap JSON + derived HTML/plaintext). Derived
* outputs are passed in by the caller because rendering happens
* client-side in the editor component; the store stays dumb about
* Tiptap's schema.
*/
async updateContent(id: string, content: CampaignContent) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') {
throw new Error('[broadcast] only drafts can be edited');
}
const patch = { content } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', patch);
await campaignTable.update(id, {
...patch,
updatedAt: new Date().toISOString(),
});
},
async updateAudience(id: string, audience: AudienceDefinition) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') {
throw new Error('[broadcast] only drafts can be edited');
}
const patch = { audience } as Record<string, unknown>;
await encryptRecord('broadcastCampaigns', patch);
await campaignTable.update(id, {
...patch,
updatedAt: new Date().toISOString(),
});
},
/**
* Flip draft scheduled with a future timestamp. Actual send happens
* server-side when mana-mail's cron sees the row; this store just
* arms the trigger.
*/
async schedule(id: string, scheduledAt: string) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') return;
await campaignTable.update(id, {
status: 'scheduled' as CampaignStatus,
scheduledAt,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id,
scheduledAt,
});
},
/** Revoke a scheduled send before it fires. Can be reactivated as draft. */
async cancel(id: string) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status !== 'draft' && existing.status !== 'scheduled') {
throw new Error('[broadcast] only drafts or scheduled campaigns can be cancelled');
}
await campaignTable.update(id, {
status: 'cancelled' as CampaignStatus,
scheduledAt: null,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id,
});
},
/**
* Duplicate an existing campaign (typically a sent one being reused as
* template-of-the-moment). Produces a fresh draft with the same
* content + audience but new number / status.
*/
async duplicate(id: string): Promise<string> {
const existing = await campaignTable.get(id);
if (!existing) throw new Error('[broadcast] duplicate: source not found');
const { decryptRecords } = await import('$lib/data/crypto');
const [decrypted] = (await decryptRecords('broadcastCampaigns', [existing])) as LocalCampaign[];
return this.createCampaign({
name: `Kopie von ${decrypted.name}`,
subject: decrypted.subject,
preheader: decrypted.preheader,
fromName: decrypted.fromName,
fromEmail: decrypted.fromEmail,
replyTo: decrypted.replyTo,
content: decrypted.content,
audience: decrypted.audience,
templateId: decrypted.templateId,
});
},
async deleteCampaign(id: string) {
const existing = await campaignTable.get(id);
if (!existing) return;
if (existing.status === 'sending' || existing.status === 'sent') {
throw new Error(
'[broadcast] versendete oder laufende Kampagnen können nicht gelöscht werden (Bookkeeping)'
);
}
await campaignTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
campaignId: id,
});
},
/**
* Server-side hook surface: once M4's orchestrator accepts a send, it
* writes back here to reflect progress. Exposed as a store method so
* callers share the encryption/event plumbing.
*/
async applyServerStatus(
id: string,
patch: {
status: CampaignStatus;
serverJobId?: string | null;
sentAt?: string | null;
stats?: LocalCampaign['stats'];
}
) {
await campaignTable.update(id, {
...patch,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,83 @@
/**
* Broadcast settings store singleton row per user/space.
*
* Same pattern as invoices settings: stable sentinel id, lazy create on
* first read, plaintext structural fields stay out of the Dexie-tx crypto
* boundary so ensure/get paths don't need crypto ops inside a transaction.
*/
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { settingsTable } from '../collections';
import { BROADCAST_SETTINGS_ID } from '../constants';
import type { LocalBroadcastSettings, BroadcastSettings } from '../types';
import { toSettings } from '../queries';
async function ensureSettings(): Promise<LocalBroadcastSettings> {
const existing = await settingsTable.get(BROADCAST_SETTINGS_ID);
if (existing) return existing;
const defaults: LocalBroadcastSettings = {
id: BROADCAST_SETTINGS_ID,
defaultFromName: '',
defaultFromEmail: '',
defaultReplyTo: null,
defaultFooter: null,
dnsCheck: null,
legalAddress: '',
unsubscribeLandingCopy: null,
};
const wrapped = { ...defaults };
await encryptRecord('broadcastSettings', wrapped);
await settingsTable.add(wrapped);
return wrapped;
}
export const broadcastSettingsStore = {
async get(): Promise<BroadcastSettings> {
await ensureSettings();
const row = await settingsTable.get(BROADCAST_SETTINGS_ID);
if (!row) throw new Error('[broadcast] settings row vanished after ensure');
const [decrypted] = (await decryptRecords('broadcastSettings', [
row,
])) as LocalBroadcastSettings[];
return toSettings(decrypted);
},
async update(patch: Partial<Omit<LocalBroadcastSettings, 'id'>>) {
await ensureSettings();
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('broadcastSettings', wrapped);
await settingsTable.update(BROADCAST_SETTINGS_ID, {
...wrapped,
updatedAt: new Date().toISOString(),
});
emitDomainEvent(
'BroadcastSettingsUpdated',
'broadcast',
'broadcastSettings',
BROADCAST_SETTINGS_ID,
{ fields: Object.keys(patch) }
);
},
/** Quick-read defaults for a fresh campaign. */
async getDefaults(): Promise<{
fromName: string;
fromEmail: string;
replyTo: string | null;
footer: string | null;
legalAddress: string;
}> {
const s = await this.get();
return {
fromName: s.defaultFromName,
fromEmail: s.defaultFromEmail,
replyTo: s.defaultReplyTo,
footer: s.defaultFooter,
legalAddress: s.legalAddress,
};
},
};
export { ensureSettings };

View file

@ -0,0 +1,406 @@
<!--
ComposeView — 4-step wizard for creating / editing a campaign.
M2 ships steps 1 (Audience) + 2 (Content) fully functional. Preflight
and Send steps are stubbed (buttons disabled) — those land in M3/M4
when HTML rendering and bulk-send orchestration exist.
The wizard is state-aware: user can jump back to earlier steps, but
step 3/4 refuse to activate if earlier steps are incomplete.
-->
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import AudienceBuilder from '../audience/AudienceBuilder.svelte';
import Editor from '../editor/Editor.svelte';
import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
import type { Campaign, CampaignContent, AudienceDefinition } from '../types';
interface Props {
existing?: Campaign;
}
let { existing }: Props = $props();
// Capture once at mount — ComposeView is keyed on campaign id at the
// route level, so prop changes remount rather than re-initialise.
const initial = untrack(() => existing);
const isEdit = untrack(() => Boolean(existing));
type Step = 1 | 2 | 3 | 4;
let step = $state<Step>(1);
// ─── Form state (captured once at mount) ───────────────────
let name = $state<string>(initial?.name ?? 'Neue Kampagne');
let subject = $state<string>(initial?.subject ?? '');
let preheader = $state<string>(initial?.preheader ?? '');
let fromName = $state<string>(initial?.fromName ?? '');
let fromEmail = $state<string>(initial?.fromEmail ?? '');
let audience = $state<AudienceDefinition>(
initial?.audience ?? { filters: [], estimatedCount: 0 }
);
let content = $state<CampaignContent>(
initial?.content ?? { tiptap: { type: 'doc', content: [{ type: 'paragraph' }] } }
);
let saving = $state(false);
let error = $state<string | null>(null);
let savedAt = $state<string | null>(null);
// ─── Save ───────────────────────────────────────────────────
async function save() {
if (!existing) return;
saving = true;
error = null;
try {
await broadcastCampaignsStore.updateCampaign(existing.id, {
name,
subject,
preheader: preheader || null,
fromName,
fromEmail,
});
await broadcastCampaignsStore.updateAudience(existing.id, audience);
await broadcastCampaignsStore.updateContent(existing.id, content);
savedAt = new Date().toLocaleTimeString();
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
} finally {
saving = false;
}
}
// Autosave on step change — keeps content from vanishing when the
// user clicks through the wizard without hitting an explicit save.
async function goToStep(next: Step) {
if (isEdit) await save();
step = next;
}
const audienceReady = $derived(audience.estimatedCount > 0);
const contentReady = $derived(subject.trim().length > 0);
function onCancel() {
goto(isEdit && existing ? `/broadcasts/${existing.id}` : '/broadcasts');
}
</script>
<div class="compose">
<header class="head">
<input
class="name-input"
type="text"
placeholder="Kampagnen-Name"
bind:value={name}
aria-label="Kampagnen-Name"
/>
<div class="head-actions">
{#if savedAt}
<span class="saved">Gespeichert um {savedAt}</span>
{/if}
<button type="button" class="btn-ghost" onclick={onCancel}>Schließen</button>
<button type="button" class="btn-primary" onclick={save} disabled={saving || !isEdit}>
{saving ? 'Speichert …' : 'Speichern'}
</button>
</div>
</header>
<nav class="stepper">
<button
type="button"
class="step"
class:active={step === 1}
class:done={audienceReady && step > 1}
onclick={() => goToStep(1)}
>
<span class="step-num">1</span>
<span class="step-label">Empfänger</span>
</button>
<button
type="button"
class="step"
class:active={step === 2}
class:done={contentReady && step > 2}
onclick={() => goToStep(2)}
>
<span class="step-num">2</span>
<span class="step-label">Inhalt</span>
</button>
<button
type="button"
class="step"
class:active={step === 3}
disabled={!audienceReady || !contentReady}
onclick={() => goToStep(3)}
>
<span class="step-num">3</span>
<span class="step-label">Check</span>
</button>
<button
type="button"
class="step"
class:active={step === 4}
disabled={!audienceReady || !contentReady}
onclick={() => goToStep(4)}
>
<span class="step-num">4</span>
<span class="step-label">Senden</span>
</button>
</nav>
{#if error}
<div class="error">{error}</div>
{/if}
{#if step === 1}
<section class="step-panel">
<AudienceBuilder bind:audience />
</section>
{:else if step === 2}
<section class="step-panel">
<div class="meta-grid">
<label class="field">
<span>Betreff *</span>
<input type="text" placeholder="Neuer Newsletter" bind:value={subject} />
</label>
<label class="field">
<span>Preheader</span>
<input
type="text"
placeholder="Kurzer Vorschautext, erscheint in Gmail neben dem Betreff"
bind:value={preheader}
/>
</label>
<label class="field">
<span>Absender-Name *</span>
<input type="text" bind:value={fromName} />
</label>
<label class="field">
<span>Absender-E-Mail *</span>
<input type="email" bind:value={fromEmail} />
</label>
</div>
<Editor
bind:content
placeholder="Schreib deinen Newsletter. Nutze Bilder, Überschriften und Links."
/>
</section>
{:else if step === 3}
<section class="step-panel">
<div class="placeholder">
<h3>Preflight</h3>
<p>Spam-Score, DNS-Checks und Empfänger-Übersicht folgen in M3/M8.</p>
<p class="hint">
Empfänger: <strong>{audience.estimatedCount}</strong><br />
Betreff: <strong>{subject || '—'}</strong><br />
Absender: <strong>{fromName} &lt;{fromEmail}&gt;</strong>
</p>
</div>
</section>
{:else if step === 4}
<section class="step-panel">
<div class="placeholder">
<h3>Senden</h3>
<p>
Der Bulk-Send-Flow (Jetzt / Später) landet in M4 sobald mana-mail's <code>/bulk-send</code
>-Endpoint steht.
</p>
<button class="btn-primary" disabled>Jetzt senden (M4)</button>
</div>
</section>
{/if}
</div>
<style>
.compose {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.name-input {
flex: 1;
min-width: 18rem;
padding: 0.55rem 0.75rem;
border: 1px solid transparent;
border-radius: 0.4rem;
font-size: 1.15rem;
font-weight: 600;
background: transparent;
}
.name-input:hover,
.name-input:focus {
border-color: var(--color-border, #e2e8f0);
background: white;
outline: none;
}
.head-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.saved {
font-size: 0.8rem;
color: #6366f1;
}
.stepper {
display: flex;
gap: 0.25rem;
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 0.3rem;
}
.step {
flex: 1;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 0;
border-radius: 0.35rem;
cursor: pointer;
font-size: 0.9rem;
justify-content: center;
}
.step:hover:not(:disabled) {
background: white;
}
.step.active {
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
font-weight: 500;
}
.step.done .step-num {
background: #22c55e;
color: white;
}
.step:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background: var(--color-border, #e2e8f0);
color: var(--color-text-muted, #64748b);
font-size: 0.75rem;
font-weight: 600;
}
.step.active .step-num {
background: #6366f1;
color: white;
}
.step-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field > span {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.field input {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.95rem;
font-family: inherit;
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.placeholder {
background: var(--color-surface-muted, #f8fafc);
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.placeholder h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--color-text, #0f172a);
}
.placeholder .hint {
margin-top: 1rem;
text-align: left;
display: inline-block;
}
.btn-primary {
background: #6366f1;
color: white;
padding: 0.55rem 1.25rem;
border-radius: 0.4rem;
border: 0;
font-weight: 500;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: white;
border: 1px solid var(--color-border, #e2e8f0);
padding: 0.55rem 1rem;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/stores';
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
import ComposeView from '$lib/modules/broadcast/views/ComposeView.svelte';
const campaigns$ = useAllCampaigns();
const campaigns = $derived(campaigns$.value ?? []);
const id = $derived($page.params.id);
const campaign = $derived(campaigns.find((c) => c.id === id));
const canEdit = $derived(campaign?.status === 'draft');
</script>
<svelte:head>
<title>{campaign?.name ?? 'Kampagne'} - Mana</title>
</svelte:head>
{#if !campaign && campaigns$.value !== undefined}
<div class="not-found">
<p>Kampagne nicht gefunden.</p>
<a href="/broadcasts">Zurück zur Übersicht</a>
</div>
{:else if campaign && !canEdit}
<div class="not-editable">
<h2>Kampagne kann nicht bearbeitet werden</h2>
<p>
Status: <strong>{campaign.status}</strong>. Nur Entwürfe sind editierbar. Dupliziere die
Kampagne, um eine neue Version zu erstellen.
</p>
<a href="/broadcasts">Zurück</a>
</div>
{:else if campaign}
<ComposeView existing={campaign} />
{:else}
<div class="loading">Lädt …</div>
{/if}
<style>
.not-found,
.loading,
.not-editable {
padding: 3rem 1rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.not-editable h2 {
color: var(--color-text, #0f172a);
}
.not-editable p {
max-width: 40ch;
margin: 0.5rem auto;
}
</style>

View file

@ -0,0 +1,55 @@
<!--
/broadcasts/new — bootstraps a fresh draft and redirects to its
edit view. Keeps the ComposeView stateful-only (no create/edit
bifurcation inside the component itself).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { broadcastCampaignsStore } from '$lib/modules/broadcast/stores/campaigns.svelte';
let error = $state<string | null>(null);
onMount(async () => {
try {
const id = await broadcastCampaignsStore.createCampaign();
goto(`/broadcasts/${id}/edit`, { replaceState: true });
} catch (e) {
error = e instanceof Error ? e.message : 'Erstellen fehlgeschlagen';
}
});
</script>
<svelte:head>
<title>Neue Kampagne - Mana</title>
</svelte:head>
<div class="page">
{#if error}
<div class="error">{error}</div>
<a href="/broadcasts">Zurück</a>
{:else}
<p class="loading">Kampagne wird angelegt …</p>
{/if}
</div>
<style>
.page {
padding: 3rem 1.5rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.loading {
margin: 0;
}
</style>

View file

@ -3,6 +3,7 @@
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -12,7 +13,9 @@
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src",
"noEmit": false,
"rewriteRelativeImportExtensions": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

777
pnpm-lock.yaml generated
View file

@ -138,14 +138,14 @@ importers:
version: link:../../../../packages/shared-landing-ui version: link:../../../../packages/shared-landing-ui
astro: astro:
specifier: ^5.16.0 specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.3 version: 5.9.3
devDependencies: devDependencies:
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.18 specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -154,13 +154,13 @@ importers:
version: 20.19.39 version: 20.19.39
eslint: eslint:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7) version: 9.39.4(jiti@2.6.1)
eslint-config-prettier: eslint-config-prettier:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-astro: eslint-plugin-astro:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.8.1 version: 3.8.1
@ -253,10 +253,10 @@ importers:
version: 3.7.2 version: 3.7.2
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
astro: astro:
specifier: ^5.16.11 specifier: ^5.16.11
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
tailwindcss: tailwindcss:
specifier: ^3.4.17 specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
@ -576,6 +576,21 @@ importers:
'@quotes/content': '@quotes/content':
specifier: workspace:* specifier: workspace:*
version: link:../../../quotes/packages/content version: link:../../../quotes/packages/content
'@tiptap/core':
specifier: ^3.22.4
version: 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-image':
specifier: ^3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-link':
specifier: ^3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder':
specifier: ^3.22.4
version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/starter-kit':
specifier: ^3.22.4
version: 3.22.4
'@types/pako': '@types/pako':
specifier: ^2.0.4 specifier: ^2.0.4
version: 2.0.4 version: 2.0.4
@ -7783,6 +7798,142 @@ packages:
vitest: vitest:
optional: true optional: true
'@tiptap/core@3.22.4':
resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==}
peerDependencies:
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.22.4':
resolution: {integrity: sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-bold@3.22.4':
resolution: {integrity: sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-bullet-list@3.22.4':
resolution: {integrity: sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-code-block@3.22.4':
resolution: {integrity: sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-code@3.22.4':
resolution: {integrity: sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-document@3.22.4':
resolution: {integrity: sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-dropcursor@3.22.4':
resolution: {integrity: sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==}
peerDependencies:
'@tiptap/extensions': 3.22.4
'@tiptap/extension-gapcursor@3.22.4':
resolution: {integrity: sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==}
peerDependencies:
'@tiptap/extensions': 3.22.4
'@tiptap/extension-hard-break@3.22.4':
resolution: {integrity: sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-heading@3.22.4':
resolution: {integrity: sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-horizontal-rule@3.22.4':
resolution: {integrity: sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-image@3.22.4':
resolution: {integrity: sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-italic@3.22.4':
resolution: {integrity: sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-link@3.22.4':
resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-list-item@3.22.4':
resolution: {integrity: sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-list-keymap@3.22.4':
resolution: {integrity: sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-list@3.22.4':
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-ordered-list@3.22.4':
resolution: {integrity: sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==}
peerDependencies:
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-paragraph@3.22.4':
resolution: {integrity: sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-placeholder@3.22.4':
resolution: {integrity: sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q==}
peerDependencies:
'@tiptap/extensions': 3.22.4
'@tiptap/extension-strike@3.22.4':
resolution: {integrity: sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-text@3.22.4':
resolution: {integrity: sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extension-underline@3.22.4':
resolution: {integrity: sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/extensions@3.22.4':
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/pm@3.22.4':
resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==}
'@tiptap/starter-kit@3.22.4':
resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==}
'@tokenizer/inflate@0.2.7': '@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -12468,6 +12619,9 @@ packages:
linkify-it@2.2.0: linkify-it@2.2.0:
resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==} resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
lint-staged@16.4.0: lint-staged@16.4.0:
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
engines: {node: '>=20.17'} engines: {node: '>=20.17'}
@ -13368,6 +13522,9 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
os-tmpdir@1.0.2: os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -13888,6 +14045,42 @@ packages:
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
prosemirror-changeset@2.4.1:
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
prosemirror-view@1.41.8:
resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==}
protobufjs@7.5.4: protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -14470,6 +14663,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
rou3@0.7.12: rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
@ -15971,6 +16167,9 @@ packages:
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -16711,16 +16910,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -16741,6 +16930,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -18900,11 +19099,6 @@ snapshots:
'@esbuild/win32-x64@0.27.7': '@esbuild/win32-x64@0.27.7':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -22691,6 +22885,154 @@ snapshots:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@tiptap/core@3.22.4(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bold@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bullet-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-code@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-document@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-dropcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-gapcursor@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-hard-break@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-heading@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-horizontal-rule@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-image@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-italic@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-keymap@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-ordered-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-paragraph@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-strike@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-text@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-underline@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/pm@3.22.4':
dependencies:
prosemirror-changeset: 2.4.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
'@tiptap/starter-kit@3.22.4':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-blockquote': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bold': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bullet-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-code': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-code-block': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-document': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-dropcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-gapcursor': 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-hard-break': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-heading': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-horizontal-rule': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-italic': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-item': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-list-keymap': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-ordered-list': 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-paragraph': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-strike': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-text': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-underline': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tokenizer/inflate@0.2.7': '@tokenizer/inflate@0.2.7':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -23186,7 +23528,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.0.0 std-env: 4.0.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.3': '@vitest/expect@4.1.3':
dependencies: dependencies:
@ -23248,7 +23590,7 @@ snapshots:
sirv: 3.0.2 sirv: 3.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/utils@4.1.3': '@vitest/utils@4.1.3':
dependencies: dependencies:
@ -23679,108 +24021,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -23985,6 +24225,108 @@ snapshots:
- uploadthing - uploadthing
- yaml - yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -25788,11 +26130,6 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4 semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -25802,10 +26139,6 @@ snapshots:
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -25850,20 +26183,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -26037,47 +26356,6 @@ snapshots:
eslint-visitor-keys@5.0.1: {} eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1): eslint@9.39.4(jiti@2.6.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -28777,6 +29055,8 @@ snapshots:
dependencies: dependencies:
uc.micro: 1.0.6 uc.micro: 1.0.6
linkifyjs@4.3.2: {}
lint-staged@16.4.0: lint-staged@16.4.0:
dependencies: dependencies:
commander: 14.0.3 commander: 14.0.3
@ -30216,6 +30496,8 @@ snapshots:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wcwidth: 1.0.1 wcwidth: 1.0.1
orderedmap@2.1.1: {}
os-tmpdir@1.0.2: {} os-tmpdir@1.0.2: {}
own-keys@1.0.1: own-keys@1.0.1:
@ -30667,6 +30949,75 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
prosemirror-changeset@2.4.1:
dependencies:
prosemirror-transform: 1.12.0
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.8
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
rope-sequence: 1.3.4
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
prosemirror-view: 1.41.8
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.8:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.0
protobufjs@7.5.4: protobufjs@7.5.4:
dependencies: dependencies:
'@protobufjs/aspromise': 1.1.2 '@protobufjs/aspromise': 1.1.2
@ -31614,6 +31965,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1 '@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
rou3@0.7.12: {} rou3@0.7.12: {}
router@2.2.0: router@2.2.0:
@ -33056,23 +33409,6 @@ snapshots:
lightningcss: 1.32.0 lightningcss: 1.32.0
terser: 5.46.1 terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33107,6 +33443,23 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33124,10 +33477,6 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -33136,6 +33485,10 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -33334,6 +33687,8 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0