GrRichText

Пакет: @feugene/granularity-editorспутникГруппа: Прочее

Берут, когда текст, который читатель увидит оформленным.

Когда брать

  • текст, который читатель увидит оформленным — описание, статья, объявление: абзацы, списки и выделение несут смысл, и терять их при сохранении нельзя;
  • комментарий или заметка с минимальным наборомschema="minimal": начертание, ссылка, список, и ничего, чем можно испортить страницу;
  • поле формы, а не отдельный экран — компонент читает GrFormField, отдаёт значение в форму скрытым полем и подчиняется disabled, readonly, invalid наравне с остальными контролами;
  • свой набор возможностейextensions добавляет расширения TipTap к схеме, и компонент остаётся тем же полем со своим тулбаром и клавиатурой.

Когда взять другое

НужноБерите
Простой текст в несколько строк, без разметкиGrTextarea
Строка в одну линиюGrInput
Показать готовый код с подсветкой, а не править текстGrCodeBlock
Набор своих меток-тегов, а не текстGrInputTag

Схема — она же санитайзер

Отдельного санитайзера в пакете нет, и это не упущение. ProseMirror разбирает вход по схеме: узел или марка, которых в ней нет, отбрасываются при разборе, а на выход документ сериализуется из того же дерева. <script>, <iframe> и <img> не переживают вставки — проверено тестом, а не обещано.

Отсюда следствие, которое стоит знать: чего нет в схеме, того не будет и в значении. Вставили статью с картинками в поле со схемой minimal — останется текст. Это осознанный размен: поле, принимающее произвольную разметку, ломает вёрстку страницы, на которой её потом покажут.

Две готовые схемы

minimal — начертание, ссылка, список. article добавляет структуру: заголовки, цитату, блок кода.

Заголовка первого уровня не даёт ни одна. h1 на странице один и принадлежит ей, а не полю ввода внутри неё; редактор, позволяющий вставить второй, ломает структуру документа у того, кто просто печатал текст.

Тулбар собран из схемы, а не написан разметкой

Кнопки строятся по списку действий самой схемы. Напиши их руками — и первая же правка схемы разошлась бы с панелью молча: кнопка осталась, а команды за ней больше нет.

Панель переносится группами целиком, а не кнопка за кнопкой. flex-wrap по отдельным кнопкам рвал бы ряд там, где кончилось место, и разлучал бы кнопки одного смысла: «Заголовок» в одной строке, «Подзаголовок» в другой, причём без разделителя между ними. Обёртка группы этого не допускает — на узком поле строки читаются теми же блоками, что и один ряд.

Тулбар — role="toolbar" с одной остановкой Tab: внутри ходят стрелками. Иначе до самого текста пришлось бы добираться десятком нажатий — у «статьи» десять кнопок. Активный формат объявлен aria-pressed, а не только подсветкой: подсветка для скринридера не существует.

Пузырёк держится на фокусе в тексте

toolbar="bubble" ставит панель у выделения, "both" — и сверху, и у выделения.

Пузырёк живёт ровно пока в поле есть непустое выделение и фокус: ухода фокуса достаточно, чтобы он погас. Отсюда два неочевидных следствия, и оба сидят в коде:

  • панель не забирает фокус при открытии — иначе гасила бы себя в том же кадре, в котором открылась;
  • кнопка пузырька отменяет mousedown. Без этого фокус уходил бы на кнопку, и пузырёк исчезал под курсором после первого же формата — второй подряд применить нечем. Панели сверху это не нужно: она от фокуса не зависит.

Кнопки пузырька — всегда самой мелкой ступени, независимо от размера поля. Панель сверху живёт в раме поля и растёт вместе с ним, а пузырёк висит над текстом, который читают, и там уместна наименьшая площадь, дающая цель нажатия 28×28 — выше требуемых WCAG 2.2 двадцати четырёх. Есть и арифметическая причина: панель поповера ограничена по ширине, и десяток кнопок ступени поля в неё не помещался.

Esc пузырёк закрывает, клик вне — нет: слушать пришлось бы click, а именно им заканчивается протяжка выделения, и панель закрывалась бы ровно в тот момент, когда должна появиться.

