<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Poznámky z praxe · francl.digital</title>
    <link>https://francl.digital/cs/blog/</link>
    <description>Poznámky ze zakázek — weby, propojování systémů, AI. Proč jsem něco postavil takhle a co bych příště udělal jinak. Bez teorie a bez plánu vydávání.</description>
    <language>cs-CZ</language>
    <managingEditor>krystof@francl.digital (Kryštof Francl)</managingEditor>
    <atom:link href="https://francl.digital/cs/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Proč jsem si postavil tenhle web</title>
      <link>https://francl.digital/cs/blog/proc-tento-web/</link>
      <guid isPermaLink="true">https://francl.digital/cs/blog/proc-tento-web/</guid>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <description>Mohl jsem si vystačit s LinkedIn vizitkou. Tady je pár důvodů, proč jsem místo toho postavil vlastní web na Cloudflare Workers.</description>
      <category>meta</category>
      <category>astro</category>
      <category>cloudflare</category>
      <category>portfolio</category>
      <content:encoded><![CDATA[LinkedIn mi půjčuje profil, dokud se Microsoftu vyplatí ho provozovat. Nemám tam vlastní typografii, nemůžu si říct, co je blog a co CV záznam. Jsem někde mezi pár sty miliony lidí a reklamami na product management kurzy.

Vlastní web je jiná věc. Pravidla si píšu já. I když o tom skoro nikdo neví.

## Chtěl jsem si zkusit Workers

Edge computing mě zajímá a Cloudflare Workers jsem chtěl vyzkoušet na něčem reálném. Zero cold start, globální síť bez konfigurace, platíš za volání, ne za čas. Nejlepší způsob, jak platformě rozumět, je na ní něco postavit.

Skončilo to u Astra 6 s Workers adaptérem, Tailwind v4 s vlastními tokeny, Biome jako linter a celé ve dvou jazycích. Čtrnáct stránek, jeden deploy příkaz.

## Chtěl jsem si zkusit psát

Analytici píšou dokumenty každý den, ale vždy podle šablony a pro lidi, které znáš. Psát pro neznámého čtenáře o tématu, které si vybereš sám, je jiný úkol. Chci se v tom zlepšovat.

Takže tady budou občas zápisky o věcech, které řeším — integrace v bance, LLM v účetnictví, obnovitelná energie, občas něco o nářadí. Žádný plán, žádná pravidelnost.

## A baví mě to

Tohle je asi ten hlavní důvod. Vybírat typografii do sedmé verze. Ladit barvu mřížky, aby nepřekážela textu. Řešit, proč se font chová jinak, než má.

**Koníček.** A koníčku se odpustí, že ne vždy všechno dává smysl.

Vítej tu. Pokud se ti něco nelíbí, napiš — pravděpodobně budu souhlasit.]]></content:encoded>
    </item>
    <item>
      <title>Raveo: jak jsem postavil vlastní Cloudflare stack pro weby na zakázku</title>
      <link>https://francl.digital/cs/blog/raveo/</link>
      <guid isPermaLink="true">https://francl.digital/cs/blog/raveo/</guid>
      <pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
      <description>Každý web na zakázku začínal od nuly. Postavil jsem si proto vlastní základ — klient si obsah spravuje sám, není co udržovat na serveru a web běží rychle po celém světě.</description>
      <category>Cloudflare</category>
      <category>Astro</category>
      <category>PayloadCMS</category>
      <category>Workers</category>
      <category>edge</category>
      <content:encoded><![CDATA[Problém s tvorbou webů na zakázku je, že pokaždé začínáš znovu. Vybereš CMS, nastavíš hosting, vymyslíš, jak dostat obsah na frontend, vyřešíš cache. A příště to samé znovu, jen trochu jinak.

Chtěl jsem mít vlastní základnu: monorepo s CMS i frontendem, nasaditelné jedním příkazem, s obsahem, který si klient spravuje sám. Bez klasického serveru. Tak vznikl Raveo.

Pro toho, kdo si web objednává, z toho plyne trojí: texty a fotky si mění sám a nemusí kvůli překlepu psát vývojáři, není žádný server, který by někdo musel hlídat a aktualizovat, a platí se za skutečný provoz místo za stroj, který běží celý měsíc naprázdno. Zbytek zápisku je o tom, jak je to poskládané uvnitř.

## Co je Raveo

Raveo je monorepo se dvěma Cloudflare Workers. Jeden běží `PayloadCMS` na `Next.js` jako headless CMS. Druhý běží `Astro` frontend. Všechno ostatní jsou Cloudflare služby: `D1` jako databáze, `R2` na media, `KV` na cache.

```
raveo/
├── apps/
│   ├── cms/     PayloadCMS + Next.js → Cloudflare Worker
│   └── web/     Astro v6 → Cloudflare Worker
└── packages/
    ├── types/   vygenerované typy z PayloadCMS schématu
    ├── ui/      sdílené komponenty + Lexical renderer
    └── config/  sdílené tsconfig + Biome konfigurace
```

Monorepo spravuje `Turborepo` s `pnpm` workspace. Deploy obou workerů najednou jde přes jeden příkaz.

## Cloudflare primitivy: co to všechno je

Než půjdu do implementace, krátce k tomu, co každá z těch služeb dělá — z názvů to samozřejmé není.

**Workers** jsou V8 isolates — malá izolovaná prostředí, ve kterých běží kód při každém requestu. Nespustí se jako tradiční server, který čeká v paměti. Spustí se na každý požadavek, vyřídí ho a zaniknou. Žádný cold start, globální síť, platíš za skutečné requesty, ne za provoz serveru.

**D1** je serverless SQLite přímo na Cloudflare hraně. Žádný PostgreSQL server, žádná managed databáze. SQLite soubor replikovaný globálně, přístupný přes Workers binding. Pro CMS databázi, která nepotřebuje miliony concurrent writes za sekundu, je to ideální.

**R2** je object storage kompatibilní s S3 API. Media soubory, obrázky, dokumenty. Bez egress poplatků za stahování — což je oproti klasickému S3 při provozu znát.

**KV** je distribuovaný key-value store s velmi nízkou latencí čtení. Hodí se na cache: jednou zapíšeš, mnohokrát čteš. S TTL expirací.

## PayloadCMS na Workers: D1 a R2 adaptéry

`PayloadCMS` normálně předpokládá Node.js server a PostgreSQL nebo MongoDB. Cloudflare Workers jsou runtime bez Node.js a bez přístupu k externím databázím přes TCP. Řeší to adaptéry.

V `payload.config.ts` vypadá konfigurace takhle:

```ts
export default buildConfig({
  collections: [Users, Media, Categories, Posts, Pages, Forms, FormSubmissions],
  globals: [Navigation, SiteSettings],
  editor: lexicalEditor(),

  // D1 místo PostgreSQL
  db: sqliteD1Adapter({
    binding: cloudflare.env.D1,
    push: !isSeed,
  }),

  // R2 místo lokálního filesystému nebo S3
  plugins: [
    r2Storage({
      bucket: cloudflare.env.R2,
      collections: { media: true },
    }),
  ],
});
```

`D1` binding je přímý přístup k SQLite databázi bez sítě. `R2` binding je přímý přístup k object storage. Vše jde přes interní Cloudflare kanály, ne přes veřejný internet.

Výsledek: `PayloadCMS` admin rozhraní běží jako Worker. Klient se přihlásí, spravuje obsah, ukládá změny. Databáze je `D1`, media jdou do `R2`.

## Service Bindings: jak spolu workery mluví

Dva Workers potřebují spolu komunikovat. Naivní řešení je volat CMS přes HTTP, ale to přidává latenci, DNS lookup a další síťový hop.

Cloudflare má na tohle Service Bindings. Přímé propojení mezi Workers bez HTTP, bez DNS, s nulovou latencí. Worker A zavolá Worker B jako funkci, ne jako HTTP endpoint.

V middlewaru to vypadá takhle:

```ts
async function fetchFromCMS(fetcher: Fetcher | null, cmsUrl: string, path: string) {
  const url = `https://cms${path}`;
  const fallbackUrl = `${cmsUrl}${path}`;

  const res = fetcher
    ? await fetcher.fetch(url)   // produkce: service binding, nulová latence
    : await fetch(fallbackUrl);  // dev: HTTP na localhost:3000

  if (!res.ok) return null;
  return await res.json();
}
```

`fetcher` je Service Binding — v produkci je k dispozici přes `env.CMS`. V lokálním vývoji není, takže padne zpátky na HTTP. Přepínání je automatické.

## Middleware a KV cache

Astro middleware se spustí na každý request. Jeho úkolem je načíst data z CMS a předat je stránkám přes `locals`. Ale volat CMS na každý request by bylo zbytečně pomalé.

Proto je mezi tím `KV` cache:

```ts
async function cachedFetch(fetcher, cmsUrl, path, cache) {
  // 1. Zkus KV cache
  if (cache) {
    const cached = await cache.get(path, 'json');
    if (cached) return cached;
  }

  // 2. Cache miss — zavolej CMS
  const data = await fetchFromCMS(fetcher, cmsUrl, path);

  // 3. Ulož do KV na 5 minut (fire-and-forget, nezpomalí odpověď)
  if (data && cache) {
    cache.put(path, JSON.stringify(data), { expirationTtl: 300 }).catch(() => {});
  }

  return data;
}
```

Klíčový detail je `fire-and-forget` zápis do KV. Odpověď uživateli nečeká na to, až se data zapíšou do cache. Zápis jde na pozadí.

Na každý request se paralelně načtou čtyři věci:

```ts
const [navigation, siteSettings, pagesData, postsData] = await Promise.all([
  cachedFetch(fetcher, cmsUrl, '/api/globals/navigation?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/globals/site-settings?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/pages?depth=2&limit=100&where[status][equals]=published', cache),
  cachedFetch(fetcher, cmsUrl, '/api/posts?depth=2&limit=100&where[status][equals]=published', cache),
]);
```

`Promise.all` je důležitý — všechna čtyři volání jdou paralelně, ne za sebou.

## Revalidace obsahu

KV cache má TTL pět minut. Ale co když klient uloží změnu a chce ji vidět okamžitě?

Každá kolekce v CMS má `afterChange` hook. Když se obsah uloží, hook zavolá Web Worker a řekne mu, ať zneplatní cache:

```ts
const revalidate = async () => {
  if (process.env.NODE_ENV === 'production') {
    // Service binding: CMS Worker volá Web Worker přímo
    await cfEnv.WEB.fetch(
      new Request('https://web/api/revalidate', {
        method: 'POST',
        headers: { 'x-revalidate-secret': cfEnv.REVALIDATE_SECRET ?? '' },
      }),
    );
  } else {
    await fetch(`${webUrl}/api/revalidate`, { method: 'POST', ... });
  }
};

export const revalidateAfterChange: CollectionAfterChangeHook = async ({ doc }) => {
  await revalidate();
  return doc;
};
```

Po revalidaci KV cache vyprší a při dalším requestu se načtou čerstvá data z CMS. Žádný rebuild pipeline, žádné čekání na deploy.

## Lexical renderer: JSON do HTML bez JavaScriptu

`PayloadCMS` ukládá obsah bohatého textu ve formátu `Lexical JSON` — stromová struktura uzlů. Hotové knihovny to řeší přes React a klientský JavaScript. Jenže Astro stránky jsou statické, klientský JavaScript nechci. Napsal jsem vlastní server-side renderer.

Zajímavá část je formátování textu. Lexical ukládá formát jako bitový příznak — číslo, kde každý bit znamená jiný styl:

```ts
function renderTextFormat(text: string, format: number): string {
  let result = escapeHtml(text);
  if (format & 16) result = `<code>${result}</code>`;    // inline kód
  if (format & 1)  result = `<strong>${result}</strong>`; // tučné
  if (format & 2)  result = `<em>${result}</em>`;         // kurzíva
  if (format & 8)  result = `<u>${result}</u>`;           // podtržení
  if (format & 4)  result = `<s>${result}</s>`;           // přeškrtnutí
  if (format & 32) result = `<sub>${result}</sub>`;       // dolní index
  if (format & 64) result = `<sup>${result}</sup>`;       // horní index
}
```

Bitový AND (`&`) zkontroluje, jestli je daný bit nastavený. Text může být tučný a zároveň kurzíva — oba bity jsou nastavené zároveň. Renderer zpracovává celý strom rekurzivně: odstavce, nadpisy, citace, seznamy, checkboxy, odkazy, obrázky. Výstup je čisté HTML bez klientského JavaScriptu.

## Rate limiting na KV

Rate limiter jsem nechtěl řešit přes externí službu. `KV` namespace pro cache je k dispozici, tak jsem ho použil i na rate limiting.

Implementace je sliding window: pro každou IP adresu uchovává počet requestů a začátek časového okna:

```ts
interface RateLimitEntry {
  count: number;
  windowStart: number;
}
```

Klíčové rozhodnutí v návrhu: rate limiter **fails open** — pokud `KV` není dostupný, request projde. Alternativa by byla odmítnout request, ale výpadek KV by pak vyřadil celý web. Fail open je v tomhle případě správná volba.

POST requesty jsou omezeny na 10 za minutu, API endpointy na 30. Odpověď vrací RFC standardní hlavičky `RateLimit-Remaining` a `Retry-After`.

## Kde to teď je a kam míří

Na Raveu teď stavím web pro svoje skautské středisko Prácheň. Bude to první reálné nasazení stacku v produkci.

Do budoucna mě zajímá integrace s `Medusa.js` jako transakčním backendem. Z Ravea by tak šlo stavět e-shopová řešení na zakázku: `PayloadCMS` na správu obsahu, `Medusa` na produkty a objednávky, `Astro` na frontend. Celé bez vlastního serveru, globálně distribuované, s obsahem, který si klient spravuje sám.

Celý projekt je open source na [github.com/raveo-dev/raveo](https://github.com/raveo-dev/raveo).]]></content:encoded>
    </item>
    <item>
      <title>MCP nástroj, který hlídá chyby v integračních specifikacích</title>
      <link>https://francl.digital/cs/blog/esmm-validator/</link>
      <guid isPermaLink="true">https://francl.digital/cs/blog/esmm-validator/</guid>
      <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
      <description>Překlep ve specifikaci se najde až u testera nebo u vývojáře v kódu, a to znamená vracet se a předělávat. Napsal jsem nástroj, který ji zkontroluje dřív — MCP server pro AI asistenta v editoru.</description>
      <category>MCP</category>
      <category>integrace</category>
      <category>validace</category>
      <category>AI</category>
      <category>banking</category>
      <content:encoded><![CDATA[Než banka propojí dva systémy, musí někdo sepsat, které pole z jedné zprávy odpovídá kterému poli ve druhé. U nás se tomu dokumentu říká ESMM a je to tabulka v Excelu. Když se v ní člověk uklepne, na první pohled to nikdo nepozná: chyba vyplave buď v test analýze, nebo až u vývojáře v kódu. V obou případech se vracíš ke specifikaci a předěláváš.

Stávalo se to pravidelně a neexistoval nástroj, který by ten zápis zkontroloval dřív. Tak jsem si jeden napsal.

## Co je ESMM a kde vznikají chyby

V ESMM analytik popisuje, jak data putují mezi systémy: jaký `xpath` mají ve vstupní zprávě, jak se jmenují v cíli, jaké podmínky platí. Z toho pak vznikají technické specifikace, unit testy i implementační kód.

Příklad `xpath` pro REST službu:

```
createInternalPO//POST/request/BODY/accountNumber/numberPart1
```

A pro TIF/WMB:

```
//getListRequest/identity/userLoginName
```

Formát je pro každou technologii jiný. REST má jinou strukturu než TIF, pole se zapisují jinak, lomítka mají jiná pravidla. Tohle všechno musí přesně sedět na strukturu `XSD` nebo `YAML` souboru. Překlep, záměna velikosti písmen, přebytečné lomítko na začátku. Žádná z těchto chyb není v Excelu vidět, dokud ji nezačne zpracovávat někdo jiný.

Pravidla přitom jsou jasná a mechanická. Dají se zkontrolovat automaticky.

## Co je MCP a co v něm jde postavit

Než popíšu implementaci, krátce k MCP jako konceptu — mluví se o něm hodně, konkrétně se vysvětluje málo.

MCP (Model Context Protocol) je protokol, který definuje, jak AI klient (VS Code Copilot, Claude Desktop, ...) komunikuje s externím serverem. Server běží lokálně nebo někde v síti, klient se k němu připojí a od té chvíle má AI přístup k tomu, co server nabízí.

Server může nabídnout čtyři typy věcí:

**Tools** jsou funkce, které AI může volat. V mém případě je to šest nástrojů: prohledání pracovního prostředí, vytvoření dočasné složky, konverze `XLSX` na markdown, analýza orchestrace, vyhledání IMS souborů, příprava validačního vstupu. AI dostane seznam dostupných nástrojů s popisem a sama rozhodne, kdy a jak je zavolat.

**Resources** jsou statická data nebo znalosti, které AI může číst. U mě jsou to validační pravidla: kompletní specifikace toho, co je validní `xpath` pro REST, co pro TIF, co jsou komentáře, co se kontroluje a co ne. AI si je načte jako kontext předtím, než začne validovat.

**Prompts** jsou předdefinované šablony, které říkají, jak začít pracovat. Místo aby uživatel psal pokyn od začátku, vybere prompt a server mu připraví strukturovaný vstup. Můj workflow prompt vypadá takhle:

```
Jsi AI agent pro kompletní validaci ESMM. Spusť sekvenčně kroky 1–8.
Nepřeskakuj, neparalelizuj.

1. discover_workspace_structure – najdi službu, ulož SERVICE_ROOT_PATH.
2. create_temp_validation – vytvoř složku pro artefakty.
3. convert_to_md – konvertuj XLSX, musí vzniknout head.md.
4. analyze_orchestration_info – načti orchestraci, najdi služby pro IMS lookup.
5. find_ims_service – pro každou službu najdi soubory.
6. create_ai_validation_sampling – sestav sampling_input.md.
7. auto_ai_validation – spusť sampling, ulož výsledek.
8. Finální report – shrň výsledky nebo chybu.
```

Uživatel napíše název služby, spustí prompt a server ho provede celým workflow automaticky.

**Sampling** je čtvrtý typ a nejzajímavější. Server může přes protokol požádat klienta, aby zavolal AI model za něj. K tomu se dostanu za chvíli.

V `index.js` to celé vypadá takhle při inicializaci:

```js
this.server = new Server(
  { name: "esmm-validation-server", version: "1.0.0" },
  {
    capabilities: {
      tools: {},
      resources: {},
      prompts: {},
      sampling: { createMessage: true },
    },
  }
);
```

Čtyři řádky, čtyři typy capabilities. Server komunikuje přes `stdio`, klient ho spustí jako subprocess a vše jde přes standardní vstup a výstup.

## Jak to funguje krok za krokem

Prvních šest kroků je přípravná pipeline. Server projde strukturu repozitáře a najde složku se službou. Vytvoří složku `temp_validation`, kam ukládá mezivýsledky. Převede `XLSX` soubory na markdown, každý list jako samostatnou tabulku. Z hlavičkového listu vyčte, jaké služby jsou orchestrované a jakou technologii používají. Pro každou službu vyhledá odpovídající soubory.

Vyhledávání souborů byl zajímavý problém, protože struktury repozitářů nejsou vždy konzistentní. Implementoval jsem vícestupňové prohledávání: nejdřív přesná shoda podle názvu, pak pattern matching podle systémového prefixu, pak rekurzivní prohledávání celé složky. Teprve pak řekne, že nic nenašel.

Po nalezení všech souborů sestaví `sampling_input.md`: jeden velký markdown dokument se všemi daty dohromady. ESMM tabulky, `XSD` struktury, `YAML` definice.

## MCP Sampling v praxi

Sedmý krok je ten klíčový. Místo přímého volání AI API jsem použil Sampling: server požádá klienta, aby zavolal model za něj.

Proč to dělat takhle? Klient (Copilot) si spravuje autentizaci, rate limiting a výběr modelu sám. Server to nemusí řešit. Zároveň model vidí celý kontext práce v klientovi, ne jen izolovaný dotaz poslaný z API.

Velké ESMM soubory jsem musel rozdělit do bloků, jinak by to neprolezlo tokenovým limitem:

```js
const MAX_BLOCK = 40000;
const blocks = [];
for (let i = 0; i < samplingContent.length; i += MAX_BLOCK) {
  blocks.push(samplingContent.substring(i, i + MAX_BLOCK));
}

const messages = blocks.map((block, idx) => ({
  role: "user",
  content: {
    type: "text",
    text: idx === 0 ? block : `# CONTINUATION\n${block}`,
  },
}));

const samplingResponse = await server.createMessage({
  messages,
  systemPrompt,
  maxTokens: 16000,
  modelPreferences: { intelligencePriority: 0.9 },
});
```

Každý blok je samostatná zpráva, druhý a další mají hlavičku `# CONTINUATION`, aby AI věděla, že jde o pokračující data. Systémový prompt definuje přesná pravidla validace.

Výstup je markdown tabulka:

```
| index | file | sheet | row | column | raw_path | error_code | expected |
```

Pokud AI vrátí něco nepoužitelného nebo prázdnou odpověď, nástroj zahodí výsledek a vrátí prázdnou kostru. Falešné výsledky se nerozšíří dál.

## Co nefunguje a co řeším

Bylo by neférové napsat jen to, co funguje.

Největší praktický problém je kontextové okno. Při práci s větším počtem souborů nebo komplexnějšími `XSD` strukturami se kontext vyčerpá dřív, než AI dokončí validaci. Sampling se přeruší nebo vrátí nekompletní výsledek. Momentálně to řeším dělením na menší bloky, ale u opravdu velkých ESMM souborů to pořád naráží.

Druhá věc: osm kroků za sebou, které musí uživatel ručně spouštět, je v praxi nepohodlné. Implementoval jsem proto jednodušší tříkrokové workflow, které celý průběh zkomprimuje a uživatel ho spustí jednou. Výsledek je stejný, práce navíc minimální.

Třetí věc, tentokrát pozitivní objev: hodně záleží na modelu. `claude-sonnet-4-6` s Thinking režimem si poradí s věcmi, na kterých se jiné modely zaseknou. Konkrétně: když nástroj nenajde soubor kvůli nekonzistenci v názvu, model sám odhalí, kde je problém, doplní chybějící vstup a pokračuje do samplingové fáze bez zásahu uživatele. Tohle je rozdíl, který v praxi šetří čas.

## Kde to teď je

Kolegové na integračním oddělení to začínají používat. Validaci spustí ještě předtím, než ESMM putuje do test analýzy — a chyby, které dřív vyplavaly až u testera nebo vývojáře, se zachytí tady.

Projekt je interní a ještě žije. Ale princip je přenositelný: vezmi tabulkovou specifikaci, porovnej ji se skutečnými soubory, vrať, co nesedí. MCP k tomu dává hezkou strukturu: tools pro akce, resources pro znalosti, prompts pro workflow, sampling pro AI. Čtyři stavební bloky a z nich jde poskládat nástroj, který se chová jako plnohodnotný účastník vývojového prostředí, ne jako izolovaný skript.]]></content:encoded>
    </item>
  </channel>
</rss>
