← Blog
Dokumentacja15 min czytania

Markdoc — framework dokumentacji oparty o Markdown, napędzający docs Stripe'a

Markdoc dodaje do Markdowna tagi, zmienne i warunki, ale nie pozwala wykonywać kodu z treści. Pokazujemy schemat jako kontrakt, katalog błędów walidatora i pułapkę, która unieważnia całą tę ochronę.

Każdy zespół prowadzący dokumentację techniczną dochodzi w pewnym momencie do tej samej ściany. Czysty Markdown przestaje wystarczać — chcesz zakładek z przykładami dla trzech języków, ostrzeżeń w ramkach, fragmentów wstawianych w wielu miejscach, treści zależnej od tego, czy czytelnik jest na planie darmowym. Naturalny następny krok to MDX, czyli Markdown z możliwością wstawiania komponentów i kodu JavaScript.

I tu pojawia się problem, który widać dopiero przy skali. Skoro w pliku z treścią można wykonać kod, to plik z treścią przestaje być treścią, a staje się kodem. Nie zwalidujesz go bez uruchomienia. Nie pozwolisz go edytować osobie nietechnicznej. Nie wygenerujesz z niego niczego poza stroną. A gdy autorów jest pięćdziesięciu i piszą w tysiącu plików, każdy taki plik jest potencjalnym miejscem, w którym coś się wywali podczas budowania — albo zrobi coś, czego nikt nie przewidział.

Markdoc odpowiada na to inaczej: rozszerza Markdowna o tagi, zmienne, warunki i funkcje, ale treść zostaje danymi. Nie ma wykonywania kodu z dokumentu. Wszystko, co autor może użyć, musi być wcześniej zadeklarowane przez programistę w schemacie — a to znaczy, że dokument da się zwalidować, zanim ktokolwiek go zobaczy.

Stan projektu

Repozytorium: markdoc/markdoc. 8455 gwiazdek, 251 forków, start w maju 2022. Licencja MIT, prawa autorskie po stronie Stripe, Inc. README nie owija: zaprojektowaliśmy Markdoca, by napędzał publiczną dokumentację Stripe'a, nasz największy i najbardziej złożony serwis treściowy. To ważny kontekst — narzędzie powstało do obsłużenia konkretnego, dużego przypadku, a nie jako eksperyment.

Wydania idą spokojnie, ale idą: 0.5.9 z 4 sierpnia 2026, 0.5.8 z 15 lipca, 0.5.7 z 26 marca, 0.5.6 i 0.5.5 z marca 2026. Ostatni commit pochodzi z 8 września 2026, a wśród autorów widać zarówno osoby ze Stripe'a, jak i kontrybutorów z zewnątrz. Otwartych zgłoszeń jest 29.

Jedno trzeba powiedzieć wprost: po ponad czterech latach projekt nadal jest w wersji 0.5.x. Nigdy nie wydano jedynki. Czy to problem? Praktycznie — nie, bo to samo narzędzie obsługuje produkcyjnie dokumentację Stripe'a, a lista zmian nie sugeruje wywracania API. Formalnie — jeśli wasza organizacja ma politykę „tylko stabilne wydania", to jest pozycja, przy której trzeba tę politykę przedyskutować, bo numer wersji mówi tu mniej niż to, kto z tego korzysta.

Trzy funkcje i koniec

Całe API sprowadza się do potoku: parsuj, przekształć, wyrenderuj.

const Markdoc = require('@markdoc/markdoc');

const doc = `
# Markdoc README

{% image src="/logo.svg" /%}
`;

const ast = Markdoc.parse(doc);
const content = Markdoc.transform(ast);
return Markdoc.renderers.react(content, React);

parse zwraca drzewo składniowe. transform zamienia je w drzewo renderowalne — czyli zwykłe obiekty JavaScriptu opisujące, co ma się pojawić. renderers zamieniają to drzewo na HTML albo elementy Reacta. Osobno jest validate, o którym za chwilę, oraz format, który potrafi zapisać drzewo z powrotem jako Markdoc — przydatne przy migracjach i narzędziach do automatycznej edycji treści.

