Спецификация: типограф русского языка на .NET

Документ фиксирует требования и архитектурные решения, согласованные до начала реализации. На него ссылается план реализации.

Имена файлов документации — латиницей (совместимость с DocFX, CI и URL), содержимое — на русском.

1. Цель

Библиотека, которая приводит русский текст к типографским нормам: кавычки-ёлочки с правильной вложенностью, тире вместо дефисов там, где это тире, неразрывные пробелы в местах, где перенос строки недопустим, корректные символы вместо суррогатов ((c), ->, 1/2, x).

Работает и с обычным текстом, и с HTML-фрагментами. Закрытый «Типограф» Артемия Лебедева задаёт семантику параметров (entityType, useBr, useP, maxNobr), но не форму API.

2. Приоритеты (в порядке убывания)

  1. Производительность
  2. Оптимальность и эффективность кода
  3. Developer experience
  4. Безопасность
  5. Структурность репозитория
  6. Полнота документации
  7. Документация на русском языке

Приоритеты — критерий разрешения споров: если удобство API требует аллокации в горячем пути, побеждает производительность. Гарантии раздела 8 размену не подлежат.

3. Источники истины

Приоритет при расхождении: ГОСТ Р 7.0.110-2025 ⇒ Мильчин и Чельцова ⇒ практика студии Лебедева ⇒ JS-typograf.

Источник Что берём
ГОСТ Р 7.0.110-2025 «Требования к типографическому оформлению» Неразрывные пробелы (9.4–9.5), диапазоны (14.3), дефис и тире (16.1–16.2), кавычки и вложенность (16.3), знак номера (16.4), скобки (16.5)
Мильчин А. Э., Чельцова Л. К. «Справочник издателя и автора» Сокращения, числа и знаки, даты и время, цитаты
type.today, «Справочник: кавычки» Три уровня вложенности «» → „“ → ‘’, отличие кавычек от штрихов (дюйм, фут, минута, секунда)
typograf/typograf (MIT) Имена и состав 107 правил, набор регрессионных случаев
typograf.artlebedev.ru Семантика entityType/useBr/useP/maxNobr, поведение как эталон-оракул

Каждое правило в коде несёт ссылку на пункт источника; ссылка попадает в генерируемый справочник правил.

Известное расхождение, зафиксированное намеренно: ГОСТ 14.3 требует диапазон без отбивки (1941—1945), практика веба ставит неразрывный пробел (1941 — 1945). RuleSet.Default следует ГОСТу, RuleSet.Lebedev — практике.

4. Публичный API

Пространство имён Typographer, и главный тип называется так же. Совпадение имени типа с именем пространства имён намеренное: вызов Typographer.Html(text) читается ровно как название пакета, и ради этого стоит потерпеть CA1724.

4.1 Точки входа

// Статический фасад — дефолтные настройки, ноль церемоний
public static class Typographer
{
    public static string Html(string html);
    public static string PlainText(string text);
}

// Настраиваемый, иммутабельный, потокобезопасный
public sealed class HtmlTypographer
{
    public HtmlTypographer(HtmlOptions? options = null);
    public static HtmlTypographer Default { get; }

    public string Process(string html);
    public void Process(ReadOnlySpan<char> html, IBufferWriter<char> destination);
}

public sealed class TextTypographer
{
    public TextTypographer(TextOptions? options = null);
    public static TextTypographer Default { get; }

    public string Process(string text);
    public void Process(ReadOnlySpan<char> text, IBufferWriter<char> destination);
}

Разделение на два типа — не стилистика: UseBr, UseP, MaxNobr и Entities не имеют смысла вне HTML, и в TextOptions их физически нет. Невалидная комбинация не компилируется.

4.2 Настройки

public sealed record TextOptions
{
    public RuleSet Rules { get; init; } = RuleSet.Default;
    /// <summary>Предел длины результата в символах. 0 — без ограничения.</summary>
    public int MaxOutputLength { get; init; }
    public static TextOptions Default { get; } = new();
}

