@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||
import { PluginBlock } from './nodes/plugin-block';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||
import { TaskItem } from './nodes/task-item';
|
||||
import { DateMarker } from './nodes/date-marker';
|
||||
import { Mention } from './nodes/mention';
|
||||
import { Transclusion } from './nodes/transclusion';
|
||||
import { Wikilink } from './nodes/wikilink';
|
||||
@ -46,6 +47,7 @@ export const documentExtensions: AnyExtension[] = [
|
||||
PluginBlock,
|
||||
Wikilink,
|
||||
Mention,
|
||||
DateMarker,
|
||||
Transclusion,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { InputRule, Node } from '@tiptap/core';
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import type { NodeViewProps } from '@tiptap/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||
|
||||
/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */
|
||||
const INPUT_PATTERN =
|
||||
/(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/;
|
||||
|
||||
function isoDateOf(match: RegExpMatchArray): string | null {
|
||||
const [year, month, day] = match[2]
|
||||
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const valid =
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
if (!valid) return null;
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
return `${year}-${pad(month)}-${pad(day)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start
|
||||
* date (`<<`), formatted per the viewer's language. A due date in the past
|
||||
* gets an "overdue" tint. The stored attr is always canonical ISO.
|
||||
*/
|
||||
function DateMarkerView({ node }: NodeViewProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('editor');
|
||||
const kind = node.attrs.kind as 'due' | 'start';
|
||||
const iso = node.attrs.date as string;
|
||||
|
||||
const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
|
||||
new Date(`${iso}T00:00:00`),
|
||||
);
|
||||
const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10);
|
||||
const classes = ['dt-date', `dt-date--${kind}`];
|
||||
if (overdue) classes.push('dt-date--overdue');
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="dt-date-nodeview">
|
||||
<span
|
||||
className={classes.join(' ')}
|
||||
title={t(kind === 'due' ? 'dateMarker.due' : 'dateMarker.start')}
|
||||
contentEditable={false}
|
||||
>
|
||||
{kind === 'due' ? '»' : '«'} {formatted}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const dateMarkerSpec = nodeSpec('date_marker');
|
||||
export const DateMarker = Node.create({
|
||||
name: 'date_marker',
|
||||
group: dateMarkerSpec.group,
|
||||
inline: dateMarkerSpec.inline,
|
||||
atom: dateMarkerSpec.atom,
|
||||
addAttributes() {
|
||||
return attributesFromSpec(dateMarkerSpec);
|
||||
},
|
||||
parseHTML: () => dateMarkerSpec.parseDOM,
|
||||
renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node),
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: INPUT_PATTERN,
|
||||
handler: ({ range, match, chain }) => {
|
||||
const iso = isoDateOf(match);
|
||||
if (!iso) return;
|
||||
// Keep a leading boundary character (space/line start) intact.
|
||||
const full = match[0];
|
||||
const markerStart = range.from + (full.length - full.trimStart().length);
|
||||
chain()
|
||||
.insertContentAt({ from: markerStart, to: range.to }, [
|
||||
{
|
||||
type: 'date_marker',
|
||||
attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso },
|
||||
},
|
||||
{ type: 'text', text: ' ' },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(DateMarkerView);
|
||||
},
|
||||
});
|
||||
@ -3824,3 +3824,22 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Date marker chip (issue #152): » = due date, « = start date. */
|
||||
.dt-date {
|
||||
display: inline-block;
|
||||
padding: 0 0.35em;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dt-date--due {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dt-date--overdue {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@ -185,5 +185,9 @@
|
||||
"mention": {
|
||||
"unresolved": "Unbekannter Nutzer",
|
||||
"suggestLabel": "Nutzer-Vorschläge"
|
||||
},
|
||||
"dateMarker": {
|
||||
"due": "Zieldatum",
|
||||
"start": "Startdatum"
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,5 +185,9 @@
|
||||
"mention": {
|
||||
"unresolved": "Unknown user",
|
||||
"suggestLabel": "User suggestions"
|
||||
},
|
||||
"dateMarker": {
|
||||
"due": "Due date",
|
||||
"start": "Start date"
|
||||
}
|
||||
}
|
||||
|
||||
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Node } from 'prosemirror-model';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { docToHtml } from './html';
|
||||
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||
import { docToPlainText } from './plain-text';
|
||||
|
||||
function markers(doc: Node): { kind: string; date: string }[] {
|
||||
const found: { kind: string; date: string }[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === 'date_marker') {
|
||||
found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string });
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('date markers >>/<< (issue #152)', () => {
|
||||
it('parses ISO due and start dates and round-trips canonically', () => {
|
||||
const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.');
|
||||
expect(markers(doc)).toEqual([
|
||||
{ kind: 'start', date: '2026-07-01' },
|
||||
{ kind: 'due', date: '2026-12-31' },
|
||||
]);
|
||||
const markdown = docToMarkdown(doc);
|
||||
expect(markdown).toContain('<<2026-07-01');
|
||||
expect(markdown).toContain('>>2026-12-31');
|
||||
});
|
||||
|
||||
it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => {
|
||||
const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.');
|
||||
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||
expect(docToMarkdown(doc)).toContain('>>2026-12-31');
|
||||
});
|
||||
|
||||
it('keeps a line-leading >>date out of blockquote parsing', () => {
|
||||
const doc = markdownToDoc('>>2026-12-31 ist die Deadline.');
|
||||
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||
let quotes = 0;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === 'blockquote') quotes += 1;
|
||||
});
|
||||
expect(quotes).toBe(0);
|
||||
// A real blockquote still works.
|
||||
const quote = markdownToDoc('> ein Zitat');
|
||||
let realQuotes = 0;
|
||||
quote.descendants((node) => {
|
||||
if (node.type.name === 'blockquote') realQuotes += 1;
|
||||
});
|
||||
expect(realQuotes).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves invalid calendar dates as plain text', () => {
|
||||
const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.');
|
||||
expect(markers(doc)).toEqual([]);
|
||||
expect(docToMarkdown(doc)).toContain('31.02.2026');
|
||||
});
|
||||
|
||||
it('renders HTML with kind, ISO date, and shows up in plain text', () => {
|
||||
const doc = markdownToDoc('Bis >>2026-12-31.');
|
||||
const html = docToHtml(doc);
|
||||
expect(html).toContain('data-date-marker="due"');
|
||||
expect(html).toContain('data-date="2026-12-31"');
|
||||
expect(docToPlainText(doc)).toContain('2026-12-31');
|
||||
});
|
||||
|
||||
it('works inside task list lines', () => {
|
||||
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]);
|
||||
const markdown = docToMarkdown(doc);
|
||||
expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||
});
|
||||
});
|
||||
@ -69,6 +69,14 @@ function renderInline(node: Node): string {
|
||||
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
||||
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
||||
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
||||
} else if (child.type.name === 'date_marker') {
|
||||
// A date marker (issue #152): static HTML shows the unambiguous ISO
|
||||
// date; locale-aware formatting happens where the viewer is known.
|
||||
const kind = escapeHtml(child.attrs.kind as string);
|
||||
const date = escapeHtml(child.attrs.date as string);
|
||||
out +=
|
||||
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
|
||||
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
|
||||
} else if (child.type.name === 'mention') {
|
||||
// A user mention (issue #150): static HTML shows the @username; the
|
||||
// stable user id rides along for consumers that can resolve it.
|
||||
|
||||
@ -192,6 +192,82 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */
|
||||
const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/;
|
||||
|
||||
/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */
|
||||
function isoDateOf(match: RegExpExecArray): string | null {
|
||||
const [year, month, day] = match[2]
|
||||
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
const valid =
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
if (!valid) return null;
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
return `${year}-${pad(month)}-${pad(day)}`;
|
||||
}
|
||||
|
||||
/** markdown-it inline rule for date markers (issue #152): `>>` = due date,
|
||||
* `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input
|
||||
* lenience. An invalid calendar date stays plain text. */
|
||||
function dateMarkerRule(state: StateInline, silent: boolean): boolean {
|
||||
const match = DATE_MARKER.exec(state.src.slice(state.pos));
|
||||
if (!match) return false;
|
||||
const iso = isoDateOf(match);
|
||||
if (!iso) return false;
|
||||
if (!silent) {
|
||||
const token = state.push('date_marker', '', 0);
|
||||
token.attrs = [
|
||||
['kind', match[1] === '>' ? 'due' : 'start'],
|
||||
['date', iso],
|
||||
];
|
||||
}
|
||||
state.pos += match[0].length;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph
|
||||
* with a due-date marker, not a nested blockquote — registered before
|
||||
* `blockquote` so markdown-it never sees the `>` as a quote. */
|
||||
function dateLineRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
|
||||
const max = state.eMarks[startLine]!;
|
||||
const match = DATE_MARKER.exec(state.src.slice(start, max));
|
||||
if (!match || !isoDateOf(match)) return false;
|
||||
if (silent) return true;
|
||||
// Consume the paragraph like markdown-it's own paragraph rule would.
|
||||
const terminatorRules = state.md.block.ruler.getRules('paragraph');
|
||||
let nextLine = startLine + 1;
|
||||
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) {
|
||||
if (state.sCount[nextLine]! - state.blkIndent > 3) continue;
|
||||
if (state.sCount[nextLine]! < 0) continue;
|
||||
let terminate = false;
|
||||
for (const rule of terminatorRules) {
|
||||
if (rule(state, nextLine, endLine, true)) {
|
||||
terminate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (terminate) break;
|
||||
}
|
||||
const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim();
|
||||
const open = state.push('paragraph_open', 'p', 1);
|
||||
open.map = [startLine, nextLine];
|
||||
const inline = state.push('inline', '', 0);
|
||||
inline.content = content;
|
||||
inline.map = [startLine, nextLine];
|
||||
inline.children = [];
|
||||
state.push('paragraph_close', 'p', -1);
|
||||
state.line = nextLine;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
|
||||
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||
|
||||
@ -308,6 +384,10 @@ function createTokenizer(): MarkdownIt {
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||
// `@username` mentions (issue #150).
|
||||
md.inline.ruler.before('link', 'mention', mentionRule);
|
||||
// `>>`/`<<` date markers (issue #152) …
|
||||
md.inline.ruler.before('link', 'date_marker', dateMarkerRule);
|
||||
// … and the guard that keeps a line-leading `>>date` out of blockquote.
|
||||
md.block.ruler.before('blockquote', 'date_line', dateLineRule);
|
||||
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
||||
// as plain text (issue #135).
|
||||
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
||||
@ -383,6 +463,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
||||
userId: tok.attrGet('userId') ?? '',
|
||||
}),
|
||||
},
|
||||
date_marker: {
|
||||
node: 'date_marker',
|
||||
getAttrs: (tok) => ({
|
||||
kind: tok.attrGet('kind') ?? 'due',
|
||||
date: tok.attrGet('date') ?? '',
|
||||
}),
|
||||
},
|
||||
transclusion: {
|
||||
node: 'transclusion',
|
||||
getAttrs: (tok) => ({
|
||||
@ -517,6 +604,10 @@ const markdownSerializer = new MarkdownSerializer(
|
||||
mention(state, node) {
|
||||
state.write(`@${node.attrs.username as string}`);
|
||||
},
|
||||
date_marker(state, node) {
|
||||
const prefix = node.attrs.kind === 'due' ? '>>' : '<<';
|
||||
state.write(`${prefix}${node.attrs.date as string}`);
|
||||
},
|
||||
transclusion(state, node) {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
|
||||
@ -11,6 +11,8 @@ export function docToPlainText(doc: Node): string {
|
||||
}
|
||||
// A mention reads as `@username` (issue #150), so search finds it.
|
||||
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
|
||||
// A date marker contributes its ISO date (issue #152).
|
||||
if (leaf.type.name === 'date_marker') return leaf.attrs.date as string;
|
||||
return '';
|
||||
});
|
||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
@ -256,6 +256,38 @@ export const editorSchema = new Schema({
|
||||
},
|
||||
},
|
||||
|
||||
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
|
||||
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
|
||||
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
|
||||
// the editor, where the viewer's language is known.
|
||||
date_marker: {
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
attrs: {
|
||||
kind: { validate: 'string' },
|
||||
date: { validate: 'string' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'span[data-date-marker]',
|
||||
getAttrs: (dom) => ({
|
||||
kind: dom.getAttribute('data-date-marker'),
|
||||
date: dom.getAttribute('data-date'),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const kind = node.attrs.kind as string;
|
||||
const date = node.attrs.date as string;
|
||||
return [
|
||||
'span',
|
||||
{ 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` },
|
||||
`${kind === 'due' ? '»' : '«'} ${date}`,
|
||||
];
|
||||
},
|
||||
},
|
||||
|
||||
// `@username` user mention (issue #150). An inline atom carrying the
|
||||
// mentioned user's stable id plus the username as serialization/display
|
||||
// fallback. Live display-name resolution happens in the editor where a
|
||||
|
||||
Loading…
Reference in New Issue
Block a user