Kluczowe jest to, co siedzi w środku: transform zwraca dane. Możesz je zapisać jako JSON, przesłać przez API, wyrenderować na serwerze, przeszukać, zindeksować albo policzyć. To nie jest szczegół implementacyjny — to cała teza tego projektu.

Składnia z prawdziwą gramatyką

Tagi zapisuje się w klamrach z procentami: {% tag attr="wartość" /%}. To nie jest wyrażenie regularne doklejone do parsera Markdowna — w repozytorium leży plik src/grammar/tag.pegjs, czyli formalna gramatyka PEG, z której generowany jest parser. Osoby, które kiedykolwiek debugowały składnię opartą o regeksy, docenią różnicę: komunikaty o błędach wskazują miejsce, a nie „coś nie pasuje".

Do tego dochodzą adnotacje (dopisywanie atrybutów do zwykłych elementów Markdowna), frontmatter, zmienne i funkcje. Wszystko to obsługuje własny tokenizer nadbudowany na markdown-it.

Schemat jako kontrakt

Tu zaczyna się właściwa wartość Markdoca. Każdy element — zarówno wbudowany element Markdowna, jak i twój własny tag — jest opisany schematem. Plik src/schema.ts pokazuje, jak to wygląda dla rzeczy wbudowanych:

export const heading: Schema = {
  attributes: {
    level: { type: Number, render: false, required: true },
    // ...
  },
};

export const fence: Schema = {
  render: 'pre',
  attributes: {
    content: { type: String, render: false, required: true },
    language: { type: String, render: 'data-language' },
    process: { type: Boolean, render: false, default: true },
  },
};

Trzy rzeczy warto z tego wyłuskać. Po pierwsze, render określa, w co element ma się zamienić — document renderuje się domyślnie jako article, paragraph jako p. Możesz to nadpisać dla dowolnego elementu, więc podmiana wszystkich nagłówków na własny komponent z odnośnikiem-kotwicą to zmiana w jednym miejscu konfiguracji, nie w tysiącu plików.

Po drugie, render: 'data-language' pokazuje, że atrybut może trafić do wyjścia pod inną nazwą. Po trzecie i najważniejsze — render: false znaczy „użyj tej wartości przy przekształcaniu, ale nie wypuszczaj jej do HTML-a". Dzięki temu level nagłówka jest dostępny logice, a nie wycieka do znacznika.

Atrybuty mają typy, mogą być wymagane, mogą mieć wartości domyślne i mogą być ograniczone do listy dopuszczalnych wartości albo do wyrażenia regularnego. To pełny kontrakt — i to on daje walidację.

Walidator, czyli po co to wszystko

Markdoc.validate() zwraca tablicę błędów. Nie wyjątek, nie ostrzeżenie w konsoli — dane, które możesz przetworzyć. Każdy błąd ma stabilny identyfikator, poziom istotności i komunikat. Oto katalog wprost z src/validator.ts:

  • tag-undefined, function-undefined — poziom critical. Autor użył tagu albo funkcji, których nie zadeklarowałeś.
  • tag-placement-invalid — tag postawiony tam, gdzie nie wolno (np. blokowy wewnątrz treści liniowej). Też critical.
  • tag-selfclosing-has-children — tag miał być samozamykający, a dostał zawartość.
  • attribute-undefined, attribute-missing-required, attribute-type-invalid, attribute-value-invalid — cała rodzina błędów atrybutów, wraz z komunikatem podającym oczekiwany typ albo listę dopuszczalnych wartości.
  • parameter-undefined, parameter-missing-required, parameter-type-invalid — to samo dla parametrów funkcji.
  • variable-undefined — autor odwołał się do zmiennej, której nie ma w konfiguracji.
  • slot-undefined — nieistniejący slot.

Praktyczne znaczenie: możesz wpiąć to w potok CI i odrzucać pull requesty z dokumentacją, która się nie waliduje. Autor dostaje komunikat „Attribute 'level' must be type of 'Number'" z numerem linii, zamiast dowiadywać się o problemie z padniętego budowania albo — gorzej — ze zgłoszenia użytkownika. Przy pięćdziesięciu autorach to różnica między dokumentacją, która działa, a dokumentacją, którą ktoś musi nadzorować.