public sealed record HtmlOptions
{
    public RuleSet Rules { get; init; } = RuleSet.Default;
    /// <summary>Как выводить типографские символы. Соответствует entityType веб-сервиса Лебедева.</summary>
    public EntityMode Entities { get; init; } = EntityMode.Symbols;
    /// <summary>Заменять перевод строки на br.</summary>
    public bool UseBr { get; init; }
    /// <summary>Оборачивать абзацы в p.</summary>
    public bool UseP { get; init; }
    /// <summary>Максимальное число слов, объединяемых в nobr. 0 — не объединять.</summary>
    public int MaxNobr { get; init; }
    public int MaxOutputLength { get; init; }
    public static HtmlOptions Default { get; } = new();
}

public enum EntityMode
{
    /// <summary>Символами: « » и U+00A0. Соответствует entityType = 0.</summary>
    Symbols = 0,
    /// <summary>Буквенными кодами: laquo, mdash, nbsp. entityType = 1.</summary>
    Named = 1,
    /// <summary>Числовыми кодами: 171, 8212, 160. entityType = 2.</summary>
    Numeric = 2,
    /// <summary>Смешанно: невидимые символы кодами, видимые — символами. entityType = 3.</summary>
    Mixed = 3,
}

4.3 Правила

public readonly struct RuleId : IEquatable<RuleId>
{
    /// <summary>Имя вида «ru/nbsp/abbr». Совпадает с именами JS-typograf.</summary>
    public string Name { get; }

    public static bool TryParse(string name, out RuleId rule);

    public static class Common { public static class Nbsp { public static RuleId AfterShortWord { get; } } }
    public static class Ru { public static class Nbsp { public static RuleId Abbr { get; } } }
}

public sealed class RuleSet : IReadOnlyCollection<RuleId>
{
    public static RuleSet Default { get; }   // безопасная типографика
    public static RuleSet Lebedev { get; }   // поведение веб-сервиса Лебедева
    public static RuleSet Typograf { get; }  // паритет дефолтов с JS-typograf
    public static RuleSet Gost { get; }      // строго по ГОСТ Р 7.0.110-2025
    public static RuleSet All { get; }
    public static RuleSet Minimal { get; }   // кавычки, тире, многоточие
    public static RuleSet None { get; }

    public RuleSet With(params RuleId[] rules);
    public RuleSet Without(params RuleId[] rules);
    public bool Contains(RuleId rule);
}

RuleSet иммутабелен, внутри — битовая маска на 128 бит (два ulong), проверка включённости правила стоит одну инструкцию и не аллоцирует.

Пример:

var typographer = new HtmlTypographer(new HtmlOptions
{
    Entities = EntityMode.Named,
    UseBr = true,
    MaxNobr = 3,
    Rules = RuleSet.Default
        .Without(RuleId.Ru.Money.Ruble)
        .With(RuleId.Ru.OptAlign.Quote),
});

string html = typographer.Process(source);

5. Конвейер: пять фаз

Порядок фаз жёсткий. Внутри фазы правила независимы: результат не зависит от порядка их применения, и это проверяется тестом на случайных перестановках.

Сегментация входа (разметка и защищённые зоны отделяются от текстовых узлов) не отдельная фаза: MarkupScanner вызывается ВНУТРИ каждой из пяти фаз — фаза сама проходит документ по сегментам, копирует разметку байт в байт и применяет свою логику только к текстовым узлам.

# Фаза Что делает
1 Prepare BOM, распознавание переводов строк (без нормализации — см. 5.4), табы, обрезка, декодирование типографских сущностей в символы
2 Scan Посимвольный проход: кавычки, тире, апостроф, многоточие, символы, числа, чистка пробелов и пунктуации
3 Bind Словарный проход по словам: неразрывные пробелы, интервалы, сокращения, инициалы, единицы
4 Layout nobr-группы, висячая пунктуация, br, p
5 Emit Кодирование по EntityMode, запись в приёмник

Фазы 2–4 пишут в общий изменяемый буфер и имеют право патчить уже записанные позиции по сохранённому индексу. Это ключевое решение: оно позволяет правилам с длинным контекстом («неразрывный пробел перед последним коротким словом предложения») работать за один проход — кандидат запоминается как индекс, а решение принимается при встрече точки и записывается назад за O(1).