Клавиатурный путь к форматам в этом режиме — горячие клавиши: панель у выделения остановкой Tab не является. Нужны обе дороги — toolbar="both".

Шапка и подвал — зоны поля, а не блоки рядом

Слоты #header и #footer дают полосы над областью ввода и под ней, внутри той же рамки:

<GrRichText v-model="value">
  <template #header>Кому: <strong>[email protected]</strong></template>
  <template #footer>Знаков: {{ length }}</template>
</GrRichText>

Раньше зон было ровно две — тулбар и текст, — поэтому подпись со счётчиком ставили снаружи рамки. Визуально они оказывались отдельным элементом: рамка обводила только текст, и связь с полем держалась на близости, а не на разметке.

Зоны отбиты рамкой изнутри, как тулбар, — линия принадлежит границе между зонами, а не самой зоне: у края поля она сошлась бы со скруглением рамки. Фона у них нет намеренно: --gr-muted подложен тулбару, потому что он панель управления, а здесь содержимое потребителя, и вторая подложка спорила бы с ним.

Клавиатура зон — забота потребителя: тулбар водит фокус ровером по своим кнопкам, а что положено в шапку, компонент не знает.

Форма значения задаётся пропом

output="html" (по умолчанию) — строка разметки; output="json" — документ TipTap. Тот же приём, что у valueAdapter в granularity-chrono: форму значения выбирает потребитель, а не поведение пользователя.

В нативную форму значение уходит строкой в любом режиме: скрытое поле не умеет объектов.

Содержимое не печатается на сервере

ProseMirror требует DOM, поэтому редактор поднимается после монтирования, а серверная разметка — пустая оболочка с data-allow-mismatch.

v-html в пакете нет намеренно: печатать чужую разметку ради первого кадра значило бы завести единственную XSS-поверхность ровно там, где данные приходят от пользователя. Поле — это ввод, а не публикация.

Свои расширения

extensions добавляет расширения TipTap к схеме, а не заменяет её:

<script setup lang="ts">
import { CharacterCount } from '@tiptap/extensions'
</script>

<template>
  <GrRichText v-model="text" :extensions="[CharacterCount.configure({ limit: 500 })]" />
</template>

Готовые расширения перечислены в каталоге TipTap, своё пишется по руководству. Пакет их не оборачивает: что отдали, то и получит редактор.

Смена набора — как и смена schemaпересобирает редактор: схема ProseMirror неизменяема, из неё выведены и документ, и команды. Текст переносится разметкой и проходит разбор заново, поэтому узел, которого в новой схеме нет, отбрасывается — то же правило, что и при вставке.

Кнопку для своего расширения тулбар не покажет: он строится по схеме, и кнопка без команды за ней была бы обманом. Своя кнопка ставится слотом #action-<key> либо собственной панелью поверх инстанса из defineExpose.

Границы

  • не CMS: ни картинок, ни упоминаний, ни таблиц в этом релизе. Расширение TipTap потребитель добавляет сам через extensions, а инстанс редактора компонент отдаёт через defineExpose. Кнопку для своего расширения тулбар не покажет: он строится по схеме, а не по набору расширений;
  • без совместного редактирования: одновременная правка двумя людьми требует транспорта и разрешения конфликтов, а это не задача поля ввода;
  • без просмотрщика markdown: показ сохранённого текста закрывает отдельный компонент, и он ещё не написан.

Установка

npm i @feugene/granularity-editor

Импорт

import { GrRichText } from '@feugene/granularity-editor/components/GrRichText'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 5

Basic

Basic
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrRichTextSize } from '@feugene/granularity-editor'

// `GrRichText`, `GrFormField`, `GrRadioGroup` подставляются авто-импортом.

/**
 * Поле с тулбаром и небольшой конструктор под ним.
 *
 * Панель модели тут не для красоты: значение — размеченный текст, и увидеть, что
 * именно уходит наружу, иначе нельзя. Переключатель `output` меняет **форму**
 * этого значения, и разница видна в той же панели.
 */
const value = ref<string | Record<string, unknown>>('<p>Наберите текст и примените <strong>формат</strong>.</p>')