Pułapka, która unieważnia całą tę ochronę

Powyższe brzmi tak dobrze, że łatwo założyć, że dzieje się samo. Nie dzieje się. Wystarczy przeczytać index.ts, żeby zobaczyć, że transform() wykonuje mergeConfig, resolve i przekształcenie drzewa — i nie wywołuje walidacji. Walidacja to osobna funkcja, którą musisz wywołać samodzielnie.

Czyli: jeśli twój potok budowania robi parse i transform, a nie robi validate, to cała warstwa schematów nie chroni cię przed niczym. Dokument z nieistniejącym tagiem po prostu wyrenderuje się bez niego, atrybut złego typu przejdzie dalej, a odwołanie do nieistniejącej zmiennej rozwiąże się na pustą wartość. Nic nie krzyknie.

To nie błąd projektowy — rozdzielenie tych kroków jest sensowne, bo walidację chcesz uruchamiać w CI i w edytorze, a nie przy każdym renderowaniu strony. Ale jest to najważniejsza rzecz do świadomego skonfigurowania w całym Markdocu, i jednocześnie ta, o której najłatwiej zapomnieć, bo brak walidacji nie objawia się żadnym błędem. Jeśli wdrażacie Markdoca, zapiszcie sobie wywołanie validate() jako osobne zadanie w potoku, a nie jako coś, co „chyba jest w środku".

Wbudowane tagi i funkcje — celowo mało

Lista jest krótka i to jest zamierzone. Wbudowane tagi to dokładnie pięć pozycji: if, else, partial, slot i table. Wbudowane funkcje to sześć: and, or, not, equals, default i debug.

To wszystko. Nie ma funkcji do formatowania daty, nie ma pętli, nie ma arytmetyki. Jeśli czegoś potrzebujesz, deklarujesz to jako własną funkcję w konfiguracji — czyli w kodzie, który przechodzi przegląd, a nie w treści. To ta sama zasada, tylko widziana z drugiej strony: zbiór operacji dostępnych autorowi jest zamknięty i znany.

Warto zauważyć, jak zaimplementowany jest default — zwraca drugi parametr, gdy pierwszy jest undefined. I debug, który po prostu wypisuje parametr jako sformatowany JSON. Przy pisaniu szablonów dokumentacji ten drugi ratuje więcej czasu, niż się spodziewasz.

Partiale nie czytają dysku

To detal, który najlepiej pokazuje konsekwencję filozofii tego projektu, więc wyciągam go osobno. Tag {% partial file="..." /%} wstawia inny fragment treści. Można by założyć, że czyta plik ze wskazanej ścieżki — i wtedy trzeba by się zastanawiać nad wyjściem z katalogu, nad tym, co się stanie przy ../../etc/passwd, i nad tym, kto może edytować dokumenty.

Markdoc robi to inaczej. Fragmenty muszą być wcześniej zarejestrowane w config.partials — mapie, którą dostarczasz z kodu. Jeśli autor poda nazwę, której tam nie ma, walidator zwraca błąd z komunikatem mówiącym wprost, że atrybut file musi być ustawiony w config.partials. Dokument nie może więc dosięgnąć niczego, czego mu nie podałeś. Ścieżka w atrybucie to klucz w twojej mapie, nie ścieżka w systemie plików.

Przy okazji: fragment dostaje własny zakres zmiennych, scalony z przekazanymi w atrybucie variables, plus wewnętrzną zmienną z nazwą pliku. Czyli fragmenty są parametryzowane, a nie tylko wklejane.

Renderery

Renderer HTML-a jest krótki i można go przeczytać w całości w minutę — co samo w sobie jest zaletą przy komponencie odpowiedzialnym za bezpieczeństwo wyjścia. Escapuje wszystkie ciągi tekstowe i wszystkie wartości atrybutów funkcją escapeHtml z markdown-it, a elementy puste (br, img, hr, input i pozostałe ze specyfikacji HTML) obsługuje z jawnej listy, bez domykającego znacznika.