5.1 Защищённые зоны: что считается защищённой

Копируется байт-в-байт, типографика внутрь не применяется:

  • содержимое code, pre, script, style, textarea, kbd, samp;
  • любые теги целиком, включая значения атрибутов;
  • комментарии, в том числе условные комментарии IE;
  • CDATA, DOCTYPE, инструкции обработки;
  • HTML-сущности, не входящие в таблицу типографских (например, &amp;, &lt;).

Ломаный HTML не является ошибкой: незакрытый тег, > внутри значения атрибута, одинокий < — всё это выводится как есть, обработка продолжается.

Отдельно защищён веб-адрес в тексте. Адрес — машинный идентификатор, а не текст: правка меняет то, куда он ведёт, а при включённом common/html/url испорченный адрес попадает ещё и в href. Внутри адреса не работает ни одно правило фаз Scan и Bind: точка в ?v=1.5 не десятичный разделитель, /1/2 не половина, дефис в /2020-2026 не тире, != не знак неравенства, ( не начало (c), одиннадцать цифр в /tel/89991234567 не телефон, /м2 не квадратный метр, /2018-10-10 не дата. Адрес и не токен: слово за ним к его хвосту не привязывается.

  • начало адреса — последовательность ://;
  • конец — пробельный символ, <, >, прямая или закрывающая типографская кавычка (», “, ”) либо любой тег; правая одинарная кавычка между буквами — апостроф, адрес не завершает;
  • прямой апостроф адрес не завершает вовсе: он законен внутри адреса (RFC 3986), и ?q='x y' рвать нельзя; автоссылка отрезает с хвоста непарный апостроф как внешнюю кавычку ('http://a.ru'), но сохраняет закрывающий апостроф параметра (?q='hello');
  • прямая кавычка завершает адрес и тогда, когда во входе она записана сущностью &quot;: правило common/html/quot раскодирует её в фазе Prepare, до защиты адреса, и ?q=&quot;x&quot; обрывается на первой кавычке. Кавычка в адресе недопустима (RFC 3986) и кодируется как %22; сущность внутри адреса переживает только выключенное common/html/quot;
  • границы одни на три фазы — Scan, Bind и автоссылку common/html/url фазы Layout, — иначе кавычка, завершившая адрес для одной фазы, уходила в href у другой;
  • угловая скобка адрес завершает потому, что в тексте она неотличима от начала тега: ?a=1<=2 защищённым не остаётся, и это осознанная цена гарантии 3.

5.2 Фаза Scan: кавычки

Стек уровней: «» → „“ → ‘’. Открывающая или закрывающая определяется предыдущим символом: после пробела, начала текста или открывающей скобки — открывающая, иначе закрывающая. Дополнительно:

  • " сразу после цифры при пустом стеке — дюйм или секунда, остаётся прямой (17", 3' 25");
  • ' между буквами — апостроф ’ (д’Артаньян, O’Neil);
  • ' после цифры — фут или минута, остаётся прямой;
  • кавычки вокруг ссылки выносятся за тег;
  • перед закрывающей кавычкой допустимы ?, !, многоточие и точка после сокращения (и т. д.).

5.3 Фаза Scan: тире, дефис, минус

  • дефис между словами без пробелов остаётся дефисом (из-за, по-русски, кто-то);
  • дефис, окружённый пробелами, между словами — длинное тире с неразрывным пробелом слева;
  • дефис между числами — короткое тире без отбивки (1941—1945 в Gost, 1941 — 1945 в Lebedev);
  • дефис перед числом после пробела — минус (от -5 до +5);
  • дефис в начале строки или абзаца — тире прямой речи.

5.4 Переводы строк: распознаются, а не нормализуются

Библиотека не переписывает \r\n на \n или наоборот: перезапись чужих байт нарушила бы гарантию 3 (разметка и, шире, посимвольный состав входа не меняются без необходимости) и гарантию 6 (тот же экземпляр строки на входе без правок). Вместо нормализации оба варианта перевода строки распознаются там, где решение зависит от границы слова или абзаца:

  • возврат каретки (\r) — граница слова наравне с пробелом и переводом строки;
  • граница абзаца (для HtmlOptions.UseP) — и Unix-форма ("\n\n"), и Windows-форма ("\r\n\r\n").

6. Состав правил

107 правил, имена совпадают с JS-typograf. Полный справочник генерируется из кода в docs/rules.md; здесь — группы и дефолты.

Группа Кол-во Фаза В Default
common/space/* — пробелы и пунктуационные пробелы 20 Scan кроме нормализации*
common/punctuation/* — кавычки, апостроф, многоточие, двойная пунктуация 5 Scan да
common/symbols/* — (c), (tm), стрелки, градусы 3 Scan да
common/number/* — дроби, знак умножения, неравенства, разряды 4 Scan кроме digitGrouping†
common/nbsp/* — неразрывные пробелы общего вида 10 Prepare / Bind кроме replaceNbsp**
common/html/* — сущности, br, p, автоссылки, экранирование 8 Prepare / Layout / Emit только quot***
common/other/* — BOM, повтор слова 2 Prepare / Bind только delBOM
ru/dash/* — тире и дефисы русского языка 18 Scan да
ru/nbsp/* — сокращения, инициалы, адреса, единицы, номер, параграф 16 Bind да
ru/punctuation/* — двойные знаки, ?.., запятые перед «а» и «но» 4 Scan кроме ano
ru/number/* — десятичная запятая, порядковые 2 Scan да
ru/date/* — ISO-даты, дни недели 2 Bind да
ru/money/* — валюта, рубль 2 Bind нет
ru/space/* — пробелы, специфичные для русского 2 Scan да
ru/symbols/* — сдвоенный знак номера 1 Scan да
ru/optalign/* — висячая пунктуация 3 Layout нет
ru/other/* — ударения, телефоны 2 Bind только телефоны
ru/typo/* — раскладка клавиатуры 1 Bind нет
en-GB, en-US — тире 2 Scan нет

† common/number/digitGrouping — единственное правило реестра, работающее в ДВУХ фазах. В фазе Scan оно разбивает длинное число по разрядам (1000000 → 1 000 000), а в фазе Bind делает неразрывными пробелы, которые между разрядами уже стоят в тексте автора (1 000 000, ГОСТ 9.5). Вторая половина в фазе Scan невыразима: пробел там разделяет два токена, а токенов фаза Scan не знает. В реестре правило числится за фазой Scan — по своей основной работе, — но проход фазы Bind включает тоже.

* Семь правил группы common/space/* — trimLeft, trimRight, delLeadingBlanks, delTrailingBlanks, delRepeatN, replaceTab, insertFinalNewline — нормализуют пробельное письмо, а не типографику: они обрезают края фрагмента, переписывают отступы и разворачивают табы. Тот, кто вызвал Typographer.Html(text), такого не ожидает, поэтому в Default их нет. Побочная выгода: пока ни одно из них не включено, документный проход нормализации не запускается и не стоит ни буфера, ни копии документа.

** Правило common/nbsp/replaceNbsp снимает неразрывные пробелы ПЕРЕД типографированием, чтобы правила расставили свои. Оно вне Default по той же причине, что и правила нормализации: неразрывный пробел во входе поставлен руками и означает «здесь рвать нельзя», а правило это решение стирает. Работает только вместе с правилами фазы Bind; включённое в одиночку, оставляет текст вовсе без неразрывных пробелов.

*** Два правила группы common/html/* не реализуются, а не отложены: stripTags (удаление тегов — это санитайзинг, а не типографика, и он противоречит гарантии 3) и processingAttrs (типографирование значений атрибутов противоречит той же гарантии, а реализация требует разбора значений внутри тега и повторного запуска конвейера на каждом из них). Оставшиеся шесть работают; правила этой группы имеют смысл только в HTML-режиме, и TextTypographer их не применяет.

Правило common/punctuation/quoteLink зарегистрировано, но НЕ РЕАЛИЗУЕТСЯ: оно выносит кавычки за пределы ссылки, то есть переносит текст через границу тега вопреки гарантии 3.

Выключены в Default, но реализованы: ru/punctuation/ano (сам расставляет запятые), common/other/repeatWord (удаляет повтор слова), ru/other/accent (меняет запись слова), ru/money/* (меняет запись суммы), common/nbsp/replaceNbsp (стирает авторский неразрывный пробел), common/number/digitGrouping, ru/dash/to, ru/dash/kakto, ru/dash/de (омонимичные частицы), en-GB/dash/main и en-US/dash/main, а также common/html/url, common/html/e-mail, common/html/nbr, common/html/p, common/html/escape и ru/optalign/* (делают тег из текста или преобразуют разметку).

Причина одна: правило, меняющее смысл или запись текста — а тем более создающее разметку, — не должно срабатывать у того, кто просто вызвал Typographer.Html(text). Правила, создающие или преобразующие разметку, перечислены в коде массивом RuleId.Registry.MarkupChanging; тест стережёт, что ни одно из них не попало в Default.

Не реализованы всего три правила, и каждое — осознанный отказ с причиной в XML-комментарии: common/punctuation/quoteLink, common/html/stripTags и common/html/processingAttrs. Четвёртое, ru/typo/switchingKeyboardLayout, зарегистрировано и отложено с первого плана: оно требует таблицы соответствия раскладок и решения о том, когда латиница в русском тексте — опечатка, а когда намеренная вставка.

7. Локализация

Только русский язык. Латинские вставки обрабатываются общими правилами (common/*); детектор языка не делается. Правила en-GB/dash/main и en-US/dash/main реализованы и доступны через RuleSet, но в Default выключены.

8. Гарантии

Контракт, который документируется и проверяется тестами:

  1. Время O(n), память O(n). Один проход чтения, один буфер вывода из ArrayPool<char>.
  2. Не бросает исключений ни на каком входе, кроме ArgumentNullException на null и OutputTooLargeException при превышении MaxOutputLength.
  3. Разметка не меняется. Всё, что было тегом, атрибутом или защищённой зоной, выходит байт-в-байт. Единственное исключение — правило common/html/escape, которое существует ровно ради обратного: оно показывает разметку как текст. Оно вне Default, и включает его только тот, кто просит именно этого.
  4. Текст никогда не становится разметкой. Ни одно правило Default не создаёт тег из текстового содержимого.
  5. Идемпотентность: f(f(x)) == f(x) для любого входа и любого набора правил. Проверяется не только на пресетах, но и на каждой из 5671 пары правил реестра (RulePairTests). Три пары нарушают гарантию и закреплены списком в тесте: правило, переписывающее токен, меняет его границы, а соседнее правило той же фазы их уже не пересматривает. В пресетах ни одна из трёх не проявляется. Исключение — опции HtmlOptions.UseBr и MaxNobr: они рассчитаны на однократное применение к исходному тексту и оборачивают его в разметку (<br />, <nobr>). Прогон по СОБСТВЕННОМУ ВЫВОДУ типографа с той же опцией вкладывает разметку в саму себя (<nobr><nobr>текст</nobr></nobr>), потому что фаза Protect не отличает тег, поставленный этими опциями, от тега, который уже был во входе. Учить её такому распознаванию не стали намеренно: это эвристика уровня разбора структуры документа, которая не окупается ценой ради двух опций компоновки. Гарантия распространяется на правила RuleSet — они патчат только символы, а не оборачивают текст в новые теги. UseP под исключение не попадает: абзацы размечаются по документу целиком, а документ, в котором блочная разметка уже есть, второй раз не размечается. Второе исключение — правило common/nbsp/replaceNbsp. Оно снимает неразрывные пробелы перед типографированием, а пробел, поставленный ОДНОКРАТНЫМ превращением (- в тире, руб. в знак рубля, разряды длинного числа), на втором прогоне восстановить уже нечем: исходной формы в тексте нет. Это свойство самого правила, а не дефект реализации, и поэтому оно вне Default. Третье и четвёртое исключения — правила common/html/nbr и common/html/escape. Первое делает то же, что опция UseBr, и точно так же вкладывает теги переноса повторно. У второго неподвижной точки нет по определению: амперсанд, ставший &amp;, на следующем прогоне станет &amp;amp;. Оба вне Default.
  6. Если правок нет — возвращается исходный экземпляр строки (ReferenceEquals(input, output)).
  7. Битый UTF-16 (одинокие суррогаты) проходит насквозь без исключения и без потери символов.
  8. Регулярных выражений в горячем пути нет, поэтому катастрофический бэктрекинг невозможен.

9. Тестирование

Три уровня в CI плюс один вне CI.

  1. Unit — по тесту на правило, включая отрицательные случаи («не должно сработать»).
  2. Golden-корпус — tests/Typographer.Corpus/, пары *.in.txt и *.out.txt.
  3. Property — идемпотентность, целостность разметки, отсутствие изменений в тексте без целевых символов, устойчивость на случайных данных.
  4. Оракул (вне CI, помечен [Trait("Category", "Oracle")]) — сверка с typograf.artlebedev.ru, расхождения выводятся отчётом.

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

Категория Примеры
Вложенные кавычки Эксперт уточнил: «в перечень включен закон „О стандартизации“»
Кавычки против штрихов 17", 3' 25", 5'6", 30°15'20", диагональ 15,6"
Апостроф д’Артаньян, O’Neil, Кот-д’Ивуар
Тире, дефис, минус из-за, по-русски, -5 °C, от -5 до +5, - Привет, - сказал он., 1941-1945
Числа-ловушки 3.14 в 3,14, но не 1.2.3, не 192.168.0.1, не 01.01.2020
Сокращения А.С. Пушкин, в 3 г. IV в. до н. э., ул. Ленина, д. 5, кв. 7, 10 км/ч, 100 %, 25 °C
Многоточие и смайлы ?.., !.., :-) не должен стать тире
HTML-ловушки <code>a - b</code>, <a title="Не трогать - тире">, <b>сло</b>во, кавычки вокруг ссылки, условный комментарий IE
URL и почта http://example.com/a-b?x=1&y=2
Разрыв контекста тегом $<b>100</b>, 5<sup>2</sup> м
Смешанный ru/en Windows 10 - "лучшая" ОС
Юникод-мусор BOM, одинокие суррогаты, zero-width, уже стоящие nbsp

Как запускать:

Уровень Команда
Всё, что идёт в CI dotnet test
Перегенерация golden-корпуса TYPOGRAPHER_UPDATE_CORPUS=1 dotnet test tests/Typographer.Tests
Перегенерация списка расхождений TYPOGRAPHER_UPDATE_ORACLE=1 dotnet test tests/Typographer.Tests
Живая сверка с оракулом TYPOGRAPHER_ORACLE=1 dotnet test tests/Typographer.Oracle
Фаззинг workflow Fuzz — по расписанию и вручную

Оба режима перегенерации намеренно ЗАВАЛИВАЮТ тест: перезапись эталона проверкой не является, и зелёный прогон обманул бы того, кто запустил перегенерацию и забыл снять переменную.

Расхождения с оракулом Лебедева закреплены тестом OracleSnapshotTests: он идёт по закоммиченному снимку docs/oracle/lebedev.md без сети и падает в обе стороны — и когда совпадение сломалось, и когда записанное расхождение исчезло. Читаемая проекция списка — docs/oracle/divergences.md. Живая сверка со СЛУЖБОЙ вынесена в tests/Typographer.Oracle и отвечает на другой вопрос: не устарел ли сам снимок.

Каждое правило реестра проверяется ещё и в одиночку (EveryRuleTests): включённое одно, без соседей по фазе, оно не должно падать, нарушать идемпотентность, менять разметку или трогать текст, в котором его знаков нет. Порядок правил внутри фазы задан диспетчером и снаружи не меняется, поэтому переставлять правила незачем — прогон в одиночку проверяет то же свойство строже.

Фаззинг — SharpFuzz, отдельный workflow по расписанию; падение считается багом.

Бенчмарки — BenchmarkDotNet. Цель:

  • пропускная способность не менее 50 млн символов в секунду в один поток на эталонной машине;
  • ноль байт аллокаций в куче на пути Process(ReadOnlySpan<char>, IBufferWriter<char>).

Замеры от 10 сентября 2026 — docs/perf.md. Второе выполнено. Первое нет: получается 27 млн символов в секунду при закрытом реестре из 107 правил, и попытка ускорения дала 6–13 %, а не двукратный рост.

Профиль по фазам объясняет, почему двукратного роста и не будет: цель в 50 млн символов в секунду — это 444 мкс на эталонном входе, а пол конвейера БЕЗ ЕДИНОГО ПРАВИЛА — 253 мкс плюс 193 мкс на машинерию фазы Bind. Дело не в медленных правилах, а в том, что фаз пять и каждая проходит документ целиком. Измеренный потолок нынешней архитектуры — около 30 млн символов в секунду; взять цель можно только слиянием фаз, то есть разменом гарантий 3 и 5 на скорость. Решение — менять цифру или перестраивать конвейер — за владельцем.

Сравнение с string.Replace приводится в отчёте как ориентир масштаба, но критерием не является: это векторизованный поиск подстроки, и шестифазный конвейер со ста семью правилами не может быть с ним одного порядка.

10. Пакеты

Граница пакета — внешняя зависимость. Ядро зависимостей не имеет.

Пакет Содержимое Зависимости Платформы
Typographer Ядро: HTML и plain text, правила, опции нет на net8.0 и net10.0; на netstandard2.0 — один официальный полифил System.Memory netstandard2.0;net8.0;net10.0
Typographer.DependencyInjection AddTypographer() Microsoft.Extensions.DependencyInjection.Abstractions netstandard2.0;net8.0;net10.0
Typographer.AspNetCore TagHelper и IHtmlContent ASP.NET Core net8.0;net10.0
Typographer.Markdig Типографика Markdown как расширение конвейера Markdig netstandard2.0;net8.0;net10.0
dotnet-typographer CLI как dotnet tool ядро net10.0

Типографика Markdown применяется к дереву документа после разбора, а не к готовому HTML: рендерер кодирует прямую кавычку в &quot;, а типограф сущности разметки намеренно не декодирует (5.1), и кавычки-ёлочки в отрендеренном HTML не появились бы вовсе.

Структура репозитория:

src/Typographer/                 ядро
src/Typographer.DependencyInjection/
src/Typographer.AspNetCore/
src/Typographer.Markdig/
src/Typographer.Cli/
tests/Typographer.Tests/         unit и property
tests/Typographer.Corpus/        golden-файлы
tests/Typographer.Oracle/        сверка с сервисом Лебедева, вне CI
tests/Typographer.Integrations.Tests/ тесты пакетов интеграций
bench/Typographer.Bench/         BenchmarkDotNet
fuzz/Typographer.Fuzz/           цель libFuzzer
docs/                            документация и DocFX

Целевые платформы ядра: netstandard2.0, net8.0, net10.0. У пакетов интеграций — по таблице выше: их ограничивают собственные зависимости.

11. Документация

  • XML-комментарии на русском на каждом публичном члене, GenerateDocumentationFile включён, предупреждения — ошибки.
  • docs/ на русском: быстрый старт, архитектура, рецепты, источники.
  • docs/rules.md генерируется из метаданных правил, примеры берутся из реальных тестов. Тест в CI падает, если файл разошёлся с кодом.
  • Сайт DocFX публикуется на GitHub Pages при релизе.

12. Версионирование и релизы

MinVer, теги вида 1.2.3 без префикса. Публикация в NuGet начиная с 0.1.0. Ветвление — git flow: master (релизы), develop (интеграция), feature/*, release/*, hotfix/*.

13. Вне рамок

  • Ёфикация и проверка орфографии.
  • Расстановка переносов.
  • Языки, кроме русского (английские правила тире — только как опция).
  • Полный HTML5-парсер: работаем со фрагментами и не нормализуем чужую разметку.