diff --git a/README.md b/README.md index 78624eb0..5abf114c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- LaraFly โ€” Firefly Framework for PHP + larafly โ€” Firefly Framework for PHP

-

LaraFly

+

larafly

Spring Boot's cohesion, native to Laravel 13. @@ -40,7 +40,7 @@

Table of contents -- [๐Ÿ“˜ The Book โ€” *LaraFly by Example*](#-the-book--larafly-by-example) +- [๐Ÿ“˜ The Book โ€” *larafly by example*](#-the-book--larafly-by-example) - [Why LaraFly?](#why-larafly) - [Quickstart](#quickstart) - [Philosophy](#philosophy) @@ -60,10 +60,10 @@ --- -## ๐Ÿ“˜ The Book โ€” *LaraFly by Example* +## ๐Ÿ“˜ The Book โ€” *larafly by example* -**LaraFly by Example** is the official, project-driven book for the framework โ€” a PHP sibling to -[*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the +**larafly by example** is the official, project-driven book for the framework โ€” a PHP sibling to +[*pyfly by example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty directory into a secured, event-driven, actuator-observed microservice, chapter by chapter โ€” every listing drawn from that real project (its boot and test suite are verified in CI against the same framework source). @@ -1122,7 +1122,7 @@ Start at the **[documentation table of contents](docs/README.md)** โ€” it groups - [Laravel โ†” Spring Boot Comparison](docs/laravel-comparison.md) โ€” concept-by-concept mapping for both audiences. - [Versioning](docs/versioning.md) ยท [Contributing](docs/contributing.md) ยท [Publishing](docs/publishing.md). - Every [module guide](#modules) above. -- [*LaraFly by Example*](book/README.md) โ€” the complete bilingual book (16 chapters + appendices, PDF + EPUB). +- [*larafly by example*](book/README.md) โ€” the complete bilingual book (16 chapters + appendices, PDF + EPUB). - [`samples/lumen/`](samples/lumen/) โ€” the wallet-and-ledger sample this README's showcases are drawn from; run its own test suite with `vendor/bin/pest samples/lumen/tests`. @@ -1161,7 +1161,7 @@ still ahead, accurately: - **Read models / projections as a first-class concept.** The sample's `LedgerProjector` shows the pattern today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. -- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *LaraFly by Example* +- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example* [book](book/README.md) (16 chapters + appendices, EN + ES, PDF + EPUB), and a [docs table of contents](docs/README.md) all shipped with the documentation-parity milestone. Deeper guides (more recipes, more diagrams) continue to grow from here. diff --git a/book/README.md b/book/README.md index e83a48a9..94216212 100644 --- a/book/README.md +++ b/book/README.md @@ -1,6 +1,6 @@ -# LaraFly by Example โ€” the book build system +# larafly by example โ€” the book build system -This directory builds *LaraFly by Example*, a bilingual (EN + ES) book teaching +This directory builds *larafly by example*, a bilingual (EN + ES) book teaching LaraFly by walking through the `samples/lumen` project chapter by chapter. It mirrors `fireflyframework-pyfly/book/`'s architecture (WeasyPrint -> PDF, a hand-rolled EPUB3 assembler, python-markdown with custom directives), renamed @@ -95,7 +95,7 @@ book/ # note/tip/warning/laravel admonitions, codehilite epub.py # stdlib-only EPUB3 (OCF) zip assembler pdf.py # WeasyPrint HTML -> PDF - gen_cover.py # regenerates art/cover.{svg,png} (firefly/spark motif) + gen_cover.py # validates canonical art; --render rasterizes outlined SVGs verify_code.py # fenced ```php listings: `php -l`, except `source:` ones run.sh # sets DYLD_FALLBACK_LIBRARY_PATH, execs build.py requirements.txt # pinned: weasyprint, markdown, pygments, pyyaml, pytest, cairosvg @@ -105,7 +105,9 @@ book/ print.css # @page rules, running heads, page-break control (PDF only) pygments.css # syntax-highlighting token colors art/ - cover.svg, cover.png # generated by gen_cover.py + cover{,-es}.{svg,png} # canonical localized front covers + back-cover{,-es}.{svg,png} # canonical localized back covers + PROVENANCE.md # source and delivered artwork hashes figures/ # inline-SVG diagrams referenced by ::: figure openers/ # reserved for future per-chapter opener art (empty) src/ # EN manuscript (Markdown) @@ -168,3 +170,24 @@ code they run, and no `php` or `source:` block carries one. The repository's own prose guard derives both halves of that parity โ€” the per-chapter sizes, and the blocks themselves, byte for byte โ€” and fails the build the moment one edition stops matching the other. + +## Branded front and back covers + +The English and Spanish manifests select their own front/back SVG and PNG files +under `art/`. See [artwork provenance](art/PROVENANCE.md) for the canonical brand +kit source and delivered asset hashes. The SVG typography is outlined; the build +needs no author-machine fonts for the covers. + +```bash +book/.venv/bin/python book/build/gen_cover.py +``` + +This validates all four SVG/PNG pairs without modifying them. Use an explicit +`--render` only to rasterize the checked-in SVGs again. The book build fails if a +configured PNG is missing. Covers fill the PDF trim without running text; EPUB +editions include accessible front/back documents and localized navigation. + +Book-only editions can be published separately under `books-*` tags and must not +be marked as the latest framework release. Keep the book source commit and asset +checksums with the edition. A book-only edition does not change framework package +versions or replace the assets attached to an existing framework version. diff --git a/book/art/PROVENANCE.md b/book/art/PROVENANCE.md new file mode 100644 index 00000000..bbae800c --- /dev/null +++ b/book/art/PROVENANCE.md @@ -0,0 +1,80 @@ +# Book artwork provenance + +The front and back covers belong to the shared Firefly Framework book collection, +prepared on 2026-10-08. Canonical delivery: `Framework-Brand-Kit/11-Books/php/`. +They use the official Firefly Framework identity (A2 treatment) and Manrope +letterforms converted to vector paths. They contain no live font dependency. + +The source SVGs and delivered PNGs are 1500 ร— 1850 pixels, matching the book's +7.5 ร— 9.25 inch trim. English uses `cover` and `back-cover`; Spanish uses +`cover-es` and `back-cover-es`. Both are full pages in PDF and explicit bookends +in the EPUB reading order. Edition manifests supply accessible alternative text. + +`book/build/gen_cover.py` validates this canonical set without changing it. +An explicit `--render` recreates the PNGs from these SVGs with CairoSVG; it never +recreates the retired cover design or substitutes machine-local fonts. Raster +bytes can vary between renderers, so refresh the hashes below if replacing the +delivered PNGs. Publication checksums identify the actual PDF/EPUB bytes. + +## Delivered asset SHA-256 + +| Asset | SHA-256 | +|---|---| +| `cover.svg` | `d8faa683578c0297f57f8d1230daff157cf22d4d8c1cae3d4d33ccd55c800f04` | +| `cover.png` | `6b9dd580be852e410a7c76ff8dbbc01104390780f1b731477059b8860216eab2` | +| `cover-es.svg` | `d4a63f98680517a1fe68511b76f1125f72ad2d1e393def3c875c3117e706142e` | +| `cover-es.png` | `de603dcb41f1ae8ce2881012ffa3e5fed4e6e5871569eff247bca833467402d9` | +| `back-cover.svg` | `64a6b78c9ec4c23354ab0420579e9087a9b742b105617482cdbad96080ff290b` | +| `back-cover.png` | `da2e633783f90d47a18152d917271fa44e38ef5ba62a9e8ebd6beac2405b8c16` | +| `back-cover-es.svg` | `4fb65c27c3c586eba36374c54a6c664b9b780b5e9c6e16d5f9fbaefc9a7ee192` | +| `back-cover-es.png` | `2c2b9db429bbba63e9f30a6197167574cf022b50b00cca0ae04e2ab06f89c4cb` | + +## Documentation and interior diagram identity + +The 2026-10-08 identity pass applies shared ink (`#10110f`), warm neutral +(`#f3f1eb` / `#dedbd2`) and amber (`#ffb34a`) surfaces. Dark amber (`#8a5714`) +is reserved for readable lines and text on white. Existing blue, green and rust +status/callout colors and third-party marks retain their semantic distinction. + +`diagram-branding.json` inventories the diagrams and records SHA-256 fingerprints +of their original non-paint structure. These fingerprints cover every technical +label, path, arrow and layout attribute. Only paint, the old decorative raster +mark and the added footer are excluded. The original viewBox is recorded too. +The footer adds 7.8% of the original width below the content; its outlined family +lockup is embedded with unique SVG IDs and does not cover or scale any original +shape. Screen readers retain the diagram's original title/description. + +The canonical lockup is `docs/assets/larafly-logo-light.svg`, +from `Framework-Brand-Kit/12-Frameworks/php/`. +The documentation favicon and small diagram marks use the official kit's +`02-Icons/favicon.svg`. No legacy snake/insect raster is regenerated. + +Run `book/.venv/bin/python book/build/brand_diagrams.py --check` to validate +identity, geometry, text and mirrors without writing. Omit `--check` to apply +the idempotent palette/footer pass to a reviewed original source. An intentional +technical diagram edit requires review and updating its fingerprint; never reset +fingerprints merely to make a failing check pass. + +Book interiors use the same ink, paper and amber theme. Blue informational, +green tip, rust warning and native Spring/Laravel callout colors remain semantic. + +All nine hand-authored diagrams under `docs/assets/diagrams/` are mirrored +byte-for-byte into `book/art/figures/`. There is no Mermaid/PlantUML export step. + +For PDF only, `book/build/pdf.py` rasterizes the small canonical footer lockup +at 800 pixels wide before WeasyPrint renders it. This preserves the approved +gradient-y and clipped paths that WeasyPrint otherwise simplifies. Diagram +labels and technical shapes remain selectable text and vectors. Public SVGs +and EPUBs keep the outlined vector logo. Gradients whose stops are the same +color use identical solid paint so WeasyPrint does not drop header backgrounds. + +### Outbox layout correction + +The `outbox-flow.svg` commit bar previously covered the final pre-commit labels. +Its bar and atomic-commit caption now sit below both branches and `pg_notify`. +The transaction frame grows downward by 70 SVG units; the separate-consumer +section moves down by the same amount into existing bottom whitespace. All +text, arrow connections, width and overall viewBox remain unchanged. Both +mirrors match. The manifest retains the previous structure hash and explains +the intentional layout revision; a geometry guard keeps the commit bar clear +of labels and the consumer frame inside the original canvas. diff --git a/book/art/back-cover-es.png b/book/art/back-cover-es.png new file mode 100644 index 00000000..cc50fe60 Binary files /dev/null and b/book/art/back-cover-es.png differ diff --git a/book/art/back-cover-es.svg b/book/art/back-cover-es.svg new file mode 100644 index 00000000..5efc0ca9 --- /dev/null +++ b/book/art/back-cover-es.svg @@ -0,0 +1 @@ +larafly - ContraportadaFramework by Firefly \ No newline at end of file diff --git a/book/art/back-cover.png b/book/art/back-cover.png new file mode 100644 index 00000000..1fad75fd Binary files /dev/null and b/book/art/back-cover.png differ diff --git a/book/art/back-cover.svg b/book/art/back-cover.svg new file mode 100644 index 00000000..fbba2ece --- /dev/null +++ b/book/art/back-cover.svg @@ -0,0 +1 @@ +larafly - Back coverFramework by Firefly \ No newline at end of file diff --git a/book/art/cover-es.png b/book/art/cover-es.png new file mode 100644 index 00000000..a5b9c028 Binary files /dev/null and b/book/art/cover-es.png differ diff --git a/book/art/cover-es.svg b/book/art/cover-es.svg new file mode 100644 index 00000000..dfd19cc1 --- /dev/null +++ b/book/art/cover-es.svg @@ -0,0 +1 @@ +larafly by exampleFramework by Firefly \ No newline at end of file diff --git a/book/art/cover.png b/book/art/cover.png index 14d2492a..915a07b0 100644 Binary files a/book/art/cover.png and b/book/art/cover.png differ diff --git a/book/art/cover.svg b/book/art/cover.svg index 4c87a0f7..601e9571 100644 --- a/book/art/cover.svg +++ b/book/art/cover.svg @@ -1,114 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -FIREFLY SOFTWARE SOLUTIONS INC. - - - - - - - - - - - - - - - - - -LaraFly -by Example -Hexagonal PHP Microservices -on Laravel 13 with the Firefly Framework -Copyright (c) 2026 Firefly Software Solutions Inc. - \ No newline at end of file +larafly by exampleFramework by Firefly \ No newline at end of file diff --git a/book/art/diagram-branding.json b/book/art/diagram-branding.json new file mode 100644 index 00000000..8776c95b --- /dev/null +++ b/book/art/diagram-branding.json @@ -0,0 +1,87 @@ +{ + "version": "2026-10-08", + "logo": "docs/assets/larafly-logo-light.svg", + "mark": "docs/assets/larafly-favicon.svg", + "palette": { + "#4b5563": "#62645b", + "#1f2937": "#10110f", + "#374151": "#46483f", + "#6b7280": "#62645b", + "#9ca3af": "#a6a295", + "#d1d5db": "#dedbd2", + "#f3f4f6": "#f3f1eb", + "#f8fafc": "#faf9f6", + "#eef2ff": "#fff0d8", + "#ecfdf5": "#edf1e8" + }, + "diagrams": { + "docs/assets/diagrams/boot-pipeline.svg": { + "structure": "77a2180ee41aa084e582f8cbc1bc8ddbc18024c55eb9e37ac5fb6e1aea06919b", + "originalViewBox": "0 0 1120 1000", + "mirrors": [ + "book/art/figures/boot-pipeline.svg" + ] + }, + "docs/assets/diagrams/cqrs-eda-bridge.svg": { + "structure": "679ecd3e23e10c0dd19c97d6000e0bc927bc68f655645538ae9572d396452079", + "originalViewBox": "0 0 1200 820", + "mirrors": [ + "book/art/figures/cqrs-eda-bridge.svg" + ] + }, + "docs/assets/diagrams/di-autoconfig.svg": { + "structure": "61e54779e19b086c6fb735e45b2d8f84eab4766159d58e18e5b4974b8590cb3e", + "originalViewBox": "0 0 1120 970", + "mirrors": [ + "book/art/figures/di-autoconfig.svg" + ] + }, + "docs/assets/diagrams/method-interceptor-chain.svg": { + "structure": "44bab250ce3d89df314405594787ffaf4fe701f3ed7df342c5df9daabccc8017", + "originalViewBox": "0 0 1240 920", + "mirrors": [ + "book/art/figures/method-interceptor-chain.svg" + ] + }, + "docs/assets/diagrams/oauth2-authorization-code.svg": { + "structure": "68be371c70f2779811347000a211902cb2d4d000dddedfcf214f008ab7343651", + "originalViewBox": "0 0 1240 1070", + "mirrors": [ + "book/art/figures/oauth2-authorization-code.svg" + ] + }, + "docs/assets/diagrams/outbox-flow.svg": { + "structure": "56c906d58240aa387b8a663e6ecdae9ffe067e4a6f0d0c14e97044f9dae07a03", + "originalViewBox": "0 0 1200 1080", + "mirrors": [ + "book/art/figures/outbox-flow.svg" + ], + "previousStructure": "a5ae1cf023bf1d432a6038abcc4348e3d030938859098316469a4792c4af1d77", + "layoutRevision": { + "date": "2026-10-08", + "reason": "Move commit bar below both pre-commit branches and pg_notify label; extend transaction frame 70 units, shift consumer section 70 units into existing bottom whitespace. No text, topology, width or overall viewBox changes." + } + }, + "docs/assets/diagrams/request-lifecycle.svg": { + "structure": "a8dbb676a409939f39e9fc76b0828304b4d5f400de870f304c755402cf637db0", + "originalViewBox": "0 0 1300 900", + "mirrors": [ + "book/art/figures/request-lifecycle.svg" + ] + }, + "docs/assets/diagrams/security-filter-chain.svg": { + "structure": "7eee7837fdc8566d9718653cd3b97ed10ca41f4eaf9cc61f90ae781edb0d6a72", + "originalViewBox": "0 0 1240 900", + "mirrors": [ + "book/art/figures/security-filter-chain.svg" + ] + }, + "docs/assets/diagrams/tracing-propagation.svg": { + "structure": "8ac1bb2eefe84685714c9ecbcd402f72aed4f6fa20a5b9659c1612f0997ec5ed", + "originalViewBox": "0 0 1400 940", + "mirrors": [ + "book/art/figures/tracing-propagation.svg" + ] + } + } +} diff --git a/book/art/figures/boot-pipeline.svg b/book/art/figures/boot-pipeline.svg index 7d6bd77b..1c72fdf3 100644 --- a/book/art/figures/boot-pipeline.svg +++ b/book/art/figures/boot-pipeline.svg @@ -1,141 +1,143 @@ - + Boot pipeline register() buffers BootPass contributions into PendingBootPasses; the kernel drains and orders them at booting()/booted(). - + - + - Boot pipeline: register() buffers, the kernel drains + orders - packages/context/src/Boot/FireflyServiceProvider.php ยท PendingBootPasses.php ยท BootPhase.php + Boot pipeline: register() buffers, the kernel drains + orders + packages/context/src/Boot/FireflyServiceProvider.php ยท PendingBootPasses.php ยท BootPhase.php - Laravel discovers package providers ALPHABETICALLY โ€” order here is irrelevant to boot order + Laravel discovers package providers ALPHABETICALLY โ€” order here is irrelevant to boot order - - FireflyServiceProvider subclass - e.g. firefly/web, firefly/cqrs, โ€ฆ - register(): passes() โ†’ contributed BootPass[] - - - FireflyServiceProvider subclass - one per Firefly package - register(): passes() โ†’ contributed BootPass[] - - - FireflyServiceProvider subclass - โ€ฆand every other installed package - register(): passes() โ†’ contributed BootPass[] + + FireflyServiceProvider subclass + e.g. firefly/web, firefly/cqrs, โ€ฆ + register(): passes() โ†’ contributed BootPass[] + + + FireflyServiceProvider subclass + one per Firefly package + register(): passes() โ†’ contributed BootPass[] + + + FireflyServiceProvider subclass + โ€ฆand every other installed package + register(): passes() โ†’ contributed BootPass[] - - - + + + - - PendingBootPasses (shared, first-bound-wins) - .add(pass) โ€” buffered; nothing is resolved yet, so a capability provider - registering before the kernel is bound never fails + + PendingBootPasses (shared, first-bound-wins) + .add(pass) โ€” buffered; nothing is resolved yet, so a capability provider + registering before the kernel is bound never fails - + - - $app->booting() callback fires ONCE โ€” before ANY provider's own boot() + + $app->booting() callback fires ONCE โ€” before ANY provider's own boot() - + - - drainInto(): resolve FireflyKernel (now bound) โ†’ move every buffered - pass in; drain() empties the buffer + + drainInto(): resolve FireflyKernel (now bound) โ†’ move every buffered + pass in; drain() empties the buffer - + - - kernel->run(definitionStagePhases) โ€” DEFINITION stage: pure registry data; 650 is the one write + + kernel->run(definitionStagePhases) โ€” DEFINITION stage: pure registry data; 650 is the one write - - + + 100 ConfigAndProfiles - + 200 AutoConfigDiscovery - + 300 UserConfigurations - + 400 ConditionPassOne - + 500 AutoConfigurations - + 600 ConditionPassTwo - + 650 FlushDefinitions - + - - $app->booted() callback fires ONCE โ€” after EVERY provider's own boot() + + $app->booted() callback fires ONCE โ€” after EVERY provider's own boot() - + - - drainInto() again โ€” no-op now (buffer already empty); returns the SAME kernel + + drainInto() again โ€” no-op now (buffer already empty); returns the SAME kernel - + - - kernel->run(instanceStagePhases) โ€” INSTANCE stage: operates on resolved instances + + kernel->run(instanceStagePhases) โ€” INSTANCE stage: operates on resolved instances - - + + 700 BeanPostProcessors - + 800 EventListeners - + 850 InfrastructureStart - + 900 EagerSingletons - + 1000 WiringPasses - + 1200 ContextRefreshed - + - - bootApplicationContext(): kernel->boot() finalizes โ†’ ApplicationContext bound as singleton; - close() wired to terminating() (PHP-FPM) or WorkerStopping (Octane) + + bootApplicationContext(): kernel->boot() finalizes โ†’ ApplicationContext bound as singleton; + close() wired to terminating() (PHP-FPM) or WorkerStopping (Octane) - FireflyKernel::run() is idempotent PER PHASE โ€” every other provider's own booting()/booted() callback becomes a no-op after the first. + FireflyKernel::run() is idempotent PER PHASE โ€” every other provider's own booting()/booted() callback becomes a no-op after the first. + + diff --git a/book/art/figures/cqrs-eda-bridge.svg b/book/art/figures/cqrs-eda-bridge.svg index dd8d50b0..1ff7087e 100644 --- a/book/art/figures/cqrs-eda-bridge.svg +++ b/book/art/figures/cqrs-eda-bridge.svg @@ -1,94 +1,96 @@ - + CQRS / EDA bridge Command/query buses and the domain-to-integration-event after-commit bridge. - + - + - CQRS / EDA bridge: buses + the domain โ†’ integration-event trigger - packages/cqrs/src/{Command/DefaultCommandBus.php, Query/DefaultQueryBus.php, Event/DomainEventBridge.php, - Event/EdaCommandEventPublisher.php, Boot/DomainEventBridgeWiringPass.php} ยท packages/data/src/Domain/DomainEventDispatcher.php + CQRS / EDA bridge: buses + the domain โ†’ integration-event trigger + packages/cqrs/src/{Command/DefaultCommandBus.php, Query/DefaultQueryBus.php, Event/DomainEventBridge.php, + Event/EdaCommandEventPublisher.php, Boot/DomainEventBridgeWiringPass.php} ยท packages/data/src/Domain/DomainEventDispatcher.php - - CommandBus::send(command) - correlate โ†’ validate โ†’ authorize โ†’ - resolve + invoke #[CommandHandler] - (often #[Transactional]) โ†’ metrics + + CommandBus::send(command) + correlate โ†’ validate โ†’ authorize โ†’ + resolve + invoke #[CommandHandler] + (often #[Transactional]) โ†’ metrics - - QueryBus::ask(query) - correlate โ†’ validate โ†’ authorize โ†’ - cache-get (short-circuit) โ†’ resolve + - invoke #[QueryHandler] โ†’ cache-put โ†’ metrics - reads commit no DomainEvents โ€” never reaches the bridge + + QueryBus::ask(query) + correlate โ†’ validate โ†’ authorize โ†’ + cache-get (short-circuit) โ†’ resolve + + invoke #[QueryHandler] โ†’ cache-put โ†’ metrics + reads commit no DomainEvents โ€” never reaches the bridge - + - - Aggregate commits (firefly/data #[Transactional] proxy) โ†’ committed DomainEvent(s) published - to the in-process 'events' dispatcher inside DB::afterCommit (ApplicationEventPublisher) + + Aggregate commits (firefly/data #[Transactional] proxy) โ†’ committed DomainEvent(s) published + to the in-process 'events' dispatcher inside DB::afterCommit (ApplicationEventPublisher) - + - - DomainEventBridgeWiringPass (WiringPasses/1000): registers ONE guarded wildcard listener โ€” - dispatcher->listen('*', โ€ฆ) โ€” filters instanceof DomainEvent - (a typed #[AsEventListener] on the abstract DomainEvent base can never match a concrete subclass) + + DomainEventBridgeWiringPass (WiringPasses/1000): registers ONE guarded wildcard listener โ€” + dispatcher->listen('*', โ€ฆ) โ€” filters instanceof DomainEvent + (a typed #[AsEventListener] on the abstract DomainEvent base can never match a concrete subclass) - + - - DomainEventBridge::publish($event) โ€” applies EventFailureStrategy: - Log (default, swallow + log) or Raise (rethrow as CommandProcessingException) + + DomainEventBridge::publish($event) โ€” applies EventFailureStrategy: + Log (default, swallow + log) or Raise (rethrow as CommandProcessingException) - + - - CommandEventPublisher โ€” NoOpEventPublisher (default) or EdaCommandEventPublisher + + CommandEventPublisher โ€” NoOpEventPublisher (default) or EdaCommandEventPublisher - no eda EventPublisher bound โ†’ - stays NoOp, drops silently + no eda EventPublisher bound โ†’ + stays NoOp, drops silently - + - - EdaCommandEventPublisher maps DomainEvent โ†’ integration event: - eventType = short class name ยท payload = get_object_vars() - destination = explicit arg โ†’ #[PublishDomainEvent(destination:)] โ†’ firefly.cqrs.default_destination ('cqrs.events') - headers['x-correlation-id'] stamped from CorrelationContext + + EdaCommandEventPublisher maps DomainEvent โ†’ integration event: + eventType = short class name ยท payload = get_object_vars() + destination = explicit arg โ†’ #[PublishDomainEvent(destination:)] โ†’ firefly.cqrs.default_destination ('cqrs.events') + headers['x-correlation-id'] stamped from CorrelationContext - + - - firefly/eda EventPublisher port: publish(destination, eventType, payload, headers) + + firefly/eda EventPublisher port: publish(destination, eventType, payload, headers) - + - - + + memory - + queue - + rabbitmq - + postgres (same-tx outbox) - + kafka - Fires for ANY committed aggregate โ€” not only CommandBus writes. The bridge listens on the in-process - dispatcher, not on a specific command's aggregate. + Fires for ANY committed aggregate โ€” not only CommandBus writes. The bridge listens on the in-process + dispatcher, not on a specific command's aggregate. - Non-atomic here: the DB commit and the broker publish are two steps with a gap โ€” true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg). + Non-atomic here: the DB commit and the broker publish are two steps with a gap โ€” true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg). + + diff --git a/book/art/figures/di-autoconfig.svg b/book/art/figures/di-autoconfig.svg index 831ba34a..9690fe1d 100644 --- a/book/art/figures/di-autoconfig.svg +++ b/book/art/figures/di-autoconfig.svg @@ -1,90 +1,92 @@ - + Dependency injection and auto-configuration PSR-4 scanner to cached manifest to ConditionPassTwo to bean registry to eager singletons. - + - + - DI + auto-configuration: scan โ†’ manifest โ†’ condition pass โ†’ bean registry - packages/container/src/Scanner ยท packages/context/src/{Scanner,Pass} ยท packages/autoconfigure/src + DI + auto-configuration: scan โ†’ manifest โ†’ condition pass โ†’ bean registry + packages/container/src/Scanner ยท packages/context/src/{Scanner,Pass} ยท packages/autoconfigure/src - - PSR-4 scan roots โ€” config('firefly.scan.paths') + + PSR-4 scan roots โ€” config('firefly.scan.paths') - + - reflection happens ONCE, here โ€” never again at boot + reflection happens ONCE, here โ€” never again at boot - - ComponentScanner - discovers #[Component]/#[Configuration]/ - #[Bean]/stereotypes (e.g. #[RestController]) + + ComponentScanner + discovers #[Component]/#[Configuration]/ + #[Bean]/stereotypes (e.g. #[RestController]) - - ContextScanner - discovers #[PostConstruct]/#[PreDestroy]/ - #[AsEventListener]/#[ConditionalOn*] + + ContextScanner + discovers #[PostConstruct]/#[PreDestroy]/ + #[AsEventListener]/#[ConditionalOn*] - - + + - - ManifestCompiler.write(): var_export() โ†’ require-able PHP file + + ManifestCompiler.write(): var_export() โ†’ require-able PHP file - + - - Compiled manifest (ComponentManifest / ContextManifest) โ€” loaded with ZERO reflection at boot + + Compiled manifest (ComponentManifest / ContextManifest) โ€” loaded with ZERO reflection at boot - + - โ€” FireflyKernel boot phases โ€” + โ€” FireflyKernel boot phases โ€” - - AutoConfigDiscoveryPass (200): load each candidate's manifests, ASSEMBLE BeanDefinitions - tagged DefinitionSource::AutoConfiguration โ€” writes NOTHING to the registry yet + + AutoConfigDiscoveryPass (200): load each candidate's manifests, ASSEMBLE BeanDefinitions + tagged DefinitionSource::AutoConfiguration โ€” writes NOTHING to the registry yet - - + + - - UserConfigurations (300): user - #[Configuration]/#[Bean] enter the registry + + UserConfigurations (300): user + #[Configuration]/#[Bean] enter the registry - - ConditionPassOne (400): evaluate conditions - over USER definitions โ€” PARSE_CONFIGURATION + + ConditionPassOne (400): evaluate conditions + over USER definitions โ€” PARSE_CONFIGURATION - - + + - - AutoConfigurations (500): assembled definitions committed into the registry, tagged DefinitionSource::AutoConfiguration + + AutoConfigurations (500): assembled definitions committed into the registry, tagged DefinitionSource::AutoConfiguration - + - - ConditionPassTwoPass (600) โ€” REGISTER_BEAN, INCREMENTAL not batch - 1. pull every AutoConfiguration definition OUT of the registry - 2. sort the pulled-out candidates by (#[Order], FQCN) โ€” read from the manifest, never from a resolved instance - 3. for each, in that order: evaluate #[ConditionalOnMissingBean]/#[ConditionalOnProperty]/โ€ฆ against the registry - AS IT CURRENTLY STANDS (surviving User defs + already-accepted AutoConfig defs) โ€” add back only on a match - a definition never sees its own contribution while its own conditions run โ€” first-registered wins a #[ConditionalOnMissingBean] tie + + ConditionPassTwoPass (600) โ€” REGISTER_BEAN, INCREMENTAL not batch + 1. pull every AutoConfiguration definition OUT of the registry + 2. sort the pulled-out candidates by (#[Order], FQCN) โ€” read from the manifest, never from a resolved instance + 3. for each, in that order: evaluate #[ConditionalOnMissingBean]/#[ConditionalOnProperty]/โ€ฆ against the registry + AS IT CURRENTLY STANDS (surviving User defs + already-accepted AutoConfig defs) โ€” add back only on a match + a definition never sees its own contribution while its own conditions run โ€” first-registered wins a #[ConditionalOnMissingBean] tie - + - - FlushDefinitions (650): the ONE ContainerRegistrar::register() write โ†’ Bean registry + + FlushDefinitions (650): the ONE ContainerRegistrar::register() write โ†’ Bean registry - + - - EagerSingletonsPass (900): container->make() every non-#[Lazy] Scope::Singleton bean + - eager #[Bean] factory, sorted by (order, abstract) โ€” read from the manifest + + EagerSingletonsPass (900): container->make() every non-#[Lazy] Scope::Singleton bean + + eager #[Bean] factory, sorted by (order, abstract) โ€” read from the manifest - #[ConditionalOnMissingBean] is how a starter's default backs off the moment you supply your own bean. + #[ConditionalOnMissingBean] is how a starter's default backs off the moment you supply your own bean. + + diff --git a/book/art/figures/method-interceptor-chain.svg b/book/art/figures/method-interceptor-chain.svg index b2d0c16f..7f300914 100644 --- a/book/art/figures/method-interceptor-chain.svg +++ b/book/art/figures/method-interceptor-chain.svg @@ -1,102 +1,104 @@ - + Method interceptor chain How every AdviceSource contributes rows to one compiled ProxyPlan, how ProxyClassGenerator emits a single proxy class per bean carrying one interceptor property and one baked descriptor factory per advice kind, and how a call then runs security at advice order 100 before transaction at order 1000 through MethodInvocation::proceed(). - + - + - One proxy per bean: how an advice becomes a link in the chain - packages/data/src/Proxy/{Advice.php, AdviceSource.php, ProxyPlanner.php, ProxyPlanCompiler.php, ProxyClassGenerator.php, ProxyFactory.php, InterceptorRegistry.php, MethodInvocation.php} - packages/security/src/Access/Method/{MethodSecurityAdviceSource.php, MethodSecurityInterceptor.php} ยท packages/data/src/{Proxy/TransactionalAdviceSource.php, Transaction/TransactionInterceptor.php} + One proxy per bean: how an advice becomes a link in the chain + packages/data/src/Proxy/{Advice.php, AdviceSource.php, ProxyPlanner.php, ProxyPlanCompiler.php, ProxyClassGenerator.php, ProxyFactory.php, InterceptorRegistry.php, MethodInvocation.php} + packages/security/src/Access/Method/{MethodSecurityAdviceSource.php, MethodSecurityInterceptor.php} ยท packages/data/src/{Proxy/TransactionalAdviceSource.php, Transaction/TransactionInterceptor.php} - - Scan and compile โ€” php artisan firefly:cache (or an in-process scan of firefly.scan.paths when there is no cache) + + Scan and compile โ€” php artisan firefly:cache (or an in-process scan of firefly.scan.paths when there is no cache) - - MethodSecurityAdviceSource - #[Component] in firefly/security - advice(): Advice('security', MethodSecurityInterceptor, - SecurityMethodDescriptor, order 100, inertWhenUnbound: true) + + MethodSecurityAdviceSource + #[Component] in firefly/security + advice(): Advice('security', MethodSecurityInterceptor, + SecurityMethodDescriptor, order 100, inertWhenUnbound: true) - - TransactionalAdviceSource - #[Component] in firefly/data - advice(): Advice::transactional() โ€” Advice('tx', - TransactionInterceptor, TransactionalDescriptor, order 1000) + + TransactionalAdviceSource + #[Component] in firefly/data + advice(): Advice::transactional() โ€” Advice('tx', + TransactionInterceptor, TransactionalDescriptor, order 1000) - - โ€ฆany other AdviceSource - The port is open: a package contributes a kind of advice - by shipping one #[Component] that implements - AdviceSource โ€” advice(), scan($psr4), render($row). + + โ€ฆany other AdviceSource + The port is open: a package contributes a kind of advice + by shipping one #[Component] that implements + AdviceSource โ€” advice(), scan($psr4), render($row). - - - + + + - - ProxyPlanner merges every source's scan() rows into one ProxyPlan: - class โ†’ method โ†’ the ordered (advice id, descriptor row) pairs โ€” sources merge in Advice::order, so each list is outermost-first + + ProxyPlanner merges every source's scan() rows into one ProxyPlan: + class โ†’ method โ†’ the ordered (advice id, descriptor row) pairs โ€” sources merge in Advice::order, so each list is outermost-first - + - - ProxyPlanCompiler var_exports the plan into proxy-plan.php โ€” a literal the cached boot requires without reflecting - ProxyClassGenerator writes ONE class per bean: final class Foo__FireflyTransactionalProxy extends Foo + + ProxyPlanCompiler var_exports the plan into proxy-plan.php โ€” a literal the cached boot requires without reflecting + ProxyClassGenerator writes ONE class per bean: final class Foo__FireflyTransactionalProxy extends Foo - + - - The generated proxy โ€” one member pair PER ADVICE KIND the class uses - private MethodInterceptor $__fireflySecurityInterceptor; ยท private MethodInterceptor $__fireflyTxInterceptor; - static __fireflySecurityDescriptor('m') / __fireflyTxDescriptor('m') โ€” the descriptors baked as literals, no lookup, no reflection + + The generated proxy โ€” one member pair PER ADVICE KIND the class uses + private MethodInterceptor $__fireflySecurityInterceptor; ยท private MethodInterceptor $__fireflyTxInterceptor; + static __fireflySecurityDescriptor('m') / __fireflyTxDescriptor('m') โ€” the descriptors baked as literals, no lookup, no reflection - - Runtime โ€” one call, resolved from the container as the proxy, wrapped by ProxyFactory with the interceptors InterceptorRegistry hands it + + Runtime โ€” one call, resolved from the container as the proxy, wrapped by ProxyFactory with the interceptors InterceptorRegistry hands it - - $accounts->transfer($amount) - the caller holds the proxy, not the bean + + $accounts->transfer($amount) + the caller holds the proxy, not the bean - + - - (new MethodInvocation($this, AccountService::class, 'transfer', [$amount], - [$this->__fireflySecurityInterceptor, $this->__fireflyTxInterceptor], - [SecurityMethodDescriptor::class => self::__fireflySecurityDescriptor('transfer'), TransactionalDescriptor::class => โ€ฆ], - fn (array $__fireflyArgs) => parent::transfer(...$__fireflyArgs)))->proceed() + + (new MethodInvocation($this, AccountService::class, 'transfer', [$amount], + [$this->__fireflySecurityInterceptor, $this->__fireflyTxInterceptor], + [SecurityMethodDescriptor::class => self::__fireflySecurityDescriptor('transfer'), TransactionalDescriptor::class => โ€ฆ], + fn (array $__fireflyArgs) => parent::transfer(...$__fireflyArgs)))->proceed() - - - order 100 - MethodSecurityInterceptor โ€” reads SecurityMethodDescriptor, evaluates #[PreAuthorize] and #[PreFilter], - writes a narrowed argument back with setArguments(), then proceed(). A refusal throws HERE โ€” before any transaction exists. + + + order 100 + MethodSecurityInterceptor โ€” reads SecurityMethodDescriptor, evaluates #[PreAuthorize] and #[PreFilter], + writes a narrowed argument back with setArguments(), then proceed(). A refusal throws HERE โ€” before any transaction exists. - + - - - order 1000 - TransactionInterceptor โ€” reads TransactionalDescriptor: propagation, isolation, readOnly, the enforced timeout, - the rollback rules. Opens or joins the transaction through TransactionTemplate::execute(), then proceed(). + + + order 1000 + TransactionInterceptor โ€” reads TransactionalDescriptor: propagation, isolation, readOnly, the enforced timeout, + the rollback rules. Opens or joins the transaction through TransactionTemplate::execute(), then proceed(). - + - - terminal: parent::transfer(...$args) โ€” the real method, receiving whatever the list holds when it is finally reached + + terminal: parent::transfer(...$args) โ€” the real method, receiving whatever the list holds when it is finally reached - + - - Unwinding, outermost LAST: TransactionInterceptor commits or rolls back, then MethodSecurityInterceptor's after() - applies #[PostAuthorize] and #[PostFilter] to the value the method returned. + + Unwinding, outermost LAST: TransactionInterceptor commits or rolls back, then MethodSecurityInterceptor's after() + applies #[PostAuthorize] and #[PostFilter] to the value the method returned. - Lower advice order runs OUTER โ€” the same ordering Spring documents for @EnableMethodSecurity against @EnableTransactionManagement. It is what makes - "a refusal never opens a transaction" a property of the compiled plan rather than a convention anyone has to remember. - An advice whose interceptor bean is absent is refused at boot (ConfigurationException) unless it declared inertWhenUnbound โ€” security does, because - "annotations are inert until firefly.security.enabled is on" is its documented state โ€” and InterceptorRegistry then hands the proxy a PassThroughInterceptor. + Lower advice order runs OUTER โ€” the same ordering Spring documents for @EnableMethodSecurity against @EnableTransactionManagement. It is what makes + "a refusal never opens a transaction" a property of the compiled plan rather than a convention anyone has to remember. + An advice whose interceptor bean is absent is refused at boot (ConfigurationException) unless it declared inertWhenUnbound โ€” security does, because + "annotations are inert until firefly.security.enabled is on" is its documented state โ€” and InterceptorRegistry then hands the proxy a PassThroughInterceptor. + + diff --git a/book/art/figures/oauth2-authorization-code.svg b/book/art/figures/oauth2-authorization-code.svg index b53d950f..fc7251e1 100644 --- a/book/art/figures/oauth2-authorization-code.svg +++ b/book/art/figures/oauth2-authorization-code.svg @@ -1,126 +1,128 @@ - + OAuth2 authorization code with PKCE The full authorization-code round trip across three parties โ€” the browser, a LaraFly relying party running firefly/security-oauth2-client and a LaraFly authorization server running firefly/security-oauth2-server โ€” naming the real endpoint paths, the entry point's redirect to the login page, the 302 the browser follows out to the authorization endpoint, the single-use state, the nonce, the S256 code challenge and verifier, the authorization server's own login and consent pages, the single-use code, the back-channel token exchange and the JWKS verification of the id token. - + - + - Authorization code + PKCE: signing in, end to end - packages/security-oauth2-client/src/{OAuth2ClientSettings.php, Web/OAuth2AuthorizationRequestRedirectFilter.php, Web/OAuth2LoginAuthenticationFilter.php} - packages/security-oauth2-server/src/Web/{OAuth2AuthorizationServerFilter.php, AuthorizationEndpoint.php, Consent/ConsentPage.php, Grant/AuthorizationCodeGrant.php, TokenEndpoint.php, JwkSetEndpoint.php} - packages/security/src/Web/{HttpSecurityFilter.php, EntryPoint/LoginUrlAuthenticationEntryPoint.php, Login/FormLoginFilter.php, Settings/FormLoginSettings.php} + Authorization code + PKCE: signing in, end to end + packages/security-oauth2-client/src/{OAuth2ClientSettings.php, Web/OAuth2AuthorizationRequestRedirectFilter.php, Web/OAuth2LoginAuthenticationFilter.php} + packages/security-oauth2-server/src/Web/{OAuth2AuthorizationServerFilter.php, AuthorizationEndpoint.php, Consent/ConsentPage.php, Grant/AuthorizationCodeGrant.php, TokenEndpoint.php, JwkSetEndpoint.php} + packages/security/src/Web/{HttpSecurityFilter.php, EntryPoint/LoginUrlAuthenticationEntryPoint.php, Login/FormLoginFilter.php, Settings/FormLoginSettings.php} - - - - - Browser - Relying party ยท firefly/security-oauth2-client - Authorization server ยท firefly/security-oauth2-server - - - - 1 - GET /orders + + + + + Browser + Relying party ยท firefly/security-oauth2-client + Authorization server ยท firefly/security-oauth2-server + + + + 1 + GET /orders no session cookie, no Authorization header - - 2 - HttpSecurityFilter (-70) denies โ†’ the entry point + + 2 + HttpSecurityFilter (-70) denies โ†’ the entry point the GET is saved in the session; 302 โ†’ /login - - 3 - the login page, one button per provider + + 3 + the login page, one button per provider "Sign in with โ€ฆ" โ†’ GET /oauth2/authorization/{id} - - 4 - OAuth2AuthorizationRequestRedirectFilter (-89) + + 4 + OAuth2AuthorizationRequestRedirectFilter (-89) state, nonce and verifier in the session; 302 to the browser - - 5 - the browser follows the 302 out + + 5 + the browser follows the 302 out GET /oauth2/authorize?โ€ฆ&code_challenge_method=S256 - - 6 - OAuth2AuthorizationServerFilter (-82) matches the GET + + 6 + OAuth2AuthorizationServerFilter (-82) matches the GET /oauth2/authorize ยท state ยท nonce ยท code_challenge (S256) - - 7 - no session here either โ†’ the server's own /login + + 7 + no session here either โ†’ the server's own /login FormLoginFilter (-92) signs them in; the instant is stamped - - 8 - ConsentPage โ€” a checkbox per scope, Allow or Deny + + 8 + ConsentPage โ€” a checkbox per scope, Allow or Deny skipped when the client requires none, or consent is stored - - 9 - AuthorizationEndpoint mints a SINGLE-USE code + + 9 + AuthorizationEndpoint mints a SINGLE-USE code 302 โ†’ redirect_uri?code=โ€ฆ&state=โ€ฆ โ€” the registered URI - - 10 - the browser follows the redirect back + + 10 + the browser follows the redirect back GET /login/oauth2/code/{id}?code=โ€ฆ&state=โ€ฆ - - 11 - OAuth2LoginAuthenticationFilter (-88) + + 11 + OAuth2LoginAuthenticationFilter (-88) state spent and compared with hash_equals; redirect_uri exact - - 12 - back channel: POST /oauth2/token + + 12 + back channel: POST /oauth2/token grant_type=authorization_code ยท code ยท code_verifier - - TokenEndpoint โ†’ AuthorizationCodeGrant + + TokenEndpoint โ†’ AuthorizationCodeGrant verifier hashed and compared, code burned, tokens minted - - 13 - id_token validated: iss ยท aud ยท exp ยท nonce + + 13 + id_token validated: iss ยท aud ยท exp ยท nonce the signature checked against the provider's published keys - - GET /oauth2/jwks โ€” JwkSetEndpoint + + GET /oauth2/jwks โ€” JwkSetEndpoint the public half of the key firefly:oauth2:keys made, by kid - - 14 - OidcUser built; the session id is regenerated + + 14 + OidcUser built; the session id is regenerated context saved; success events fire; 302 to the saved request - - - - - - - - - - - - - - - - - Steps 1, 3, 5 and 10 are the browser; 2, 4 and 11-14 the relying party (firefly/security-oauth2-client); 6-9 the authorization server (firefly/security-oauth2-server), - whose /login is firefly/security's and whose consent page is its own. Either half stands alone โ€” the relying party against Google, Okta or Keycloak through a preset. - Steps 5 and 10 are the front channel: the browser itself carries state, nonce and the S256 challenge out, and the code back. - Steps 12 and 13 are back-channel calls the browser never sees: the code alone is worthless without the verifier only the relying party kept โ€” that is PKCE. + + + + + + + + + + + + + + + + + Steps 1, 3, 5 and 10 are the browser; 2, 4 and 11-14 the relying party (firefly/security-oauth2-client); 6-9 the authorization server (firefly/security-oauth2-server), + whose /login is firefly/security's and whose consent page is its own. Either half stands alone โ€” the relying party against Google, Okta or Keycloak through a preset. + Steps 5 and 10 are the front channel: the browser itself carries state, nonce and the S256 challenge out, and the code back. + Steps 12 and 13 are back-channel calls the browser never sees: the code alone is worthless without the verifier only the relying party kept โ€” that is PKCE. + + diff --git a/book/art/figures/outbox-flow.svg b/book/art/figures/outbox-flow.svg index 82887cb0..9bbeee8b 100644 --- a/book/art/figures/outbox-flow.svg +++ b/book/art/figures/outbox-flow.svg @@ -1,114 +1,116 @@ - + The genuine same-transaction outbox Aggregate transaction writes the outbox row in-tx; a separate terminal consumer claims PENDING rows with FOR UPDATE SKIP LOCKED. - + - + - The genuine same-transaction outbox - packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php} - packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php} + The genuine same-transaction outbox + packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php} + packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php} - - IN THE AGGREGATE'S OWN TRANSACTION โ€” single connection + + IN THE AGGREGATE'S OWN TRANSACTION โ€” single connection - - #[Transactional] method call โ†’ TransactionInterceptor โ†’ TransactionTemplate::execute() + + #[Transactional] method call โ†’ TransactionInterceptor โ†’ TransactionTemplate::execute() - + - - connection->beginTransaction() + + connection->beginTransaction() - + - - $work() runs: aggregate mutates state; RecordsDomainEvents records events onto AggregateTracker + + $work() runs: aggregate mutates state; RecordsDomainEvents records events onto AggregateTracker - + - - Pre-commit drain (still inside the tx): DomainEventDispatcher::dispatchAfterCommit($connection) - drains the tracker โ€” for each event, afterCommit($event, $connection) runs: + + Pre-commit drain (still inside the tx): DomainEventDispatcher::dispatchAfterCommit($connection) + drains the tracker โ€” for each event, afterCommit($event, $connection) runs: - + - - (a) OutboxPreCommitHook โ€” the same-tx path - resolves the AGGREGATE'S OWN connection - (ConnectionResolverInterface) - new PostgresEventPublisher(conn, channel, - emitNotify = driver is pgsql) - EdaCommandEventPublisher::publish(event) โ†’ INSERT - firefly_eda_outbox (status=PENDING) on THIS connection/tx + + (a) OutboxPreCommitHook โ€” the same-tx path + resolves the AGGREGATE'S OWN connection + (ConnectionResolverInterface) + new PostgresEventPublisher(conn, channel, + emitNotify = driver is pgsql) + EdaCommandEventPublisher::publish(event) โ†’ INSERT + firefly_eda_outbox (status=PENDING) on THIS connection/tx - - (b) in-process leg - (separate from the outbox) - DB::connection($connection)->afterCommit( - fn() => ApplicationEventPublisher->publish($event) - ) โ€” registered now, fires LATER after the real commit - drives in-process #[AsEventListener]s only โ€” - no outbox write on this leg + + (b) in-process leg + (separate from the outbox) + DB::connection($connection)->afterCommit( + fn() => ApplicationEventPublisher->publish($event) + ) โ€” registered now, fires LATER after the real commit + drives in-process #[AsEventListener]s only โ€” + no outbox write on this leg - + SELECT pg_notify(channel, id) โ€” same tx, pgsql only + + SELECT pg_notify(channel, id) โ€” same tx, pgsql only - + - - connection->commit() + + connection->commit() - ATOMIC โ€” outbox row + aggregate's own writes commit together, or both roll back + ATOMIC โ€” outbox row + aggregate's own writes commit together, or both roll back - - separate process โ€” woken by LISTEN/NOTIFY, or by its own poll fallback + + separate process โ€” woken by LISTEN/NOTIFY, or by its own poll fallback - - TERMINAL CONSUMER โ€” separate long-running process (firefly:eda:consume โ†’ PostgresEventConsumer) + + TERMINAL CONSUMER โ€” separate long-running process (firefly:eda:consume โ†’ PostgresEventConsumer) - - subscribe(): LISTEN <channel> (pgsql) + + subscribe(): LISTEN <channel> (pgsql) - + - - poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY โ€” - delivered exactly when the row's commit lands + + poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY โ€” + delivered exactly when the row's commit lands - + - - claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING' - ORDER BY id FOR UPDATE SKIP LOCKED (pgsql) + + claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING' + ORDER BY id FOR UPDATE SKIP LOCKED (pgsql) - + - - ConsumerLoop delivers the ReceivedEnvelope to SubscriberRegistry โ†’ - drives matching #[EventListener] handler(s) + + ConsumerLoop delivers the ReceivedEnvelope to SubscriberRegistry โ†’ + drives matching #[EventListener] handler(s) - - + + - - success โ†’ ack(): UPDATE โ€ฆ SET status='PUBLISHED' - WHERE id=? AND status='PENDING' (idempotent) + + success โ†’ ack(): UPDATE โ€ฆ SET status='PUBLISHED' + WHERE id=? AND status='PENDING' (idempotent) - - throw โ†’ nack(): attempts+1 โ†’ back to PENDING (retry) - or, past max_attempts, status='FAILED' + + throw โ†’ nack(): attempts+1 โ†’ back to PENDING (retry) + or, past max_attempts, status='FAILED' - No EventPublisher::publish() call anywhere in this consumer path โ€” it reads/updates the outbox - table directly: no re-INSERT, no self-reference. + No EventPublisher::publish() call anywhere in this consumer path โ€” it reads/updates the outbox + table directly: no re-INSERT, no self-reference. - A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent). + A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent). + + diff --git a/book/art/figures/request-lifecycle.svg b/book/art/figures/request-lifecycle.svg index 59f9f0f2..7d2e651e 100644 --- a/book/art/figures/request-lifecycle.svg +++ b/book/art/figures/request-lifecycle.svg @@ -1,105 +1,107 @@ - + Request lifecycle WebFilter chain, ControllerDispatcher, method security, and RFC-7807 error rendering. - + - + - Request lifecycle: filters โ†’ dispatcher โ†’ method security โ†’ controller - packages/web/src/{Filter,Dispatch,Route} ยท packages/security/src/Web/MethodSecurityControllerGuard.php + Request lifecycle: filters โ†’ dispatcher โ†’ method security โ†’ controller + packages/web/src/{Filter,Dispatch,Route} ยท packages/security/src/Web/MethodSecurityControllerGuard.php - - HTTP Request + + HTTP Request - + - - Laravel HTTP Kernel middleware stack + + Laravel HTTP Kernel middleware stack - + - - RequestContextFilter, CorrelationIdFilter โ€” fixed, prepended by FilterChainRegistrar + + RequestContextFilter, CorrelationIdFilter โ€” fixed, prepended by FilterChainRegistrar - + - - Ordered WebFilter beans (#[Order], read from the manifest) โ€” FilterChainRegistrar - pushes them onto the global middleware stack at WiringPasses (1000) + + Ordered WebFilter beans (#[Order], read from the manifest) โ€” FilterChainRegistrar + pushes them onto the global middleware stack at WiringPasses (1000) - + - - Native Laravel route (RouteWiringPass, one per compiled RouteDescriptor) โ†’ - dispatch closure: ControllerDispatcher::actionFor() + + Native Laravel route (RouteWiringPass, one per compiled RouteDescriptor) โ†’ + dispatch closure: ControllerDispatcher::actionFor() - + - - ControllerDispatcher::actionFor() โ€” per request, inside try { โ€ฆ } catch (Throwable) + + ControllerDispatcher::actionFor() โ€” per request, inside try { โ€ฆ } catch (Throwable) - - container->make(controllerClass) โ€” DI-wired #[RestController] singleton + + container->make(controllerClass) โ€” DI-wired #[RestController] singleton - + - - ArgumentResolver::resolve(bindings, request, container) โ€” binds #[PathVariable]/ - #[QueryParam]/#[RequestBody]/#[RequestHeader]/#[UploadedFile]; #[Valid] triggers BeanValidator + + ArgumentResolver::resolve(bindings, request, container) โ€” binds #[PathVariable]/ + #[QueryParam]/#[RequestBody]/#[RequestHeader]/#[UploadedFile]; #[Valid] triggers BeanValidator - + - - ControllerSecurityGuard::check() โ€” MethodSecurityControllerGuard evaluates the compiled - #[PreAuthorize] rule (SecurityMethodManifest, no-eval) against SecurityContextHolder + + ControllerSecurityGuard::check() โ€” MethodSecurityControllerGuard evaluates the compiled + #[PreAuthorize] rule (SecurityMethodManifest, no-eval) against SecurityContextHolder - + - - controller method invoked (#[RestController]) + + controller method invoked (#[RestController]) - + - - ResponseFactory โ€” content negotiation against Accept (JsonMessageConverter) + + ResponseFactory โ€” content negotiation against Accept (JsonMessageConverter) - + - - HTTP Response (2xx) + + HTTP Response (2xx) - exception path - + exception path + - - Throwable during dispatch โ†’ - ExceptionHandlerRegistry.resolve(e, controllerClass) + + Throwable during dispatch โ†’ + ExceptionHandlerRegistry.resolve(e, controllerClass) - + - - Handler found (controller-local - #[ExceptionHandler] wins): invoke it, - render at the exception's status. No - handler: rethrow, unchanged + + Handler found (controller-local + #[ExceptionHandler] wins): invoke it, + render at the exception's status. No + handler: rethrow, unchanged - + - - Propagates to Laravel's exception handler: - RFC-7807 ProblemDetailsRenderer + + Propagates to Laravel's exception handler: + RFC-7807 ProblemDetailsRenderer - + - - application/problem+json response + + application/problem+json response - A thrown AuthorizationException/AuthenticationException from the security guard flows through the SAME try/catch into this exception path. + A thrown AuthorizationException/AuthenticationException from the security guard flows through the SAME try/catch into this exception path. + + diff --git a/book/art/figures/security-filter-chain.svg b/book/art/figures/security-filter-chain.svg index 4b540b5c..d8d92e75 100644 --- a/book/art/figures/security-filter-chain.svg +++ b/book/art/figures/security-filter-chain.svg @@ -1,152 +1,152 @@ - + Security filter chain The ordered WebFilter chain on Laravel's global middleware stack โ€” the two prepended framework filters, tracing, HTTP exchanges and metrics, security headers, session context persistence, logout, the five authentication mechanisms, the OAuth2 client and authorization-server filters, CSRF and the deny-by-default URL rules โ€” with each filter's real #[Order] value, and the DelegatingAuthenticationEntryPoint that decides between a login redirect, a Basic challenge and a 401. - + - + - The security filter chain: every filter, in the order the registrar sorts it - packages/web/src/Filter/{FilterChainRegistrar.php, RequestContextFilter.php, CorrelationIdFilter.php} ยท packages/observability/src/Web/{TracingFilter.php, HttpExchangeFilter.php, MetricsFilter.php} - packages/security/src/{Web,Web/Basic,Web/Login,Web/Logout,Web/RememberMe,Web/EntryPoint,Session,OAuth2}/*.php - packages/security-oauth2-client/src/Web/*.php ยท packages/security-oauth2-server/src/Web/OAuth2AuthorizationServerFilter.php + The security filter chain: every filter, in the order the registrar sorts it + packages/web/src/Filter/{FilterChainRegistrar.php, RequestContextFilter.php, CorrelationIdFilter.php} ยท packages/observability/src/Web/{TracingFilter.php, HttpExchangeFilter.php, MetricsFilter.php} + packages/security/src/{Web,Web/Basic,Web/Login,Web/Logout,Web/RememberMe,Web/EntryPoint,Session,OAuth2}/*.php + packages/security-oauth2-client/src/Web/*.php ยท packages/security-oauth2-server/src/Web/OAuth2AuthorizationServerFilter.php - - Laravel's global middleware stack ยท FilterChainRegistrar::orderedFilters() + + Laravel's global middleware stack ยท FilterChainRegistrar::orderedFilters() - - - - prepend + + + + prepend RequestContextFilter - seeds firefly.request_id into Laravel's Context - - - prepend + seeds firefly.request_id into Laravel's Context + + + prepend CorrelationIdFilter - reads or mints X-Correlation-Id, echoes it back - - - -110 + reads or mints X-Correlation-Id, echoes it back + + + -110 TracingFilter - SERVER span; continues an inbound traceparent - - - -100 + SERVER span; continues an inbound traceparent + + + -100 HttpExchangeFilter - records the row /actuator/httpexchanges serves - - - -100 + records the row /actuator/httpexchanges serves + + + -100 MetricsFilter - the http_server_requests_seconds timer - - - -95 + the http_server_requests_seconds timer + + + -95 SecurityHeadersFilter - HSTS, CSP, frame, nosniff and referrer headers - - - -94 + HSTS, CSP, frame, nosniff and referrer headers + + + -94 SecurityContextPersistenceFilter - loads the context from the session; saves it after - - - -93 + loads the context from the session; saves it after + + + -93 LogoutFilter - POST /logout: CSRF, then the LogoutHandler - - - -92 + POST /logout: CSRF, then the LogoutHandler + + + -92 FormLoginFilter - POST /login: CSRF, then the AuthenticationManager - - - -91 + POST /login: CSRF, then the AuthenticationManager + + + -91 HttpBasicFilter - Authorization: Basic โ€” its OWN entry point on failure - - - -90 + Authorization: Basic โ€” its OWN entry point on failure + + + -90 JwtAuthenticationFilter - Authorization: Bearer, verified by JwtService - - - -89 + Authorization: Bearer, verified by JwtService + + + -89 OAuth2AuthorizationRequestRedirectFilter - /oauth2/authorization/{id} โ†’ the provider - - - -88 + /oauth2/authorization/{id} โ†’ the provider + + + -88 OAuth2LoginAuthenticationFilter - /login/oauth2/code/{id} โ†’ the code exchange - - - -85 + /login/oauth2/code/{id} โ†’ the code exchange + + + -85 OAuth2ResourceServerFilter - a Bearer JWT checked against the issuer's JWKS - - - -83 + a Bearer JWT checked against the issuer's JWKS + + + -83 RememberMeAuthenticationFilter - the cookie, once per session, not per request - - - -82 + the cookie, once per session, not per request + + + -82 OAuth2AuthorizationServerFilter - /oauth2/authorize, /token, /jwks, /.well-known/* - - - -80 + /oauth2/authorize, /token, /jwks, /.well-known/* + + + -80 CsrfFilter - the token for every unsafe method - - - -70 + the token for every unsafe method + + + -70 HttpSecurityFilter - deny-by-default URL rules, first match wins + deny-by-default URL rules, first match wins - + - - ControllerDispatcher โ†’ the #[RestController] action - MethodSecurityControllerGuard enforces a controller's own #[PreAuthorize] here, at dispatch + + ControllerDispatcher โ†’ the #[RestController] action + MethodSecurityControllerGuard enforces a controller's own #[PreAuthorize] here, at dispatch - + - - DelegatingAuthenticationEntryPoint - firefly.security.http.entry_point ยท default auto - - auto + + DelegatingAuthenticationEntryPoint + firefly.security.http.entry_point ยท default auto + + auto a BROWSER โ€” ErrorPageRenderer::prefersHtml(): the request names text/html, is neither an XHR nor a wantsJson() call, and its path is not in firefly.web.error-page.json-paths โ€” is sent to the login page when form login or OAuth2 login is on; else a Basic challenge when HTTP Basic is on; else the 401, rendered by firefly/web. - login + login always the login page โ€” refused at boot when neither form login nor OAuth2 login is enabled. - challenge + challenge always WWW-Authenticate: Basic. - problem + problem always the exception, rendered by firefly/web. - prefersHtml(), never handles(): error-page.enabled is a branding - choice and must never decide whether a person is asked to sign in. - Reached from HttpSecurityFilter's anonymous denial, and from the - authorization server's /oauth2/authorize. HttpBasicFilter answers - with BasicAuthenticationEntryPoint instead โ€” never this one, because - a caller that presented Basic has already chosen its mechanism. + prefersHtml(), never handles(): error-page.enabled is a branding + choice and must never decide whether a person is asked to sign in. + Reached from HttpSecurityFilter's anonymous denial, and from the + authorization server's /oauth2/authorize. HttpBasicFilter answers + with BasicAuthenticationEntryPoint instead โ€” never this one, because + a caller that presented Basic has already chosen its mechanism. - - How the order is decided - + + How the order is decided + orderedFilters() sorts every WebFilter bean by its ComponentDescriptor::$order โ€” the manifest's number, never a resolved instance's โ€” and breaks ties with strcmp on the class @@ -155,9 +155,9 @@ PREPENDED: they carry no #[Order] and are never sorted. - - What is NOT in this chain - + + What is NOT in this chain + #[PreAuthorize] on a controller action is enforced at dispatch by MethodSecurityControllerGuard, asked by web's ControllerDispatcher. #[PreAuthorize] on any OTHER stereotyped bean is enforced by @@ -169,7 +169,9 @@ orderedFilters() matches on the COMPONENT class only. - Every DISCOVERED filter extends OncePerRequestFilter and is an ordinary #[Component] behind at least one #[ConditionalOnProperty]: installing a package - adds its link, and switching the feature off removes it. Nothing here is a hard-coded list โ€” only the two prepended framework filters are unconditional. - Order values are the literal #[Order(...)] arguments on the classes named; they are the contract a WebFilter of your own inserts itself against. + Every DISCOVERED filter extends OncePerRequestFilter and is an ordinary #[Component] behind at least one #[ConditionalOnProperty]: installing a package + adds its link, and switching the feature off removes it. Nothing here is a hard-coded list โ€” only the two prepended framework filters are unconditional. + Order values are the literal #[Order(...)] arguments on the classes named; they are the contract a WebFilter of your own inserts itself against. + + diff --git a/book/art/figures/tracing-propagation.svg b/book/art/figures/tracing-propagation.svg index 838f17d0..e2d8f6f6 100644 --- a/book/art/figures/tracing-propagation.svg +++ b/book/art/figures/tracing-propagation.svg @@ -1,114 +1,116 @@ - + Trace context propagation A W3C traceparent entering at the server filter and flowing outward: the SERVER span published to Laravel Context as firefly.trace_id and firefly.span_id, an INTERNAL span on each CQRS message, a PRODUCER span on publish and a CONSUMER span on delivery around an EDA envelope whose headers carry the traceparent โ€” the in-memory and queue buses, the RabbitMQ, Kafka and Postgres publishers and the outbox relay all reach that seam, while SubscriberRegistrySink traces the delivery side for every broker consumer โ€” and a CLIENT span on every outbound Http call that injects it again, landing on every log line, on the httpexchanges endpoint and on the dashboard. - + - - - One traceparent, five boundaries, three places you can read it - packages/observability/src/Web/{TracingFilter.php, HttpClientTracingMiddleware.php} ยท packages/observability/src/Tracing/W3CTraceContextPropagator.php - packages/observability/src/{Cqrs/TracerCqrsTracing.php, Eda/TracerEdaTracing.php, Logging/TraceContextLogProcessor.php, Boot/HttpClientTracingPass.php} ยท packages/eda/src/Tracing/BrokerTracing.php - - - Inbound request header - traceparent: 00-{32 hex trace-id}-{16 hex span-id}-01 - absent โ†’ a new root trace starts here - - - - - TracingFilter โ€” #[Order(-110)], the outermost ordered filter in the chain - W3CTraceContextPropagator::extract($request->headers->all()) โ†’ the remote parent ยท startSpan($method, SpanKind::Server, โ€ฆ, $parent) - renamed to `GET /orders/{id}` once the router has matched ยท http.request.method, url.path, url.scheme, server.address, http.route, http.response.status_code, firefly.correlation_id ยท ERROR on 5xx - - - - - Laravel Context and Request::$attributes - firefly.trace_id ยท firefly.span_id โ€” the one place everything downstream reads the ids from - - - - - - - CQRS ยท in-process - CqrsTracing seam in firefly/cqrs (NoOpCqrsTracing by - default), filled by TracerCqrsTracing. - INTERNAL span per command and per query, - named by the message's short class name - firefly.cqrs.kind ยท firefly.cqrs.message - nothing to carry โ€” it nests under whatever is current - gated by firefly.observability.tracing.cqrs.enabled, which - is on by default under the master tracing gate. - - - EDA ยท across the transport - EdaTracing seam in firefly/eda, filled by TracerEdaTracing. Reached from the in-memory - and queue buses, from SubscriberRegistrySink (the sink every broker consumer feeds), from - RabbitMqEventPublisher, KafkaEventPublisher and PostgresEventPublisher (both its writers), - and from OutboxRelay, whose traceConsume() keeps the relay hop on the row's own trace. - PRODUCER `publish <destination>` writes traceparent/tracestate into the headers - CONSUMER `process <destination>` on delivery reads them back and continues that trace - gated by firefly.observability.tracing.eda.enabled; firefly.eda.tracing.brokers.enabled=false - downgrades the three broker publishers alone to the no-op, so none of ours is stamped. - - - Outbound HTTP - HttpClientTracingMiddleware, installed on the Http - factory by HttpClientTracingPass at boot. - CLIENT span ยท inject() writes traceparent on the - PSR-7 request โ€” url.path, never url.full - ERROR at โ‰ฅ 400 or on a rejection - works under Http::fake(): global middleware is outermost - gated by firefly.observability.tracing.http-client.enabled, - read by the boot pass rather than by a bean condition. - - - - - - - The next service's own TracingFilter extracts the same traceparent โ€” and the trace continues, across the process boundary, with no shared code - The propagator speaks W3C Trace Context over a plain header map, with no SDK involved: version `ff` and all-zero ids are rejected, a future version is read for its first four fields. - - Where the ids land - - - Every log line - TraceContextLogProcessor adds trace_id and span_id - to each record, beside the correlation and request ids. - firefly.logging.structured.format: json ยท ecs ยท logstash - attached per channel at resolution time, so a channel - built after boot with Log::build() gets neither. - - - /actuator/httpexchanges - HttpExchangeFilter (-100) runs INSIDE the tracing filter, so the span already exists - when it records the exchange, and it reads the traceId TracingFilter published off - Request::$attributes rather than off Context. - every row carries the trace it belonged to - a ring of the last 100 by default โ€” httpexchanges.capacity, clamped to [1, 10000] - - - The admin dashboard - firefly/admin renders the same exchanges page - server-side from the actuator registries โ€” no - second data path, no JavaScript fetch. - read its access model before exposing it: the - dashboard ships no authentication of its own. - - - What is actually recording - The shipped default is NoOpTracer: non-recording spans, an invalid SpanContext, currentSpan() of null โ€” every instrumentation site tests - $span->context()->isValid() before publishing ids. The OpenTelemetry adapter binds itself when the SDK is installed and firefly.observability.tracing.enabled is on. - - Both seams follow the CqrsMetrics shape: an interface in the owning package with a no-op default behind #[ConditionalOnMissingBean], and observability's - #[Order(500)] auto-configuration registering the real implementation first. Neither firefly/cqrs nor firefly/eda depends on firefly/observability. - A span whose end is deferred past the frame that started it โ€” the CLIENT span of an Http call ends in the promise's then() โ€” calls deactivate() as soon as its - synchronous part is over, which is what keeps every request of an Http::pool() a sibling rather than a chain. + + + One traceparent, five boundaries, three places you can read it + packages/observability/src/Web/{TracingFilter.php, HttpClientTracingMiddleware.php} ยท packages/observability/src/Tracing/W3CTraceContextPropagator.php + packages/observability/src/{Cqrs/TracerCqrsTracing.php, Eda/TracerEdaTracing.php, Logging/TraceContextLogProcessor.php, Boot/HttpClientTracingPass.php} ยท packages/eda/src/Tracing/BrokerTracing.php + + + Inbound request header + traceparent: 00-{32 hex trace-id}-{16 hex span-id}-01 + absent โ†’ a new root trace starts here + + + + + TracingFilter โ€” #[Order(-110)], the outermost ordered filter in the chain + W3CTraceContextPropagator::extract($request->headers->all()) โ†’ the remote parent ยท startSpan($method, SpanKind::Server, โ€ฆ, $parent) + renamed to `GET /orders/{id}` once the router has matched ยท http.request.method, url.path, url.scheme, server.address, http.route, http.response.status_code, firefly.correlation_id ยท ERROR on 5xx + + + + + Laravel Context and Request::$attributes + firefly.trace_id ยท firefly.span_id โ€” the one place everything downstream reads the ids from + + + + + + + CQRS ยท in-process + CqrsTracing seam in firefly/cqrs (NoOpCqrsTracing by + default), filled by TracerCqrsTracing. + INTERNAL span per command and per query, + named by the message's short class name + firefly.cqrs.kind ยท firefly.cqrs.message + nothing to carry โ€” it nests under whatever is current + gated by firefly.observability.tracing.cqrs.enabled, which + is on by default under the master tracing gate. + + + EDA ยท across the transport + EdaTracing seam in firefly/eda, filled by TracerEdaTracing. Reached from the in-memory + and queue buses, from SubscriberRegistrySink (the sink every broker consumer feeds), from + RabbitMqEventPublisher, KafkaEventPublisher and PostgresEventPublisher (both its writers), + and from OutboxRelay, whose traceConsume() keeps the relay hop on the row's own trace. + PRODUCER `publish <destination>` writes traceparent/tracestate into the headers + CONSUMER `process <destination>` on delivery reads them back and continues that trace + gated by firefly.observability.tracing.eda.enabled; firefly.eda.tracing.brokers.enabled=false + downgrades the three broker publishers alone to the no-op, so none of ours is stamped. + + + Outbound HTTP + HttpClientTracingMiddleware, installed on the Http + factory by HttpClientTracingPass at boot. + CLIENT span ยท inject() writes traceparent on the + PSR-7 request โ€” url.path, never url.full + ERROR at โ‰ฅ 400 or on a rejection + works under Http::fake(): global middleware is outermost + gated by firefly.observability.tracing.http-client.enabled, + read by the boot pass rather than by a bean condition. + + + + + + + The next service's own TracingFilter extracts the same traceparent โ€” and the trace continues, across the process boundary, with no shared code + The propagator speaks W3C Trace Context over a plain header map, with no SDK involved: version `ff` and all-zero ids are rejected, a future version is read for its first four fields. + + Where the ids land + + + Every log line + TraceContextLogProcessor adds trace_id and span_id + to each record, beside the correlation and request ids. + firefly.logging.structured.format: json ยท ecs ยท logstash + attached per channel at resolution time, so a channel + built after boot with Log::build() gets neither. + + + /actuator/httpexchanges + HttpExchangeFilter (-100) runs INSIDE the tracing filter, so the span already exists + when it records the exchange, and it reads the traceId TracingFilter published off + Request::$attributes rather than off Context. + every row carries the trace it belonged to + a ring of the last 100 by default โ€” httpexchanges.capacity, clamped to [1, 10000] + + + The admin dashboard + firefly/admin renders the same exchanges page + server-side from the actuator registries โ€” no + second data path, no JavaScript fetch. + read its access model before exposing it: the + dashboard ships no authentication of its own. + + + What is actually recording + The shipped default is NoOpTracer: non-recording spans, an invalid SpanContext, currentSpan() of null โ€” every instrumentation site tests + $span->context()->isValid() before publishing ids. The OpenTelemetry adapter binds itself when the SDK is installed and firefly.observability.tracing.enabled is on. + + Both seams follow the CqrsMetrics shape: an interface in the owning package with a no-op default behind #[ConditionalOnMissingBean], and observability's + #[Order(500)] auto-configuration registering the real implementation first. Neither firefly/cqrs nor firefly/eda depends on firefly/observability. + A span whose end is deferred past the frame that started it โ€” the CLIENT span of an Http call ends in the promise's then() โ€” calls deactivate() as soon as its + synchronous part is over, which is what keeps every request of an Http::pool() a sibling rather than a chain. + + diff --git a/book/book.es.yaml b/book/book.es.yaml index 2dde5263..84f54c33 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -1,17 +1,23 @@ -title: "LaraFly by Example" -subtitle: "Microservicios PHP Hexagonales en Laravel 13 con el Firefly Framework" +title: "larafly by example" +subtitle: "Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel" author: "Firefly Software Solutions Inc." publisher: "Firefly Software Solutions Inc." language: "es" identifier: "urn:uuid:1c24667b-e0ad-42c6-9f28-8cbb241a2314" rights: "Copyright (c) 2026 Firefly Software Solutions Inc. Bajo licencia Apache-2.0." -cover_svg: "art/cover.svg" -cover_png: "art/cover.png" +cover_svg: "art/cover-es.svg" +cover_png: "art/cover-es.png" +cover_alt: "larafly by example. Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel." +back_cover_svg: "art/back-cover-es.svg" +back_cover_png: "art/back-cover-es.png" +back_cover_alt: "Contraportada de larafly by example: aplicaciones de negocio con modelos de dominio, servicios, eventos y polรญticas operativas." trim_width: "7.5in" trim_height: "9.25in" manuscript_dir: "src-es" output_basename: "larafly-by-example-es" labels: + cover: "Portada" + back_cover: "Contraportada" contents: "Contenido" # NOTA (Tarea B2): la portada + el Inicio rรกpido + la Parte I (Capรญtulos 1-2) # se incorporaron aquรญ. Tarea B3: Parte I completada (Capรญtulos 3-4) + Parte II diff --git a/book/book.yaml b/book/book.yaml index eb85445a..bddb8243 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -1,5 +1,5 @@ -title: "LaraFly by Example" -subtitle: "Hexagonal PHP Microservices on Laravel 13 with the Firefly Framework" +title: "larafly by example" +subtitle: "Business architecture and application policy on Laravel" author: "Firefly Software Solutions Inc." publisher: "Firefly Software Solutions Inc." language: "en" @@ -7,11 +7,17 @@ identifier: "urn:uuid:7707e7fc-286d-4773-87f8-5525f6703d27" rights: "Copyright (c) 2026 Firefly Software Solutions Inc. Licensed under Apache-2.0." cover_svg: "art/cover.svg" cover_png: "art/cover.png" +cover_alt: "larafly by example. Business architecture and application policy on Laravel." +back_cover_svg: "art/back-cover.svg" +back_cover_png: "art/back-cover.png" +back_cover_alt: "Back cover of larafly by example: build business applications with domain models, services, events and operational policy." trim_width: "7.5in" trim_height: "9.25in" manuscript_dir: "src" output_basename: "larafly-by-example" labels: + cover: "Front cover" + back_cover: "Back cover" contents: "Contents" # NOTE (Task B2): front matter + Quick Start + Part I (Chapters 1-2) landed here. # Task B3: Part I completed (Chapters 3-4) + Part II opened (Chapters 5-6). diff --git a/book/build/brand_diagrams.py b/book/build/brand_diagrams.py new file mode 100644 index 00000000..d47f5aa4 --- /dev/null +++ b/book/build/brand_diagrams.py @@ -0,0 +1,140 @@ +"""Apply the approved identity without moving diagram content. + +The manifest records the original non-paint structure. Canonical outlined logos +are embedded, never fetched. Reapplying the operation is byte-idempotent. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "book/art/diagram-branding.json" +SVG = "{http://www.w3.org/2000/svg}" +ET.register_namespace("", "http://www.w3.org/2000/svg") +ET.register_namespace("xlink", "http://www.w3.org/1999/xlink") +PAINT = {"fill", "stroke", "stop-color", "flood-color", "color"} + + +def structure(source: str) -> str: + """Fingerprint every original label, path and layout attribute, excluding paint.""" + root = ET.fromstring(source) + def visit(node, top=False): + if node.get("data-framework-brand") or node.get("data-brand-decoration") or node.tag == SVG + "image": + return None + attrs = {key: value for key, value in node.attrib.items() if key not in PAINT} + if top: + for key in ("height", "viewBox", "data-original-view-box"): + attrs.pop(key, None) + return [node.tag, sorted(attrs.items()), (node.text or "").strip(), + [result for child in node if (result := visit(child)) is not None]] + return hashlib.sha256(json.dumps(visit(root, True), ensure_ascii=False).encode()).hexdigest() + + +def embedded_asset(path: Path, prefix: str, **attrs) -> str: + root = ET.fromstring(path.read_text()) + for node in list(root): + if node.tag in (SVG + "title", SVG + "desc"): + root.remove(node) + ids = {node.get("id"): prefix + node.get("id") for node in root.iter() if node.get("id")} + for node in root.iter(): + for key, value in list(node.attrib.items()): + if key == "id": + node.set(key, ids[value]) + else: + for old, new in ids.items(): + value = value.replace("#" + old + ")", "#" + new + ")") + if value == "#" + old: + value = "#" + new + node.set(key, value) + root.attrib.update({key.replace("_", "-"): str(value) for key, value in attrs.items()}) + return ET.tostring(root, encoding="unicode") + + +def brand(source: str, relative: str, config: dict) -> str: + root = ET.fromstring(source) + # WeasyPrint 69 drops fills whose gradient stops collapse to one color. + # A solid paint is visually identical and keeps white header labels legible. + for gradient in root.iter(SVG + "linearGradient"): + stops = list(gradient) + colors = {stop.get("stop-color") for stop in stops} + if len(colors) == 1 and None not in colors and all(stop.get("stop-opacity", "1") == "1" for stop in stops): + color = colors.pop() + source = source.replace(f'="url(#{gradient.get("id")})"', f'="{color}"') + if root.find(f"{SVG}g[@data-framework-brand='2026-10-08']") is not None: + if "" not in source: + start = source.index("", 0, source.rfind("")) + len("") + source = (source[:start] + "" + source[start:end] + + "" + source[end:]) + return source + x, y, width, height = map(float, root.get("viewBox").split()) + if x or y: + raise ValueError("The diagram must use a zero-origin viewBox") + prefix = "brand-" + hashlib.sha256(relative.encode()).hexdigest()[:10] + "-" + palette = config["palette"] + source = re.sub(r"#[0-9a-fA-F]{6}\b", lambda match: palette.get(match[0].lower(), match[0]), source) + counter = 0 + def replace_icon(match): + nonlocal counter + counter += 1 + image = ET.fromstring(match[0]) + return embedded_asset(ROOT / config["mark"], prefix + str(counter) + "-", + data_brand_decoration="official-mark", aria_hidden="true", + **{key: image.get(key) for key in ("x", "y", "width", "height")}) + source = re.sub(r"]*?/>|]*?>.*?", replace_icon, source, flags=re.S) + footer = round(width * .078, 2) + logo_height = round(width * .068, 2) + logo = ET.parse(ROOT / config["logo"]).getroot() + logo_width = round(logo_height * float(logo.get("width")) / float(logo.get("height")), 2) + asset = embedded_asset(ROOT / config["logo"], prefix + "logo-", x=round(width-logo_width-12, 2), + y=round(height + (footer-logo_height)/2, 2), width=logo_width, height=logo_height, + aria_hidden="true") + panel = (f'') + match = re.search(r"]*>", source) + opening = match[0] + total_height = round(height + footer, 2) + opening = re.sub(r'viewBox="[^"]+"', f'viewBox="0 0 {width:g} {total_height:g}"', opening) + if 'height="' in opening: + opening = re.sub(r'height="[^"]+"', f'height="{total_height:g}"', opening) + opening = opening[:-1] + f' data-original-view-box="0 0 {width:g} {height:g}">' + source = source[:match.start()] + opening + source[match.end():] + return brand(source.rsplit("", 1)[0] + "\n" + panel + "\n\n", relative, config) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Validate without writing.") + args = parser.parse_args(argv) + config = json.loads(MANIFEST.read_text()) + for relative, record in config["diagrams"].items(): + path = ROOT / relative + original = path.read_text() + if structure(original) != record["structure"]: + raise ValueError(f"Diagram content/layout changed; review its manifest entry: {relative}") + result = brand(original, relative, config) + if structure(result) != record["structure"]: + raise ValueError(f"Branding moved or changed diagram content: {relative}") + if args.check and result != original: + raise ValueError(f"Diagram needs branding: {relative}") + if not args.check and result != original: + path.write_text(result) + for mirror in record.get("mirrors", []): + mirror_path = ROOT / mirror + if args.check and mirror_path.read_text() != result: + raise ValueError(f"Diagram mirror differs: {mirror}") + if not args.check and mirror_path.read_text() != result: + mirror_path.write_text(result) + print(f"Verified {len(config['diagrams'])} diagram layouts and canonical branding.") + + +if __name__ == "__main__": + main() diff --git a/book/build/build.py b/book/build/build.py index fe96b6ef..11bfe09c 100644 --- a/book/build/build.py +++ b/book/build/build.py @@ -1,4 +1,4 @@ -"""Build *LaraFly by Example* into EPUB + PDF from a book manifest. +"""Build *larafly by example* into EPUB + PDF from a book manifest. Defaults to ``book.yaml`` (English). Pass ``--config book.es.yaml`` to build the Spanish edition; each manifest names its own ``manuscript_dir``, ``language``, @@ -12,7 +12,7 @@ import re import sys from pathlib import Path -from xml.sax.saxutils import escape +from xml.sax.saxutils import escape, quoteattr import yaml # PyYAML ships with the framework env; ensure installed in book/.venv sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -128,8 +128,40 @@ def _divider_html(eyebrow: str, ptitle: str) -> str: f'

{escape(ptitle)}

') +def _cover_items(cfg: dict) -> dict[str, dict]: + """Resolve configured artwork before writing any edition output.""" + covers = {} + labels = cfg.get("labels", {}) + for cid, key, label in (("cover", "cover", "Front cover"), + ("back-cover", "back_cover", "Back cover")): + filename = cfg.get(f"{key}_png") + if not filename: + continue + path = BOOK / filename + if not path.is_file(): + raise FileNotFoundError(f"Configured cover artwork is missing: {path}") + title = labels.get(key, label) + covers[cid] = {"id": cid, "path": path, "filename": filename, "title": title, + "alt": cfg.get(f"{key}_alt", f"{title}: {cfg['title']}")} + return covers + + +def _add_epub_cover(epub: EpubBuilder, cover: dict) -> None: + cid = cover["id"] + href = f"art/{cid}.png" + epub.add_file(cover["path"], href, f"{cid}-img", + properties="cover-image" if cid == "cover" else "") + epub.add_doc(Doc(id=cid, title=cover["title"], kind="cover", + xhtml_body=f'{quoteattr(cover["alt"])}/')) + + +def _pdf_cover(cover: dict) -> str: + return (f'
' + f'{quoteattr(cover["alt"])}/
') + + def main(argv: list[str] | None = None) -> int: - ap = argparse.ArgumentParser(description="Build LaraFly by Example (EPUB + PDF).") + ap = argparse.ArgumentParser(description="Build larafly by example (EPUB + PDF).") ap.add_argument("--config", default="book.yaml", help="Manifest file under book/ (default: book.yaml).") ap.add_argument("--out", default=None, @@ -144,13 +176,13 @@ def main(argv: list[str] | None = None) -> int: css_text = [(THEME / "book.css").read_text(), (THEME / "tokens.css").read_text(), (THEME / "pygments.css").read_text()] items = _items_from_manifest(cfg, man, contents_label=contents_label) + covers = _cover_items(cfg) # ---- EPUB ---- epub = EpubBuilder(title=cfg["title"], author=cfg["author"], language=cfg["language"], identifier=cfg["identifier"], css=css_text) - cover_png = BOOK / cfg["cover_png"] - if cover_png.exists(): - epub.add_file(cover_png, "art/cover.png", "cover-img", properties="cover-image") + if "cover" in covers: + _add_epub_cover(epub, covers["cover"]) for it in items: if it["kind"] == "toc": body = _toc_html(items, href_fmt="{cid}.xhtml", label=contents_label) @@ -165,13 +197,15 @@ def main(argv: list[str] | None = None) -> int: epub.add_doc(Doc(id=it["id"], title=it["title"], xhtml_body=body, in_nav=it.get("in_nav", True), kind=it["kind"], part=it.get("part"), num=it.get("num"))) + if "back-cover" in covers: + _add_epub_cover(epub, covers["back-cover"]) DIST.mkdir(exist_ok=True) epub.build(DIST / f"{out_base}.epub") # ---- PDF (single concatenated document) ---- parts_html: list[str] = [] - if cover_png.exists(): - parts_html.append(f'
') + if "cover" in covers: + parts_html.append(_pdf_cover(covers["cover"])) for it in items: if it["kind"] == "toc": body = _toc_html(items, href_fmt="#{cid}", label=contents_label) @@ -182,7 +216,12 @@ def main(argv: list[str] | None = None) -> int: else: # front | chapter body = render_markdown(Path(it["path"]).read_text(encoding="utf-8"), BOOK) parts_html.append(f'
{body}
') - full = ("" + if "back-cover" in covers: + parts_html.append(_pdf_cover(covers["back-cover"])) + full = (f'' + f'{escape(cfg["title"])}' + f'' + f'' + "\n".join(parts_html) + "") render_pdf(full, base_url=BOOK, css_paths=[THEME / "tokens.css", THEME / "pygments.css", diff --git a/book/build/epub.py b/book/build/epub.py index 97934c0c..8971239c 100644 --- a/book/build/epub.py +++ b/book/build/epub.py @@ -15,7 +15,7 @@ class Doc: title: str xhtml_body: str in_nav: bool = True - kind: str = "chapter" # front | toc | divider | chapter + kind: str = "chapter" # front | toc | divider | chapter | cover part: str | None = None # part label (e.g. "Part I โ€” Foundations") for grouping num: int | str | None = None # chapter number, when kind == "chapter" @@ -51,7 +51,7 @@ def add_file(self, path: Path, href: str, aid: str, properties: str = "") -> Ass def _xhtml(self, d: Doc) -> str: links = "\n".join(f'' for i in range(len(self.css))) - sec_class = {"toc": "toc", "divider": "part-divider"}.get(d.kind, "chapter") + sec_class = {"toc": "toc", "divider": "part-divider", "cover": "cover-page"}.get(d.kind, "chapter") return (f'\n' f'\n' @@ -87,11 +87,12 @@ def _nav(self) -> str: if cur_part is not None: out.append("") items = "\n".join(out) + label = escape(next((d.title for d in self.docs if d.kind == "toc"), "Contents")) return ('\n' '' - 'Contents' - f'' + f'xmlns:epub="http://www.idpf.org/2007/ops" lang="{self.language}">' + f'{label}' + f'' '') def _opf(self) -> str: diff --git a/book/build/gen_cover.py b/book/build/gen_cover.py index 3ea69830..70b497c9 100644 --- a/book/build/gen_cover.py +++ b/book/build/gen_cover.py @@ -1,148 +1,54 @@ -"""Generate book/art/cover.svg and cover.png for *LaraFly by Example*. +"""Validate canonical cover artwork; --render regenerates PNGs from outlined SVGs. -Reuses the firefly/spark brand motif from docs/assets/larafly-banner.svg -(a glowing firefly abdomen + wings + spark trail โ€” drawn from SVG primitives, -no external font or logo asset required) rather than PyFly's hex-network cover, -so the book cover matches the framework's existing brand language. +The source of the artwork is documented in book/art/PROVENANCE.md. This command +never creates a design or substitutes system fonts. """ from __future__ import annotations -from pathlib import Path - -ART = Path(__file__).resolve().parents[1] / "art" -W, H = 1500, 2100 # 7.5 x 9.25 in at 200 dpi - -# --------------------------------------------------------------------------- -# Palette (matches docs/assets/larafly-banner.svg) -# --------------------------------------------------------------------------- -BG_TOP = "#0f172a" -BG_BOTTOM = "#1e293b" -INK = "#1f2937" -INK_DEEP = "#111827" -SPARK = "#fbbf24" -SPARK_LT = "#fde68a" -WING = "#e0f2fe" -SLATE = "#94a3b8" -SLATE_DIM = "#64748b" -CLOUD = "#cbd5e1" -LIGHT = "#f8fafc" -LIGHT_DIM = "#e2e8f0" - -FONT = "-apple-system,Segoe UI,Roboto,Helvetica Neue,Helvetica,Arial,sans-serif" - - -def _firefly_glyph(cx: float, cy: float, scale: float) -> str: - """The firefly/spark emblem, scaled + centered at (cx, cy).""" - s = scale - return f''' - - - - - - - - - - - - - - ''' - - -def build_svg() -> str: - parts: list[str] = [] - - # 1. Background gradient fill - parts.append(f'') - - # 2. Faint particle texture - parts.append(f'') - for row in range(14): - for col in range(6): - gx = 60 + col * 280 + (70 if row % 2 else 0) - gy = 90 + row * 155 - parts.append(f'') - parts.append(f'') - parts.append('') - # 3. Publisher label โ€” top edge - parts.append( - f'' - f'FIREFLY SOFTWARE SOLUTIONS INC.' - ) - - # 4. Firefly glyph โ€” large, centered in the illustration zone - parts.append(_firefly_glyph(W / 2, 620, 4.6)) - - # 5. Divider rules - RULE_Y = 1130 - parts.append(f'') - parts.append(f'') - - # 6. Title block: "LaraFly" (Fly in spark amber) + "by Example" - TY = RULE_Y + 150 - parts.append( - f'LaraFly' - ) - parts.append( - f'by Example' - ) - - # 7. Subtitle (two lines) - SUB_Y = TY + 230 - for i, line in enumerate([ - "Hexagonal PHP Microservices", - "on Laravel 13 with the Firefly Framework", - ]): - parts.append( - f'{line}' - ) - - # 8. Footer credit - parts.append( - f'Copyright (c) 2026 Firefly Software Solutions Inc.' - ) - - defs = ( - '' - '' - f'' - '' - '' - f'' - f'' - '' - '' - ) - - svg = ( - f'\n{defs}\n' + "\n".join(parts) + "\n" - ) - return svg - - -def _render_png(svg_path: Path, png_path: Path) -> None: - """Rasterize the cover with cairosvg (book/build/requirements.txt).""" - import cairosvg - cairosvg.svg2png(url=str(svg_path), write_to=str(png_path), output_width=W, output_height=H) +import argparse +from pathlib import Path +from xml.etree import ElementTree +from PIL import Image -def main() -> None: - ART.mkdir(parents=True, exist_ok=True) - svg = build_svg() - (ART / "cover.svg").write_text(svg, encoding="utf-8") - _render_png(ART / "cover.svg", ART / "cover.png") - print("wrote cover.svg and cover.png") +ART = Path(__file__).resolve().parents[1] / "art" +NAMES = ("cover", "cover-es", "back-cover", "back-cover-es") +WIDTH, HEIGHT = 1500, 1850 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--render", action="store_true", help="Rasterize the validated SVG sources.") + args = parser.parse_args(argv) + for name in NAMES: + path = ART / f"{name}.svg" + root = ElementTree.parse(path).getroot() + if (root.tag != "{http://www.w3.org/2000/svg}svg" + or tuple(float(n) for n in root.get("viewBox", "").split()) != (0, 0, WIDTH, HEIGHT) + or root.get("width") != str(WIDTH) or root.get("height") != str(HEIGHT)): + raise ValueError(f"Expected 1500 x 1850 canonical SVG: {path}") + if any(node.tag == "{http://www.w3.org/2000/svg}text" for node in root.iter()): + raise ValueError(f"Cover typography must be outlined, not font-dependent: {path}") + if args.render: + import cairosvg + for name in NAMES: + target = ART / f"{name}.png" + temporary = target.with_suffix(".png.tmp") + try: + cairosvg.svg2png(url=str(ART / f"{name}.svg"), write_to=str(temporary), + output_width=WIDTH, output_height=HEIGHT) + temporary.replace(target) + finally: + temporary.unlink(missing_ok=True) + for name in NAMES: + path = ART / f"{name}.png" + with Image.open(path) as image: + if image.format != "PNG" or image.size != (WIDTH, HEIGHT): + raise ValueError(f"Expected 1500 x 1850 canonical PNG: {path}") + image.verify() + print("Verified four localized front/back cover pairs.") + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/book/build/md.py b/book/build/md.py index 3a2708c5..1c3f3a6c 100644 --- a/book/build/md.py +++ b/book/build/md.py @@ -1,4 +1,4 @@ -"""Markdown -> HTML for *LaraFly by Example*. +"""Markdown -> HTML for *larafly by example*. Custom block directives on top of python-markdown: ::: figure | (single line; inlines the SVG) diff --git a/book/build/pdf.py b/book/build/pdf.py index 368478f9..12bd8ff1 100644 --- a/book/build/pdf.py +++ b/book/build/pdf.py @@ -1,10 +1,38 @@ """Render the assembled book HTML to a PDF via WeasyPrint.""" from __future__ import annotations + +import base64 +import re +import xml.etree.ElementTree as ET +from functools import lru_cache from pathlib import Path -from weasyprint import HTML, CSS + +from weasyprint import CSS, HTML + +_LOGO = re.compile(r"(.*?)", re.S) + + +@lru_cache(maxsize=64) +def _print_logo(source: str) -> str: + """Keep the approved gradient/clip artwork exact without rasterizing diagram text.""" + import cairosvg + + root = ET.fromstring(source) + box = {name: root.get(name) for name in ("x", "y", "width", "height")} + root.attrib.pop("x", None) + root.attrib.pop("y", None) + width = 800 + height = round(width * float(box["height"]) / float(box["width"])) + png = cairosvg.svg2png(bytestring=ET.tostring(root), output_width=width, output_height=height) + uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii") + attributes = " ".join(f'{name}="{value}"' for name, value in box.items()) + return f'' + def render_pdf(full_html: str, base_url: Path, css_paths: list[Path], out: Path) -> Path: - out = Path(out); out.parent.mkdir(parents=True, exist_ok=True) + out = Path(out) + out.parent.mkdir(parents=True, exist_ok=True) + full_html = _LOGO.sub(lambda match: _print_logo(match[1]), full_html) HTML(string=full_html, base_url=str(base_url)).write_pdf( - str(out), stylesheets=[CSS(filename=str(p)) for p in css_paths]) + str(out), stylesheets=[CSS(filename=str(path)) for path in css_paths]) return out diff --git a/book/src-es/00-front/00-title.md b/book/src-es/00-front/00-title.md index f572fdc2..5104d084 100644 --- a/book/src-es/00-front/00-title.md +++ b/book/src-es/00-front/00-title.md @@ -1,5 +1,5 @@ -# LaraFly by Example {.chtitle} +# larafly by example {.chtitle} -### Microservicios PHP Hexagonales en Laravel 13 con el Firefly Framework +### Arquitectura de negocio y polรญticas de aplicaciรณn sobre Laravel **Firefly Software Solutions Inc.** diff --git a/book/src/00-front/00-title.md b/book/src/00-front/00-title.md index 2aca7324..be3f9e21 100644 --- a/book/src/00-front/00-title.md +++ b/book/src/00-front/00-title.md @@ -1,5 +1,5 @@ -# LaraFly by Example {.chtitle} +# larafly by example {.chtitle} -### Hexagonal PHP Microservices on Laravel 13 with the Firefly Framework +### Business architecture and application policy on Laravel **Firefly Software Solutions Inc.** diff --git a/book/tests/test_covers.py b/book/tests/test_covers.py new file mode 100644 index 00000000..4e92ea95 --- /dev/null +++ b/book/tests/test_covers.py @@ -0,0 +1,166 @@ +"""Covers survive publication as accessible bookends, without print margins.""" + +import shutil +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +import pdfplumber +import pytest +import yaml +from PIL import Image + +from build import build as book_build +from build import gen_cover + +BOOK = Path(__file__).resolve().parents[1] +NS = {"opf": "http://www.idpf.org/2007/opf", "html": "http://www.w3.org/1999/xhtml"} + + +@pytest.fixture +def edition(tmp_path, monkeypatch): + source = tmp_path / "book" + source.mkdir() + (source / "art").mkdir() + (source / "manuscript").mkdir() + (source / "manuscript" / "chapter.md").write_text("# A short chapter\n\nThe business application.\n") + for name, color in (("cover-es.png", "#112233"), ("back-cover-es.png", "#445566")): + Image.new("RGB", (1500, 1850), color).save(source / "art" / name) + cfg = { + "title": "Libro & ejemplo", "subtitle": "Sistemas & negocio", "author": "Firefly", "language": "es", + "identifier": "urn:uuid:cover-test", "output_basename": "edition", + "manuscript_dir": "manuscript", "cover_png": "art/cover-es.png", + "cover_alt": 'Portada de "Libro & ejemplo"', "back_cover_png": "art/back-cover-es.png", + "back_cover_alt": "Contraportada: arquitectura & aplicaciones", + "labels": {"contents": "Contenido", "cover": "Portada", "back_cover": "Contraportada"}, + "parts": [{"title": "Parte I โ€” Aplicaciones", "chapters": [ + {"id": "ch01", "file": "chapter.md", "num": 1, "title": "Aplicaciones"} + ]}], + } + (source / "book.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True)) + out = tmp_path / "output" + monkeypatch.setattr(book_build, "BOOK", source) + monkeypatch.setattr(book_build, "THEME", BOOK / "theme") + monkeypatch.setattr(book_build, "DIST", out) + return source, out, cfg + + +def test_epub_has_localized_cover_bookends_and_one_cover_image(edition): + source, out, cfg = edition + assert book_build.main([]) == 0 + with zipfile.ZipFile(out / "edition.epub") as archive: + opf = ET.fromstring(archive.read("OEBPS/content.opf")) + spine = [item.get("idref") for item in opf.findall("opf:spine/opf:itemref", NS)] + assert spine[0] == "cover" + assert spine[-1] == "back-cover" + covers = opf.findall('opf:manifest/opf:item[@properties="cover-image"]', NS) + assert len(covers) == 1 + assert archive.read("OEBPS/" + covers[0].get("href")) == (source / cfg["cover_png"]).read_bytes() + for doc, alt in (("cover", cfg["cover_alt"]), ("back-cover", cfg["back_cover_alt"])): + xhtml = ET.fromstring(archive.read(f"OEBPS/{doc}.xhtml")) + img = xhtml.find(".//html:img", NS) + assert img is not None + assert img.get("alt") == alt + assert "OEBPS/" + img.get("src") in archive.namelist() + nav = ET.fromstring(archive.read("OEBPS/nav.xhtml")) + links = [(a.get("href"), a.text) for a in nav.findall(".//html:a", NS)] + assert links[0] == ("cover.xhtml", "Portada") + assert links[-1] == ("back-cover.xhtml", "Contraportada") + + +def test_pdf_covers_fill_first_and_last_page_without_headers(edition): + _, out, _ = edition + assert book_build.main([]) == 0 + with pdfplumber.open(out / "edition.pdf") as pdf: + assert len(pdf.pages) == 5 # front, contents, part divider, chapter, back + for page in (pdf.pages[0], pdf.pages[-1]): + assert (page.width, page.height) == pytest.approx((540, 666)) + assert not page.chars + assert len(page.images) == 1 + img = page.images[0] + assert (img["x0"], img["top"], img["x1"], img["bottom"]) == pytest.approx((0, 0, 540, 666)) + assert "business application" in (pdf.pages[-2].extract_text() or "") + + +@pytest.mark.parametrize("key", ["cover_png", "back_cover_png"]) +def test_missing_configured_cover_fails_before_publishing(edition, key): + source, out, cfg = edition + (source / cfg[key]).unlink() + with pytest.raises(FileNotFoundError, match=Path(cfg[key]).name): + book_build.main([]) + assert not out.exists() + + +@pytest.fixture +def canonical_art(tmp_path, monkeypatch): + art = tmp_path / "art" + art.mkdir() + for name in ("cover", "cover-es", "back-cover", "back-cover-es"): + (art / f"{name}.svg").write_text( + '' + '' + ) + Image.new("RGB", (1500, 1850), "#123456").save(art / f"{name}.png") + # The legacy PyFly generator loads its logo before overwriting the cover. + (art / "logo").mkdir() + shutil.copyfile(art / "cover.png", art / "logo" / "pyfly-logo.png") + monkeypatch.setattr(gen_cover, "ART", art) + monkeypatch.setattr(sys, "argv", ["gen_cover.py"]) + return art + + +def test_cover_command_preserves_canonical_art(canonical_art): + before = {p.name: p.read_bytes() for p in canonical_art.iterdir() if p.is_file()} + gen_cover.main() + assert {p.name: p.read_bytes() for p in canonical_art.iterdir() if p.is_file()} == before + + +@pytest.mark.parametrize("invalid", ["geometry", "text"]) +def test_cover_command_rejects_noncanonical_svg(canonical_art, invalid): + svg = canonical_art / "cover.svg" + original = svg.read_text() + bad = original.replace("1500 1850", "1500 2100") if invalid == "geometry" else original.replace("", "Unoutlined") + svg.write_text(bad) + with pytest.raises(ValueError): + gen_cover.main() + assert svg.read_text() == bad + + +def test_epub_navigation_uses_the_edition_contents_label(): + epub = book_build.EpubBuilder(title="Libro", author="Firefly", language="es", identifier="test") + epub.add_doc(book_build.Doc(id="toc", title="Contenido", xhtml_body="

Contenido

", kind="toc")) + nav = ET.fromstring(epub._nav()) + assert nav.find(".//html:h1", NS).text == "Contenido" + assert nav.get("lang") == "es" + + +def test_explicit_render_rebuilds_png_from_canonical_svg(canonical_art): + target = canonical_art / "cover-es.png" + Image.new("RGB", (1500, 1850), "#ffffff").save(target) + source = (canonical_art / "cover-es.svg").read_bytes() + gen_cover.main(["--render"]) + with Image.open(target) as image: + assert image.getpixel((750, 925))[:3] == (18, 52, 86) + assert (canonical_art / "cover-es.svg").read_bytes() == source + + +def test_pdf_metadata_uses_the_edition_title(edition): + _, out, cfg = edition + assert book_build.main([]) == 0 + with pdfplumber.open(out / "edition.pdf") as pdf: + assert pdf.metadata.get("Title") == cfg["title"] + assert pdf.metadata.get("Author") == cfg["author"] + assert pdf.metadata.get("Subject") == cfg["subtitle"] + + +def test_title_page_does_not_repeat_an_uppercased_book_name(edition): + source, out, cfg = edition + (source / "manuscript" / "title.md").write_text("# larafly by example {.chtitle}\n") + cfg["front"] = [{"id": "title", "file": "title.md", "nav": False}] + (source / "book.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True)) + assert book_build.main([]) == 0 + with pdfplumber.open(out / "edition.pdf") as pdf: + title_text = pdf.pages[1].extract_text() + assert title_text.count("larafly by example") == 1 + assert "LARAFLY BY EXAMPLE" not in title_text diff --git a/book/tests/test_diagram_branding.py b/book/tests/test_diagram_branding.py new file mode 100644 index 00000000..26e907f2 --- /dev/null +++ b/book/tests/test_diagram_branding.py @@ -0,0 +1,145 @@ +"""Preserve diagram semantics and geometry while applying the shared identity.""" +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SVG = "{http://www.w3.org/2000/svg}" +ET.register_namespace("", "http://www.w3.org/2000/svg") + + +def diagrams(): + if (ROOT / "assets/tools/build_brand_assets.py").exists(): + public = [ROOT / "assets" / (name + ".svg") for name in + ("architecture", "auto-configuration", "distributed-patterns", + "ecosystem", "hexagonal", "request-lifecycle")] + return sorted(public + list((ROOT / "book/art/figures").glob("*.svg")) + + list((ROOT / "book/art/openers").glob("*.svg")) + + list((ROOT / "book/art").glob("webapps-*.svg"))) + return sorted((ROOT / "docs/assets/diagrams").glob("*.svg")) + + +@pytest.mark.parametrize("path", diagrams(), ids=lambda p: str(p.relative_to(ROOT))) +def test_diagram_has_self_contained_brand_footer(path): + root = ET.fromstring(path.read_text()) + footer = root.find(f"{SVG}g[@data-framework-brand='2026-10-08']") + assert footer is not None, f"missing approved family identity: {path}" + assert footer.get("aria-hidden") == "true" + assert footer.find(f".//{SVG}path") is not None + assert not list(root.iter(f"{SVG}image")), "retired raster logo or external resource" + assert all(not node.get("href", "").startswith(("http", "file:")) for node in root.iter()) + assert not footer.findall(f".//{SVG}text"), "family typography must stay outlined" + + +def test_book_interior_uses_the_shared_identity(): + css = (ROOT / "book/theme/tokens.css").read_text() + assert "--ink:#10110f" in css + assert "--amber:#ffb34a" in css + assert "--page:#fffef9" not in css + body = (ROOT / "book/theme/book.css").read_text() + assert "#15351a" not in body + assert "#eef3e8" not in body + + +def test_branding_preserves_every_original_label_and_coordinate(): + import importlib.util + spec = importlib.util.spec_from_file_location("brand_diagrams", ROOT / "book/build/brand_diagrams.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + config = json.loads((ROOT / "book/art/diagram-branding.json").read_text()) + assert set(config["diagrams"]) == {str(path.relative_to(ROOT)) for path in diagrams()} + canonical = ET.parse(ROOT / config["logo"]).getroot() + paths = [node.get("d") for node in canonical.iter(SVG + "path")] + for relative, record in config["diagrams"].items(): + raw = (ROOT / relative).read_text() + assert module.structure(raw) == record["structure"], relative + assert module.brand(raw, relative, config) == raw, relative + root = ET.fromstring(raw) + old_width, old_height = map(float, record["originalViewBox"].split()[2:]) + width, height = map(float, root.get("viewBox").split()[2:]) + assert width == old_width + assert 0 < height - old_height <= width * .08 + .01 + footer = root.find(f"{SVG}g[@data-framework-brand='2026-10-08']") + actual = [node.get("d") for node in footer.iter(SVG + "path")] + assert all(path in actual for path in paths), relative + for mirror in record.get("mirrors", []): + assert (ROOT / mirror).read_text() == raw + + +def test_brand_footer_does_not_shrink_long_diagram_text_in_print(tmp_path): + import sys + + import pdfplumber + sys.path.insert(0, str(ROOT / "book/build")) + from pdf import render_pdf + path = max(diagrams(), key=lambda p: float(ET.parse(p).getroot().get("viewBox").split()[3])) + branded = path.read_text() + root = ET.fromstring(branded) + root.remove(root.find(f"{SVG}g[@data-framework-brand='2026-10-08']")) + original = root.get("data-original-view-box") + root.set("viewBox", original) + if root.get("height"): + root.set("height", original.split()[3]) + unbranded = ET.tostring(root, encoding="unicode") + measurements = [] + for name, source in [("branded", branded), ("without-footer", unbranded)]: + output = tmp_path / (name + ".pdf") + render_pdf('
' + source + '
', + ROOT / "book", [ROOT / "book/theme" / file for file in + ("tokens.css", "book.css", "print.css")], output) + with pdfplumber.open(output) as pdf: + assert len(pdf.pages) == 1, "long diagram no longer fits the print text area" + page = pdf.pages[0] + chars = [char for char in page.chars if char["text"].strip()] + assert all(0 <= char["x0"] < char["x1"] <= page.width for char in chars) + assert all(0 <= char["top"] < char["bottom"] <= page.height for char in chars) + measurements.append([(char["text"], round(char["size"], 2), round(char["x0"], 2)) + for char in chars]) + assert measurements[0] == measurements[1], "footer changed original text size or horizontal position" + + +def test_pdf_keeps_diagram_text_vector_and_rasterizes_only_the_canonical_logo(tmp_path): + import sys + + import pdfplumber + sys.path.insert(0, str(ROOT / "book/build")) + from pdf import render_pdf + path = diagrams()[0] + output = tmp_path / "diagram-logo.pdf" + render_pdf('
' + path.read_text() + '
', + ROOT / "book", [ROOT / "book/theme" / file for file in + ("tokens.css", "book.css", "print.css")], output) + with pdfplumber.open(output) as pdf: + assert len(pdf.pages) == 1 + page = pdf.pages[0] + assert len(page.chars) > 100, "diagram labels must remain selectable vector text" + assert len(page.images) == 1, "only the approved footer lockup needs raster fidelity" + logo = page.images[0] + assert logo["width"] < page.width * .2 + assert logo["height"] < page.height * .1 + + +def test_outbox_commit_bar_cannot_cover_precommit_labels(): + root = ET.parse(ROOT / "docs/assets/diagrams/outbox-flow.svg").getroot() + nodes = list(root) + labels = {"".join(node.itertext()).strip(): node for node in nodes if node.tag == SVG + "text"} + commit_label = labels["connection->commit()"] + commit = nodes[nodes.index(commit_label) - 1] + assert commit.tag == SVG + "rect" + branches = [node for node in nodes if node.tag == SVG + "rect" and node.get("y") == "318"] + assert len(branches) == 2 + notify = labels["+ SELECT pg_notify(channel, id) โ€” same tx, pgsql only"] + content_bottom = max(float(node.get("y")) + float(node.get("height")) for node in branches) + content_bottom = max(content_bottom, float(notify.get("y")) + float(notify.get("font-size")) * .3) + assert float(commit.get("y")) >= content_bottom + 16, "commit bar overlaps pre-commit content" + commit_bottom = float(commit.get("y")) + float(commit.get("height")) + atomic = labels["ATOMIC โ€” outbox row + aggregate's own writes commit together, or both roll back"] + assert float(atomic.get("y")) >= commit_bottom + 16 + frames = [node for node in nodes if node.tag == SVG + "rect" and node.get("width") == "1040"] + assert len(frames) == 2 + first_bottom = float(frames[0].get("y")) + float(frames[0].get("height")) + assert first_bottom >= float(atomic.get("y")) + 16 + assert float(frames[1].get("y")) >= first_bottom + 50 + assert float(frames[1].get("y")) + float(frames[1].get("height")) <= 1060 diff --git a/book/theme/book.css b/book/theme/book.css index b3ef18ad..15ad06f8 100644 --- a/book/theme/book.css +++ b/book/theme/book.css @@ -16,18 +16,18 @@ body{ h1,h2,h3,h4{ color:var(--ink); line-height:1.22; font-weight:800; } .eyebrow{ display:block; color:var(--green); font-weight:800; letter-spacing:.18em; font-size:.72rem; text-transform:uppercase; margin-bottom:.4rem; } -h1.chtitle{ font-size:2.3rem; line-height:1.08; margin:.1rem 0 1.15rem; color:#15351a; +h1.chtitle{ font-size:2.3rem; line-height:1.08; margin:.1rem 0 1.15rem; color:var(--ink); letter-spacing:-.015em; } -h2{ font-size:1.4rem; font-weight:800; color:#173a1b; margin:2rem 0 .7rem; - padding-bottom:.35rem; border-bottom:2px solid #e7efdc; } -h3{ font-size:1.08rem; font-weight:700; margin:1.35rem 0 .4rem; color:#21402a; } +h2{ font-size:1.4rem; font-weight:800; color:var(--ink); margin:2rem 0 .7rem; + padding-bottom:.35rem; border-bottom:2px solid var(--rule); } +h3{ font-size:1.08rem; font-weight:700; margin:1.35rem 0 .4rem; color:var(--ink-soft); } h2+p,h3+p{ margin-top:0; } /* ============ text ============ */ p{ margin:0 0 .9rem; } -strong,b{ color:#173a1b; font-weight:700; } +strong,b{ color:var(--ink); font-weight:700; } em{ color:inherit; } -a{ color:var(--green-deep); text-decoration:underline; text-decoration-color:#bcd9a8; +a{ color:var(--green-deep); text-decoration:underline; text-decoration-color:#bfb8ab; text-underline-offset:2px; } ul,ol{ margin:.3rem 0 1rem; padding-left:1.45rem; } li{ margin:.28rem 0; } @@ -35,14 +35,14 @@ li::marker{ color:var(--green); } hr{ border:none; border-top:1px solid var(--rule); margin:1.7rem 0; } /* inline code โ€” ligatures OFF so operators render literally (`->` never becomes `โ†’`) */ -code{ background:#eef3e8; color:#2c6e22; padding:.06em .36em; border-radius:4px; +code{ background:#f3f1eb; color:var(--ink-soft); padding:.06em .36em; border-radius:4px; font-family:"SF Mono","JetBrains Mono",Menlo,Consolas,monospace; font-size:.85em; word-break:break-word; font-variant-ligatures:none; font-feature-settings:"liga" 0,"clig" 0,"calt" 0,"dlig" 0; } /* ============ code listings ============ */ .listing{ margin:1.2rem 0; } -.filetab{ display:inline-block; background:#e7f0dc; color:#3f6b34; +.filetab{ display:inline-block; background:#f3f1eb; color:var(--ink-soft); font:600 .72rem/1 "SF Mono","JetBrains Mono",Menlo,monospace; padding:.44rem .72rem; border:1px solid var(--code-border); border-bottom:none; border-radius:8px 8px 0 0; } @@ -74,18 +74,18 @@ figure.fig{ margin:1.45rem auto; text-align:center; } figure.fig svg{ max-width:100%; height:auto; } figure.fig img.fig-img{ max-width:240px; height:auto; } figcaption{ font-size:.78rem; color:var(--muted); margin-top:.55rem; line-height:1.45; } -figcaption b,figcaption strong{ color:#3f6b34; } +figcaption b,figcaption strong{ color:var(--ink-soft); } /* ============ tables ============ */ table{ width:100%; border-collapse:collapse; margin:1.15rem 0; font-size:.9rem; line-height:1.5; } -th,td{ border:1px solid #e1e8d6; padding:.5rem .72rem; text-align:left; vertical-align:top; } -thead th{ background:#eef6e3; color:#1f4d22; font-weight:700; } -tbody tr:nth-child(even){ background:#fafdf6; } -td code,th code{ background:#eef3e8; } +th,td{ border:1px solid var(--rule); padding:.5rem .72rem; text-align:left; vertical-align:top; } +thead th{ background:#f3f1eb; color:var(--ink); font-weight:700; } +tbody tr:nth-child(even){ background:#faf9f6; } +td code,th code{ background:#f3f1eb; } /* ============ callouts ============ */ .admonition{ border-radius:10px; padding:.8rem 1rem; margin:1.2rem 0; font-size:.92rem; - line-height:1.6; border:1px solid var(--rule); border-left-width:4px; background:#f6f8f1; } + line-height:1.6; border:1px solid var(--rule); border-left-width:4px; background:#faf9f6; } .admonition>:last-child{ margin-bottom:0; } .admonition>:first-child{ margin-top:0; } .admonition-title{ font-weight:800; font-size:.7rem; letter-spacing:.08em; text-transform:uppercase; @@ -101,7 +101,7 @@ td code,th code{ background:#eef3e8; } .admonition.laravel>.admonition-title{ color:var(--laravel); } /* ============ recap / exercises (applied to the section H2) ============ */ -h2.recap,h2.exercises{ color:var(--green-deep); border-bottom-color:#cfe0bb; +h2.recap,h2.exercises{ color:var(--green-deep); border-bottom-color:var(--rule); display:flex; align-items:center; gap:.5rem; } h2.recap::before{ content:"\2713"; color:var(--green); font-size:1.05rem; } h2.exercises::before{ content:"\270E"; color:var(--green); font-size:1.05rem; } @@ -142,6 +142,10 @@ nav#toc li.toc-part > ol a{ font-weight:400; color:var(--ink); } font-size:1rem; text-transform:uppercase; margin-bottom:.8rem; } .part-title{ /* "Foundations" โ€” centered and large */ - font-size:2.9rem; line-height:1.1; font-weight:800; color:#15351a; + font-size:2.9rem; line-height:1.1; font-weight:800; color:var(--ink); letter-spacing:-.02em; margin:0; border:none; padding:0; } + +/* Cover documents scale with the EPUB reader; print.css supplies physical trim. */ +.cover-page{ margin:0; padding:0; text-align:center; } +.cover-page img{ display:block; width:100%; height:auto; margin:0; } diff --git a/book/theme/print.css b/book/theme/print.css index d70876c8..c99c9285 100644 --- a/book/theme/print.css +++ b/book/theme/print.css @@ -2,18 +2,26 @@ @page{ size: 7.5in 9.25in; margin: 0.82in 0.78in 0.86in; - @bottom-center{ content: counter(page); font:9pt -apple-system,"Segoe UI",sans-serif; color:#9aa6a0; } + @bottom-center{ content: counter(page); font:9pt -apple-system,"Segoe UI",sans-serif; color:#85877e; } @top-center{ content: string(runhead); font:8pt -apple-system,"Segoe UI",sans-serif; - letter-spacing:.12em; text-transform:uppercase; color:#a7b0a0; } + letter-spacing:.12em; text-transform:uppercase; color:#85877e; } } @page :first{ @top-center{ content:none; } @bottom-center{ content:none; } } html{ font-size: 11pt; } /* map rem-based sizes to print */ body{ background:#fff; } -/* the cover image is its own first page (no header/number) */ -.cover-page{ break-after:page; text-align:center; } -.cover-page img{ width:100%; height:auto; } +/* Front and back covers have their own full-trim page without running text. */ +@page book-cover{ + size:7.5in 9.25in; margin:0; + @top-center{ content:none; } + @bottom-center{ content:none; } +} +.cover-page{ + page:book-cover; width:7.5in; height:9.25in; margin:0; padding:0; + break-before:page; break-after:page; +} +.cover-page img{ display:block; width:7.5in; height:9.25in; object-fit:contain; } /* each front-matter doc and each chapter starts on a fresh page */ .chapter{ break-before:page; padding-bottom:0; } @@ -21,6 +29,7 @@ body{ background:#fff; } /* running head from the chapter title */ h1.chtitle{ string-set: runhead content(text); } +section#title h1.chtitle{ string-set:runhead ""; } /* keep blocks intact across page boundaries */ table,figure.fig,.admonition{ break-inside:avoid; } @@ -47,7 +56,7 @@ section.toc h1.chtitle{ string-set:runhead "Contents"; } .toc-link{ text-decoration:none; color:var(--ink); } .toc-link::after{ content: leader('.') ' ' target-counter(attr(href url), page); - color:#6b7768; + color:#62645b; } /* ============ Part-divider page (print) ============ */ diff --git a/book/theme/tokens.css b/book/theme/tokens.css index 82f7b7e4..e8849f92 100644 --- a/book/theme/tokens.css +++ b/book/theme/tokens.css @@ -1,8 +1,9 @@ +/* Shared Firefly palette; legacy token names remain to keep the theme interface stable. */ :root{ - --green:#334155; --green-bright:#475569; --lime:#64748b; --lime-soft:#94a3b8; - --green-deep:#1e293b; --green-ink:#0f172a; --amber:#fbbf24; --amber-soft:#fde68a; - --ink:#1c2420; --ink-soft:#33402e; --muted:#7a8472; --page:#fffef9; - --code-bg:#f7faf3; --code-border:#dde6d3; --rule:#e7ece1; + --green:#8a5714; --green-bright:#ffb34a; --lime:#bfb8ab; --lime-soft:#dedbd2; + --green-deep:#8a5714; --green-ink:#10110f; --amber:#ffb34a; --amber-soft:#ffd69a; + --ink:#10110f; --ink-soft:#46483f; --muted:#62645b; --page:#ffffff; + --code-bg:#faf9f6; --code-border:#dedbd2; --rule:#dedbd2; --note:#1f6fd6; --note-bg:#eef6ff; --tip:#2f8f3f; --warn:#c2410c; --warn-bg:#fff4ec; --laravel:#ff2d20; --laravel-bg:#fff1ef; } diff --git a/docs/README.md b/docs/README.md index aea53661..48a43e2d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -160,7 +160,7 @@ thirty-fourth guide turns this page red rather than quietly making it wrong. --- -*The guided, book-style [*LaraFly by Example*](../book/README.md) โ€” bilingual (English + Spanish), rendered +*The guided, book-style [*larafly by example*](../book/README.md) โ€” bilingual (English + Spanish), rendered to PDF + EPUB, its chapter list held in `book/book.yaml` and `book/book.es.yaml` โ€” is available now, alongside the step-by-step [Tutorial](tutorial.md).* diff --git a/docs/assets/README.md b/docs/assets/README.md index 3d7119f6..15dd98e3 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -1,57 +1,20 @@ -## Banner +## Official family identity -`larafly-banner.svg` is the project banner embedded at the top of `README.md` and `docs/index.md`. It is a -brand-clean, **placeholder-quality** design โ€” a "LaraFly" wordmark, tagline, and a tasteful firefly/spark -motif built from SVG primitives (circles/ellipses/gradients), not the official Firefly logo. The official -mark (`Group 91.svg`) was not available on this machine at authoring time. +`larafly-banner.svg` is the official family banner used by the repository README +and documentation landing page. `larafly-logo.svg` is the dark-surface wordmark +configured as the MkDocs header logo. Both come from the shared 2026-10-08 Firefly +Framework brand kit, `Framework-Brand-Kit/12-Frameworks/php/`. -The banner carries an explicit swap point: the `` element (marked with an SVG comment -`` right above it) is where the official `Group 91.svg` mark should be dropped in -once available, in place of the hand-drawn spark glyph. The wordmark, tagline, and background can stay as-is -or be adjusted once the real brand asset is in hand. Like the diagrams below, the banner is self-contained -(no external fonts/images/scripts, generic `font-family`) and theme-neutral (its own background, not reliant -on the surrounding page). +`larafly-logo-light.svg` and `larafly-logo-dark.svg` retain the canonical variants +for other surfaces. Their typography is outlined and all artwork is self-contained: +no fonts, scripts, linked images or external resources are needed to render it. +The favicon uses the shared kitโ€™s `02-Icons/favicon.svg`. The 1280ร—640 social +preview embeds the official dark-surface family lockup; its technical captions +and feature chips remain unchanged. Its PNG is rendered from that checked-in SVG. -## Logo and favicon - -`larafly-logo.svg` (48ร—48, `theme.logo`) and `larafly-favicon.svg` (32ร—32, `theme.favicon`) are the banner's -firefly glyph โ€” ``, `larafly-banner.svg:35-59` โ€” **redrawn at their own scale, not cropped out -of it**. A crop would have dragged the banner's `viewBox`, its three gradients and its wordmark along with it; -each of these two carries only the gradients it actually uses โ€” one spark for the logo, the spark plus the -tile for the favicon โ€” under its own id prefix (`lfl-`, `lff-`) so that two inlined SVGs on one page cannot -collide. The glyph's geometry is the banner's, scaled: the spark disc, the two wings, the dark body, the -amber tail segment and the three-dot spark trail, in the same proportions. - -Where the two differ, they differ because the surface they are drawn on differs: - -- **The logo has no background.** Material draws it inside the header, on `--md-primary-fg-color` โ€” the - banner's own slate โ€” so a ground of its own would show as a tile floating on the header. Its dark body still - reads because it sits on the opaque centre of the spark disc rather than on the header itself. -- **The favicon carries the slate tile** (`#0f172a โ†’ #1e293b`, the banner's background gradient, `rx="7"`), - because a browser tab has no background of its own: the same transparent glyph would sit on whatever colour - the browser happens to use and lose the dark body entirely on a dark tab strip. - -The logo's wings keep the banner's own `opacity="0.55"` and sit at a slightly wider spread than the banner's; -that spread is what keeps them reading as *two wings* rather than one pale cap at 24px, the size Material -actually renders the header logo at. The favicon's are a shade stronger (`0.6`) because they are competing -with the slate tile behind them rather than with a page. - -Both are self-contained on the same terms as the banner and the diagrams: no `