Renderer React buduje elementy przez React.createElement. Jest jeszcze trzeci, ciekawszy: static, który generuje kod źródłowy komponentu jako tekst — do użycia na etapie budowania. Rządzi nim prosta i sprytna reguła: nazwa zaczynająca się małą literą to element HTML, nazwa z wielkiej litery to twój komponent wyszukiwany w przekazanej mapie komponentów. Ta sama konwencja, którą React stosuje w JSX-ie, tylko przeniesiona na warstwę danych.

Czym Markdoc różni się od CommonMarka

Dwie rzeczy zaskoczą przy migracji istniejącej dokumentacji, bo są wyłączone w tokenizerze na sztywno:

  • Bloki kodu przez wcięcie nie działają. Tokenizer wyłącza regułę code z jawnym komentarzem odsyłającym do specyfikacji CommonMarka. Cztery spacje nie zrobią bloku kodu — musisz użyć ogrodzenia z backticków. Przy przenoszeniu starych plików to najczęstsza cicha zmiana wyglądu.
  • Nagłówki podkreśleniem nie działają. Reguła lheading jest wyłączona, więc tekst podkreślony znakami === albo --- nie stanie się nagłówkiem.

Kilka rzeczy jest natomiast wyłączonych domyślnie, ale dostępnych na życzenie przez opcje tokenizera: allowComments włącza komentarze, allowLinkValidation włącza sprawdzanie odnośników (domyślnie dla protokołów http i https, listę można podmienić), allowIndentation zmienia obsługę wcięć. Tokenizer przyjmuje też zwykłe opcje markdown-it — w tym tę, która włącza surowy HTML w treści. Domyślnie jest wyłączona i przy dokumentacji pisanej przez wiele osób warto ją tak zostawić, bo jej włączenie oddaje autorom możliwość wstawienia dowolnego znacznika i przekreśla część korzyści ze schematu.

Zależności, czyli ich brak

Rzecz warta odnotowania w czasach, gdy przeciętna paczka npm ciągnie za sobą trzysta innych: Markdoc nie ma żadnych zależności produkcyjnych. W package.json są tylko optionalDependencies z definicjami typów oraz peerDependencies na Reacta i jego typy — obie oznaczone jako opcjonalne. Bez Reacta biblioteka działa normalnie i renderuje HTML.

Jest jednak szczegół, który wypada wymienić uczciwie. markdown-it siedzi w zależnościach deweloperskich, przypięty do wersji 12.3.2, i jest łatany — w repozytorium leży plik patches/markdown-it+12.3.2.patch o wielkości blisko 12 kB, nakładany przez patch-package przed każdym budowaniem. Praktyczne konsekwencje: parser Markdowna jest zawarty w wydawanej paczce, nie da się go podmienić na nowszą wersję z zewnątrz, a poprawki bezpieczeństwa w markdown-it docierają do ciebie dopiero z nowym wydaniem Markdoca. Przy audycie zależności to informacja, którą audytor powinien mieć, bo skanery patrzące tylko na drzewo zależności produkcyjnych tego nie zobaczą.

Wymagana wersja Node.js zapisana w engines to 14.7.0 lub wyższa — czyli bardzo liberalnie, choć w praktyce budujesz to na tym, na czym stoi resztą waszego projektu.

Markdoc czy MDX

To pytanie, na które sprowadza się większość decyzji, więc postawmy je uczciwie z obu stron.

MDX wybierz, gdy dokumentację pisze mały zespół programistów, chcecie w treści interaktywnych demonstracji z prawdziwym stanem i logiką, a wygoda „po prostu wstawię tu komponent i napiszę mu propsy w JSX-ie" jest ważniejsza niż możliwość walidowania treści bez uruchamiania.

