remark-speech
v0.13.0
Published
Remark plugin to add direct speech with metadata for speaker element in markdown
Maintainers
Readme
remark-speech
remark-speech is a remark plugin that adds direct-speech syntax with speaker metadata to Markdown. It includes parsing, Markdown serialization, and configurable HTML rendering through remark-rehype.
Install
npm install remark-speech remark-parse remark-stringify unifiedSample
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { remarkDirectSpeech } from "remark-speech";
const input = `
[[Fay]]"Es ist *Zeit*", säuselte eine helle Stimme, um sich
selbst dazu zu bringen, sich zu rühren.
`;
const file = await unified()
.use(remarkParse)
.use(remarkDirectSpeech)
.use(remarkStringify)
.process(input);
console.log(String(file));Input and output keep the same direct-speech block syntax, while the plugin adds AST support for working with speaker metadata.
Block speech
The above is used for "normal" direct speech that is used in most texts. The plugin also supports block speech, which is used for longer speeches or monologues. These can contain multiple paragraphs and generally all block elements. The syntax for block speech is similar to the block quote syntax and is as follows:
Dann holte er aus und begann zu sprechen:
| [[Johnathan]]
| Veränderung ist wie der Wind: Manchmal spürt man sie kaum, und doch bewegt sie ganze
| Bäume. Sie kommt leise, aber ihre Wirkung ist unaufhaltsam. Wer sich ihr widersetzt,
| wird irgendwann gebrochen. Wer sie annimmt, lernt zu fliegen. Die Frage ist nicht, ob
| wir uns ändern können, sondern ob wir den Mut haben, uns zu ändern – bevor das Leben es
| für uns tut.
|
| Ein einzelnes Licht kann die Dunkelheit erhellen, aber viele Lichter zusammen schaffen
| eine Sonne. Gemeinschaft bedeutet nicht, dass wir alle gleich sind, sondern dass
| wir uns gegenseitig stärken – in Schwäche, in Zweifel, in Freude. Denn niemand von
| uns ist so stark wie wir alle zusammen.
|
| …
Sein Redefluss sollte noch viele Stunden ungebrochen weitergehen,
und doch war es nur ein Anfang.The marker is configurable, depending on the context it may be more appropriate
to use a different marker than | for block speech.
The default is |, but if your markdown formatter complains you could go to >.
Configuration
Both inline and block speech support a requireSpeaker option.
Inline options
import { remarkDirectSpeech } from "remark-speech";
unified().use(remarkDirectSpeech, {
spoken: {
quote: '"',
closingQuote: '"',
requireSpeaker: "optional", // "always" | "never" | "optional"
},
});Block options
import { remarkDirectSpeechBlock } from "remark-speech";
unified().use(remarkDirectSpeechBlock, {
spoken: {
marker: "|",
requireSpeaker: "optional", // "always" | "never" | "optional"
},
});requireSpeaker behavior
always: a speaker in[[...]]is required.never: a speaker in[[...]]is not allowed.optional: both forms are allowed.
If requireSpeaker is omitted, the default is optional.
When a node has an empty speaker, serialization omits [[...]].
HTML Rendering
Install remark-rehype and rehype-stringify to render HTML:
npm install remark-rehype rehype-stringifyPass the handlers returned by directSpeechToHast to remark-rehype:
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import {
remarkDirectSpeech,
remarkDirectSpeechBlock,
directSpeechToHast,
} from "remark-speech";
const file = await unified()
.use(remarkParse)
.use(remarkDirectSpeech)
.use(remarkDirectSpeechBlock)
.use(remarkRehype, {
handlers: directSpeechToHast({
inline: {
tag: {
name: "span",
attributes: {
class: ({ speechType, speaker }) => `${speechType}-${speaker}`,
"data-speaker": ({ speaker }) => speaker,
},
},
openingChar: "‚",
closingChar: "’",
charactersPosition: "inside",
},
block: {
tag: { name: "blockquote", attributes: { class: "speech" } },
},
}),
})
.use(rehypeStringify)
.process('[[Fay]]"Hello **world**"');
console.log(String(file));
// <p><span class="spoken-Fay" data-speaker="Fay">‚Hello <strong>world</strong>’</span></p>inline and block accept the same rendering options:
tag: an object withnameandattributes, orfalseto omit the wrapper. Defaults tospanfor inline speech anddivfor block speech.tag.attributes: an object of static attribute values or callbacks receiving the speech node, includingspeechType,speaker, andtype. Usespeakerandattributes(rather thanspeekerandattrbutes). Bothclassand HAST'sclassNameare supported.openingCharandclosingChar: optional text placed before and after the speech. These may contain multiple characters and default to empty strings.charactersPosition:"inside"(default) puts the characters within the tag;"outside"puts them around the tag. Withtag: false, they surround the unwrapped content.
Nested Markdown is converted normally. Characters and attribute values are escaped by the HTML serializer, not injected as raw HTML. For block speech, inside characters are children of the wrapper around all paragraphs; outside characters are siblings of the wrapper. remark-rehype may insert line endings between these block-level siblings.
Tags and attributes are trusted application configuration, not an HTML sanitizer. Choose tags appropriate to the content: block speech can contain paragraphs and other block elements.
Error Reporting
The plugin validates speaker rules during processing and emits parser-visible diagnostics on the VFile.
- Violations are added to
file.messages. - Messages are marked as fatal (
message.fatal = true) so consuming programs can treat them as errors. - This is structured diagnostics, not log-only warnings.
Example:
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { remarkDirectSpeech } from "remark-speech";
const file = unified()
.use(remarkParse)
.use(remarkDirectSpeech, {
spoken: {
quote: '"',
requireSpeaker: "always",
},
})
.use(remarkStringify)
.processSync('"No speaker"');
if (file.messages.length > 0) {
for (const message of file.messages) {
console.error(String(message));
}
}