const size = ref<GrRichTextSize>('md')
const toolbar = ref<'true' | 'false' | 'bubble' | 'both'>('true')
const output = ref<'html' | 'json'>('html')

const sizeOptions = [
  { value: 'xs', label: 'XS' },
  { value: 'sm', label: 'SM' },
  { value: 'md', label: 'MD' },
  { value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrRichTextSize, label: string }>

const toolbarOptions = [
  { value: 'true', label: 'Панель' },
  { value: 'bubble', label: 'Пузырёк' },
  { value: 'both', label: 'Оба' },
  { value: 'false', label: 'Нет' },
] satisfies Array<{ value: 'true' | 'false' | 'bubble' | 'both', label: string }>

const outputOptions = [
  { value: 'html', label: 'HTML' },
  { value: 'json', label: 'JSON' },
] satisfies Array<{ value: 'html' | 'json', label: string }>

/** `toolbar` принимает и булево, и строку — радиогруппа отдаёт только строки. */
const toolbarProp = computed(() => {
  if (toolbar.value === 'true')
    return true
  if (toolbar.value === 'false')
    return false

  return toolbar.value
})

const model = computed(() => (typeof value.value === 'string'
  ? value.value
  : JSON.stringify(value.value, null, 2)))

/**
 * Форма значения меняется вместе с `output`: старое значение остаётся в прежнем
 * виде до первой правки, и компонент об этом честно предупреждает в консоли.
 * Поэтому переключатель сразу приводит модель к новой форме.
 */
function onOutputChange(next: 'html' | 'json'): void {
  output.value = next
  value.value = next === 'json'
    ? { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Наберите текст.' }] }] }
    : '<p>Наберите текст.</p>'
}
</script>

<template>
  <div class="grid gap-4">
    <GrRichText
      v-model="value"
      schema="article"
      :size="size"
      :toolbar="toolbarProp"
      :output="output"
      aria-label="Описание"
    />

    <div class="showcase-demo-panel grid gap-4 rounded-[var(--gr-radius-lg)] border p-4 sm:grid-cols-3">
      <GrFormField label="size">
        <GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
      </GrFormField>

      <GrFormField label="toolbar">
        <GrRadioGroup v-model="toolbar" :options="toolbarOptions" variant="button" size="sm" />
      </GrFormField>

      <GrFormField label="output">
        <GrRadioGroup
          :model-value="output"
          :options="outputOptions"
          variant="button"
          size="sm"
          @update:model-value="onOutputChange($event as 'html' | 'json')"
        />
      </GrFormField>
    </div>

    <pre class="max-h-64 overflow-auto rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ model }}</pre>

    <p class="showcase-demo-text text-sm">
      <strong>output</strong> меняет форму значения, а не поведение: <code>html</code> отдаёт строку
      разметки, <code>json</code> — документ TipTap. В нативную форму значение уходит строкой в любом
      режиме: скрытое поле не умеет объектов.
    </p>

    <p class="showcase-demo-text text-sm">
      <strong>toolbar</strong> решает, где живут кнопки: панель сверху, пузырёк у выделения, оба или
      ничего. Выделите фрагмент в режиме «Пузырёк» — панель появится у самого текста. Горячие клавиши
      работают всегда: <strong>Ctrl/Cmd + B</strong> и <strong>I</strong>.
    </p>

    <p class="showcase-demo-text text-sm">
      Тулбар — одна остановка <strong>Tab</strong>, внутри ходят стрелками: у «статьи» десять кнопок,
      и без этого до самого текста пришлось бы добираться десятью нажатиями. Активный формат объявлен
      <code>aria-pressed</code>, а не только подсветкой — подсветки скринридер не видит.
    </p>
  </div>
</template>

Extensions

Extensionsзависит от окружения витрины
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'

import { CharacterCount, Focus, Selection } from '@tiptap/extensions'
import type { GrRichTextExtension } from '@feugene/granularity-editor'

/**
 * Свои расширения TipTap поверх схемы.
 *
 * Переключатели включают их **на живом поле**: смена набора пересобирает
 * редактор, а текст переносится разметкой и проходит разбор по новой схеме.
 * Схему ProseMirror подменить нельзя — из неё выведены и документ, и команды.
 */
const value = ref('<p>Включите расширение и продолжайте печатать.</p>')

const LIMIT = 120

const catalogue = [
  {
    key: 'characterCount',
    title: 'CharacterCount',
    about: `Счётчик символов и потолок. Здесь предел — ${LIMIT}: дальше ввод просто не проходит.`,
    make: () => CharacterCount.configure({ limit: LIMIT }),
  },
  {
    key: 'focus',
    title: 'Focus',
    about: 'Помечает абзац под курсором классом `has-focus`. Оформление — ваше: здесь это полоса слева.',
    make: () => Focus.configure({ className: 'has-focus', mode: 'shallowest' }),
  },
  {
    key: 'selection',
    title: 'Selection',
    about: 'Оставляет выделение видимым, когда фокус ушёл из поля. Выделите текст и щёлкните мимо.',
    make: () => Selection,
  },
] as const

type ExtensionKey = typeof catalogue[number]['key']

const enabled = ref<ExtensionKey[]>([])

const extensions = computed<GrRichTextExtension[]>(() => (
  catalogue
    .filter(entry => enabled.value.includes(entry.key))
    .map(entry => entry.make() as GrRichTextExtension)
))

/** Инстанс редактора наружу отдаёт сам компонент — счётчик живёт в нём. */
const field = shallowRef<{ editor: { storage: Record<string, { characters?: () => number }> } } | null>(null)

const typed = ref(0)

// `flush: 'post'` — не педантизм: включение расширения пересобирает редактор в
// собственном наблюдателе компонента, и до этого момента счётчика в хранилище
// ещё нет. Без задержки поле показывало «0» при непустом тексте.
watch([value, enabled], () => {
  const storage = field.value?.editor?.storage?.characterCount

  typed.value = typeof storage?.characters === 'function' ? storage.characters() : 0
}, { flush: 'post' })

const counted = computed(() => enabled.value.includes('characterCount'))
</script>

<template>
  <div class="showcase-editor-extensions grid gap-4">
    <GrRichText
      ref="field"
      v-model="value"
      schema="article"
      :extensions="extensions"
      aria-label="Текст с расширениями"
    />

    <div class="showcase-demo-panel grid gap-3 rounded-[var(--gr-radius-lg)] border p-4">
      <div class="showcase-demo-title text-sm font-semibold">
        Расширения
      </div>

      <label v-for="entry in catalogue" :key="entry.key" class="flex items-start gap-3">
        <GrCheckbox
          :model-value="enabled.includes(entry.key)"
          :aria-label="entry.title"
          @update:model-value="enabled = $event ? [...enabled, entry.key] : enabled.filter(k => k !== entry.key)"
        />
        <span class="grid gap-0.5">
          <code class="text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">{{ entry.title }}</code>
          <span class="showcase-demo-text text-sm">{{ entry.about }}</span>
        </span>
      </label>

      <p v-if="counted" class="showcase-demo-text text-sm">
        Набрано символов: <strong>{{ typed }}</strong> из {{ LIMIT }}
      </p>
    </div>

    <p class="showcase-demo-text text-sm">
      Набор расширений задаётся пропом <code>extensions</code> и добавляется <strong>к схеме</strong>,
      а не заменяет её. Кнопку для своего расширения тулбар не покажет: он строится по схеме, и
      кнопка без команды за ней была бы обманом.
    </p>

    <p class="showcase-demo-text text-sm">
      Смена набора пересобирает редактор: схема ProseMirror неизменяема — из неё выведены и документ,
      и команды. Текст переносится разметкой и проходит разбор заново, поэтому узел, которого в новой
      схеме нет, отбрасывается — то же правило, что и при вставке.
    </p>

    <p class="showcase-demo-text text-sm">
      <code>Focus</code> и <code>Selection</code> сами ничего не рисуют — они вешают класс, а
      оформление остаётся за вами. В этом демо классы оформлены парой правил рядом; без них
      расширение честно работает, но выглядит как выключенное.
    </p>

    <p class="showcase-demo-text text-sm">
      <code>TrailingNode</code> в списке нет намеренно: он уже входит в <code>StarterKit</code>, то
      есть в саму схему. Добавить его пропом можно, но переключатель ничего бы не менял — под
      заголовком и цитатой пустой абзац есть и без него.
    </p>

    <p class="showcase-demo-text text-sm">
      Полный список готовых расширений —
      <GrLink href="https://tiptap.dev/docs/editor/extensions" external>каталог TipTap</GrLink>; как
      написать своё —
      <GrLink href="https://tiptap.dev/docs/editor/extensions/custom-extensions" external>руководство по расширениям</GrLink>.
      Пакет ничего в них не оборачивает: <code>extensions</code> принимает их как есть, а инстанс
      редактора компонент отдаёт через <code>defineExpose</code> — для своих команд и плагинов.
    </p>
  </div>
</template>

<!--
  Классы вешают сами расширения, а рисует их потребитель — в этом и смысл
  `Focus` и `Selection`. Стиль не `scoped`: узлы создаёт ProseMirror в рантайме,
  атрибут области видимости на них не попадает.
-->
<style>
.showcase-editor-extensions .has-focus {
  border-left: 2px solid var(--gr-primary);
  padding-left: 0.5rem;
  margin-left: -0.625rem;
}

.showcase-editor-extensions .selection {
  background: var(--gr-accent);
  border-radius: var(--gr-radius-sm);
}
</style>

Frame

Frame
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBadge, GrButton } from '@feugene/granularity'

/**
 * Шапка и подвал поля — зоны внутри рамки, а не блоки рядом с ней.
 *
 * Снаружи подпись и счётчик читались отдельным элементом: рамка обводила только
 * текст, и связь с полем держалась на близости. Здесь они внутри той же рамки и
 * отбиты линией, как тулбар.
 */
const value = ref('<p>Черновик письма клиенту.</p>')

const plainLength = computed(() => value.value.replace(/<[^>]*>/g, '').length)
</script>

<template>
  <div class="grid gap-4">
    <GrRichText v-model="value" schema="article" aria-label="Письмо">
      <template #header>
        <div class="flex items-center justify-between gap-2">
          <span class="text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">
            Кому: <strong>[email protected]</strong>
          </span>
          <GrBadge tone="warning">Черновик</GrBadge>
        </div>
      </template>

      <template #footer>
        <div class="flex items-center justify-between gap-2">
          <span class="showcase-demo-text text-sm">Знаков: {{ plainLength }}</span>
          <GrButton size="xs" variant="outline">Отправить</GrButton>
        </div>
      </template>
    </GrRichText>

    <p class="showcase-demo-text text-sm">
      Обе зоны необязательны и включаются самим фактом слота. Линия принадлежит границе между
      зонами, а не самой зоне: у края поля она сошлась бы со скруглением рамки.
    </p>
  </div>
</template>

Schema

Schema
<script setup lang="ts">
import { ref } from 'vue'

/**
 * Схема — она же санитайзер.
 *
 * Одно и то же значение в двух схемах: слева «минимум», справа «статья».
 * Вставка одинаковая, результат разный — и это не фильтр поверх, а разбор.
 */
const DIRTY = '<h2>Заголовок</h2><p>Текст с <strong>форматом</strong>.</p>'
  + '<blockquote><p>Цитата</p></blockquote>'
  + '<script>alert(1)<\/script><iframe src="https://example.com"></iframe>'

const minimal = ref(DIRTY)
const article = ref(DIRTY)
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-4 lg:grid-cols-2">
      <div class="grid gap-2">
        <span class="showcase-demo-text text-sm font-semibold">minimal</span>
        <GrRichText v-model="minimal" schema="minimal" aria-label="Минимальная схема" />
      </div>

      <div class="grid gap-2">
        <span class="showcase-demo-text text-sm font-semibold">article</span>
        <GrRichText v-model="article" schema="article" aria-label="Схема статьи" />
      </div>
    </div>

    <p class="showcase-demo-text text-sm">
      В оба поля пришло одно и то же значение — с заголовком, цитатой, <code>&lt;script&gt;</code> и
      <code>&lt;iframe&gt;</code>. Слева осталась только строчная разметка, справа — ещё заголовок и
      цитата. Скрипта и фрейма нет нигде: <strong>узлы вне схемы не переживают разбора</strong>.
    </p>

    <p class="showcase-demo-text text-sm">
      Отдельного санитайзера в пакете поэтому нет. Разбор идёт по схеме, а на выход документ
      сериализуется из того же дерева — очистка получается тем же механизмом, ради которого редактор
      и выбран. Обратная сторона: чего нет в схеме, того не будет и в значении.
    </p>
  </div>
</template>

Toolbar

Toolbar
<script setup lang="ts">
import { computed, ref } from 'vue'

import { createSchema, type GrRichTextAction } from '@feugene/granularity-editor'

/**
 * Что тулбар умеет из коробки — полным списком.
 *
 * Таблица строится из той же схемы, по которой собирается панель: разойтись они
 * не могут по построению. Допиши действие в схему — строка появится сама.
 */
const value = ref('<h2>Попробуйте кнопки</h2><p>Выделите фрагмент и примените формат.</p>')

const minimal = createSchema('minimal').actions
const article = createSchema('article').actions

const groupTitles: Record<GrRichTextAction['group'], string> = {
  inline: 'Начертание',
  block: 'Структура',
  list: 'Списки',
}

/** `Mod` — `⌘` на Apple и `Ctrl` на остальных: показываем обе записи. */
function shortcut(action: GrRichTextAction): string {
  return action.shortcut.replace('Mod', '⌘/Ctrl').replace(/-/g, ' + ')
}

function inMinimal(action: GrRichTextAction): boolean {
  return minimal.some(entry => entry.key === action.key)
}

const rows = computed(() => article.map(action => ({
  action,
  group: groupTitles[action.group],
  shortcut: shortcut(action),
  schemas: inMinimal(action) ? 'minimal, article' : 'article',
})))
</script>

<template>
  <div class="grid gap-4">
    <GrRichText v-model="value" schema="article" toolbar="both" aria-label="Все кнопки" />

    <div class="overflow-x-auto">
      <table class="w-full border-collapse text-[length:var(--gr-control-text-sm)] leading-[var(--gr-control-leading-sm)]">
        <thead>
          <tr class="border-b border-[var(--gr-brd)] text-left">
            <th class="py-2 pr-3 font-semibold">Кнопка</th>
            <th class="py-2 pr-3 font-semibold">Группа</th>
            <th class="py-2 pr-3 font-semibold">Команда TipTap</th>
            <th class="py-2 pr-3 font-semibold">Клавиши</th>
            <th class="py-2 font-semibold">Схемы</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="row in rows" :key="row.action.key" class="border-b border-[var(--gr-brd)]">
            <td class="py-2 pr-3">{{ row.action.labelFallback }}</td>
            <td class="showcase-demo-text py-2 pr-3">{{ row.group }}</td>
            <td class="py-2 pr-3"><code>{{ row.action.command }}</code></td>
            <td class="py-2 pr-3"><code>{{ row.shortcut }}</code></td>
            <td class="showcase-demo-text py-2">{{ row.schemas }}</td>
          </tr>
        </tbody>
      </table>
    </div>

    <p class="showcase-demo-text text-sm">
      Таблица построена из той же схемы, по которой собирается панель: разойтись они не могут по
      построению. Кнопка без команды за ней тут невозможна — это и есть причина, по которой тулбар
      описан данными, а не написан разметкой.
    </p>

    <p class="showcase-demo-text text-sm">
      Горячие клавиши приходят от расширений TipTap, а не от пакета: они работают и при
      <code>toolbar="false"</code>. Кроме перечисленного из коробки идут отмена и повтор
      (<code>⌘/Ctrl + Z</code> и <code>⌘/Ctrl + Shift + Z</code>), перенос строки внутри абзаца
      (<code>Shift + Enter</code>), горизонтальная черта и ссылка — последние две без своей кнопки:
      черта ставится правилом ввода <code>---</code>, ссылка живёт маркой и ждёт своего интерфейса.
    </p>

    <p class="showcase-demo-text text-sm">
      Схема <code>minimal</code> оставляет только начертание и списки, <code>article</code> добавляет
      структуру. Заголовка первого уровня не даёт ни одна: <code>h1</code> принадлежит странице, а не
      полю внутри неё.
    </p>
  </div>
</template>

Документация компонентаВсе компоненты