Markdoc wybierz, gdy autorów jest wielu i nie wszyscy są programistami, treść ma żyć dłużej niż obecny framework frontendowy, chcecie ją walidować w CI, potrzebujecie jej też w innej postaci niż strona (aplikacja mobilna, indeks wyszukiwania, eksport do PDF-a, odpowiedzi asystenta), albo gdy zależy wam, by z pliku treści nie dało się wykonać kodu.

Ta ostatnia przesłanka bywa niedoceniana, a jest najmocniejsza. Skoro transform zwraca dane, to dokumentację można wyrenderować gdziekolwiek — również tam, gdzie nie ma Reacta ani przeglądarki. Zmiana frameworka nie oznacza przepisania tysiąca plików treści, bo treść nigdy nie była w tym frameworku zapisana.

Pułapki w jednym miejscu

  • transform() nie waliduje. Bez jawnego wywołania validate() schemat nie chroni przed niczym, a brak walidacji nie objawia się żadnym błędem.
  • Bloki kodu przez wcięcie są wyłączone — cztery spacje nie zrobią bloku kodu. Używaj ogrodzeń.
  • Nagłówki podkreśleniem są wyłączone — === i --- pod tekstem nie działają.
  • Wersja nadal 0.5.x po czterech latach. Nie ma jedynki, choć narzędzie obsługuje produkcyjnie dokumentację Stripe'a.
  • markdown-it jest przypięty do 12.3.2 i łatany przez patch-package. Nie podmienisz go, a poprawki przychodzą wraz z nowym wydaniem Markdoca.
  • Surowy HTML w treści jest opcją markdown-it — włączenie go przekreśla część korzyści ze schematu przy wielu autorach.
  • Partiale nie czytają plików — muszą być zarejestrowane w config.partials, inaczej dostaniesz błąd walidacji. To zaleta, ale zaskakuje przy pierwszym użyciu.
  • Wbudowanych funkcji jest sześć. Wszystko poza and, or, not, equals, default i debug musisz zadeklarować sam.
  • Renderer statyczny rozróżnia komponenty wielkością litery — nazwa z małej litery to element HTML, z wielkiej to twój komponent.

Podsumowanie

Markdoc jest narzędziem o wyraźnie zajętym stanowisku: treść to dane, a nie kod, i wszystko, co z tego wynika, jest cenniejsze niż wygoda wstawienia dowolnego komponentu w środek akapitu. Jeśli zgadzasz się z tą tezą, dostajesz narzędzie, które tę tezę konsekwentnie realizuje aż do poziomu tego, że fragmenty nie mogą dotknąć systemu plików.

  • Treść walidowalna bez uruchamiania — katalog błędów ze stabilnymi identyfikatorami i poziomami istotności, gotowy do wpięcia w CI.
  • Schemat jako kontrakt — typy atrybutów, wartości wymagane, domyślne i ograniczone do listy, plus możliwość przemapowania każdego elementu Markdowna na własny komponent w jednym miejscu.
  • Wyjście jako dane — to samo drzewo wyrenderujesz do HTML-a, do Reacta, do kodu komponentu na etapie budowania albo zapiszesz jako JSON dla zupełnie innego odbiorcy.
  • Zero zależności produkcyjnych, z Reactem jako zależnością opcjonalną.
  • Formalna gramatyka PEG dla składni tagów, więc błędy składniowe wskazują miejsce, a nie fakt.
  • Sprawdzone na dużej skali — narzędzie napędza publiczną dokumentację Stripe'a, co jest lepszą rekomendacją niż numer wersji.

Markdoc jest wydany na licencji MIT, z prawami autorskimi Stripe, Inc. To licencja bez zobowiązań: możesz go używać komercyjnie, modyfikować i wdrażać u klientów bez obowiązku otwierania własnego kodu. Fakt, że za projektem stoi jedna duża firma, warto natomiast trzymać w głowie przy planowaniu na lata — kierunek rozwoju wyznaczają dziś jej potrzeby, a tempo wydań jest spokojne. Z drugiej strony to również ta firma, która na tym narzędziu utrzymuje jedną z najczęściej czytanych dokumentacji technicznych w branży, więc ryzyko, że przestanie jej na nim zależeć, jest raczej niewielkie.