includio-cms 0.37.1 → 0.37.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +54 -4
- package/CHANGELOG.md +19 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +18 -0
- package/dist/admin/remote/booking.remote.js +1 -1
- package/dist/booking/payment-amount.d.ts +23 -0
- package/dist/booking/payment-amount.js +37 -0
- package/dist/booking/server/http/portal-handler.js +2 -1
- package/dist/booking/server/payments.d.ts +14 -4
- package/dist/booking/server/payments.js +11 -5
- package/dist/booking/server/portal.d.ts +5 -2
- package/dist/booking/server/portal.js +10 -4
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/dist/core/fields/jsonLd/builders.d.ts +74 -0
- package/dist/core/fields/jsonLd/builders.js +88 -0
- package/dist/core/fields/jsonLd/graph.d.ts +15 -0
- package/dist/core/fields/jsonLd/graph.js +43 -0
- package/dist/core/fields/jsonLd/index.d.ts +2 -0
- package/dist/core/fields/jsonLd/index.js +2 -0
- package/dist/core/fields/resolveSeo.d.ts +1 -1
- package/dist/core/fields/resolveSeo.js +1 -1
- package/dist/core/fields/slugifyFilename.d.ts +7 -0
- package/dist/core/fields/slugifyFilename.js +14 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +3 -0
- package/dist/core/server/fields/utils/fixOrphans.d.ts +15 -3
- package/dist/core/server/fields/utils/fixOrphans.js +30 -7
- package/dist/files-local/index.js +5 -5
- package/dist/sveltekit/components/json-ld.svelte +12 -0
- package/dist/sveltekit/components/json-ld.svelte.d.ts +6 -0
- package/dist/sveltekit/components/seo.svelte +62 -11
- package/dist/sveltekit/components/seo.svelte.d.ts +10 -0
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +2 -0
- package/dist/sveltekit/server/sitemap.d.ts +19 -0
- package/dist/sveltekit/server/sitemap.js +43 -0
- package/dist/updates/0.37.2/index.d.ts +2 -0
- package/dist/updates/0.37.2/index.js +20 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.37.
|
|
1
|
+
# includio-cms — Public API v0.37.2
|
|
2
2
|
|
|
3
3
|
> Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
Entry points: **23** · Stable: **
|
|
5
|
+
Entry points: **23** · Stable: **715** · Experimental: **4**
|
|
6
6
|
|
|
7
7
|
Tags:
|
|
8
8
|
- `@public` — frozen for v1.0; semver-protected.
|
|
@@ -13,17 +13,59 @@ Tags:
|
|
|
13
13
|
|
|
14
14
|
### `includio-cms`
|
|
15
15
|
|
|
16
|
+
- `article(input: ArticleInput): JsonLdNode`
|
|
17
|
+
- `interface ArticleInput`
|
|
18
|
+
- `blogPosting(input: ArticleInput): JsonLdNode`
|
|
19
|
+
- `breadcrumbList(items: Array<{ name: string; url: string }>): JsonLdNode`
|
|
16
20
|
- `interface EmailAdapter` — Contract for email adapters. Used by `better-auth` reset-password flow and form submission
|
|
21
|
+
- `faqPage(items: Array<{ question: string; answer: string }>): JsonLdNode`
|
|
22
|
+
- `interface GeoInput`
|
|
23
|
+
- `imageNode(img: string | MediaFile): string | JsonLdNode` — Normalize an image to a URL string or an ImageObject with dimensions.
|
|
24
|
+
- `jsonLdGraph(nodes: JsonLdNode | JsonLdNode[]): { '@context': 'https://schema.org'; '@graph': JsonLdNode[]; }` — Wrap node(s) into a schema.org
|
|
25
|
+
- `type JsonLdNode = Record<string, unknown> & { '@type': string | string[] }`
|
|
26
|
+
- `localBusiness(input: LocalBusinessInput): JsonLdNode`
|
|
27
|
+
- `interface LocalBusinessInput`
|
|
28
|
+
- `organization(input: OrganizationInput): JsonLdNode`
|
|
29
|
+
- `interface OrganizationInput`
|
|
30
|
+
- `interface PostalAddressInput`
|
|
31
|
+
- `pruneEmpty(value: T): T` — Recursively drop undefined/null/'' and empty arrays/objects.
|
|
17
32
|
- `type ResolvedSeo = { [K in keyof SeoFieldData]: K extends 'ogImage' ? string | MediaFile | undefined : string | unde...` — Normalized SEO data — localized values flattened by `language`. A stable
|
|
18
33
|
- `resolveSeo(entry: { seo?: Record<string, unknown> } | Record<string,..., language?: string): ResolvedSeo` — Resolve `entry.seo` into flat fields, flattening localized objects by
|
|
19
34
|
- `interface SendMailOptions`
|
|
35
|
+
- `slugifyFilename(base: string): string` — Slugify a media file base name (extension handled by the caller).
|
|
36
|
+
- `thing(type: string | string[], props: Record<string, unknown> = {}): JsonLdNode` — Build a schema.org node of `type` from `props`, pruning empty values.
|
|
37
|
+
- `webPage(input: WebPageInput): JsonLdNode`
|
|
38
|
+
- `interface WebPageInput`
|
|
39
|
+
- `website(input: WebSiteInput): JsonLdNode`
|
|
40
|
+
- `interface WebSiteInput`
|
|
20
41
|
|
|
21
42
|
### `includio-cms/core`
|
|
22
43
|
|
|
44
|
+
- `article(input: ArticleInput): JsonLdNode`
|
|
45
|
+
- `interface ArticleInput`
|
|
46
|
+
- `blogPosting(input: ArticleInput): JsonLdNode`
|
|
47
|
+
- `breadcrumbList(items: Array<{ name: string; url: string }>): JsonLdNode`
|
|
23
48
|
- `interface EmailAdapter` — Contract for email adapters. Used by `better-auth` reset-password flow and form submission
|
|
49
|
+
- `faqPage(items: Array<{ question: string; answer: string }>): JsonLdNode`
|
|
50
|
+
- `interface GeoInput`
|
|
51
|
+
- `imageNode(img: string | MediaFile): string | JsonLdNode` — Normalize an image to a URL string or an ImageObject with dimensions.
|
|
52
|
+
- `jsonLdGraph(nodes: JsonLdNode | JsonLdNode[]): { '@context': 'https://schema.org'; '@graph': JsonLdNode[]; }` — Wrap node(s) into a schema.org
|
|
53
|
+
- `type JsonLdNode = Record<string, unknown> & { '@type': string | string[] }`
|
|
54
|
+
- `localBusiness(input: LocalBusinessInput): JsonLdNode`
|
|
55
|
+
- `interface LocalBusinessInput`
|
|
56
|
+
- `organization(input: OrganizationInput): JsonLdNode`
|
|
57
|
+
- `interface OrganizationInput`
|
|
58
|
+
- `interface PostalAddressInput`
|
|
59
|
+
- `pruneEmpty(value: T): T` — Recursively drop undefined/null/'' and empty arrays/objects.
|
|
24
60
|
- `type ResolvedSeo = { [K in keyof SeoFieldData]: K extends 'ogImage' ? string | MediaFile | undefined : string | unde...` — Normalized SEO data — localized values flattened by `language`. A stable
|
|
25
61
|
- `resolveSeo(entry: { seo?: Record<string, unknown> } | Record<string,..., language?: string): ResolvedSeo` — Resolve `entry.seo` into flat fields, flattening localized objects by
|
|
26
62
|
- `interface SendMailOptions`
|
|
63
|
+
- `slugifyFilename(base: string): string` — Slugify a media file base name (extension handled by the caller).
|
|
64
|
+
- `thing(type: string | string[], props: Record<string, unknown> = {}): JsonLdNode` — Build a schema.org node of `type` from `props`, pruning empty values.
|
|
65
|
+
- `webPage(input: WebPageInput): JsonLdNode`
|
|
66
|
+
- `interface WebPageInput`
|
|
67
|
+
- `website(input: WebSiteInput): JsonLdNode`
|
|
68
|
+
- `interface WebSiteInput`
|
|
27
69
|
|
|
28
70
|
### `includio-cms/core/server`
|
|
29
71
|
|
|
@@ -332,8 +374,10 @@ Tags:
|
|
|
332
374
|
- `const Image: LegacyComponentType`
|
|
333
375
|
- `isImageFieldData(media: MediaFieldData): media is ImageFieldData`
|
|
334
376
|
- `isVideoFieldData(media: MediaFieldData): media is VideoFieldData`
|
|
377
|
+
- `const JsonLd: LegacyComponentType`
|
|
335
378
|
- `const Media: LegacyComponentType`
|
|
336
379
|
- `const Preview: LegacyComponentType`
|
|
380
|
+
- `const Seo: LegacyComponentType`
|
|
337
381
|
- `setCustomFields(defs: Map<string, CustomFieldDefinition>): <inferred>`
|
|
338
382
|
- `setPreferMp4(value: boolean): <inferred>`
|
|
339
383
|
- `setRemotes(data: typeof remotes): <inferred>`
|
|
@@ -343,6 +387,8 @@ Tags:
|
|
|
343
387
|
|
|
344
388
|
### `includio-cms/sveltekit/server`
|
|
345
389
|
|
|
390
|
+
- `buildRobots(options: RobotsOptions = {}): string` — Build a robots.txt document. Defaults to allow-all + optional sitemap link.
|
|
391
|
+
- `buildSitemap(entries: SitemapEntry[]): string` — Build a sitemap.xml document from a list of URL entries.
|
|
346
392
|
- `cmsLayoutLoad(event: RequestEvent): <inferred>` — Returns `cmsContext` from `event.locals` for use in a SvelteKit layout
|
|
347
393
|
- `countEntries(opts: CountEntriesOptions): Promise<number>` — Count entries matching the same filters as `resolveEntries`, without
|
|
348
394
|
- `type CountEntriesOptions = Omit< ResolveEntriesOptions, 'limit' | 'offset' | 'populate' | 'orderBy' | 'dataOrderBy' >`
|
|
@@ -365,7 +411,10 @@ Tags:
|
|
|
365
411
|
- `resolveEntry(opts: ResolveEntryOptions): Promise<Entry | null>` — Fetch a single populated Entry.
|
|
366
412
|
- `interface ResolveEntryOptions`
|
|
367
413
|
- `type ResolveStatus = 'published' | 'draft' | 'scheduled'` — Status filter for `resolveEntry`/`resolveEntries`/`countEntries`.
|
|
414
|
+
- `interface RobotsOptions`
|
|
415
|
+
- `interface RobotsRule`
|
|
368
416
|
- `sendMail(options: SendMailOptions): Promise<void>` — Send an email through the configured CMS email adapter. Thin convenience
|
|
417
|
+
- `interface SitemapEntry`
|
|
369
418
|
|
|
370
419
|
### `includio-cms/db-postgres`
|
|
371
420
|
|
|
@@ -691,18 +740,19 @@ Tags:
|
|
|
691
740
|
- `createBooking(input: CreateBookingInput, opts: { now?: Date; actor?: PriceActor } = {}): Promise<{ booking: typeof bookingsTable.$inferSelect; accessToken: string }>` — Create a booking (public self-serve or staff). Validates catalog capacity +
|
|
692
741
|
- `createBookingHandler(): { POST: RequestHandler }` — Public self-serve booking creation. Mount at `POST /api/booking`.
|
|
693
742
|
- `createDepositPayment(bookingId: string): Promise<{ redirectUrl: string | null; status: 'redirect' | 'manual' | 'error'...` — Create the deposit payment for a freshly-created booking: ask the first
|
|
694
|
-
- `createInstallmentPayment(bookingId: string,
|
|
743
|
+
- `createInstallmentPayment(bookingId: string, opts: InstallmentPaymentOptions): Promise<{ paymentLink: string | null }>` — Generate an installment/balance payment link. Records a pending ledger row keyed
|
|
695
744
|
- `createMaintenanceHandler(): { POST: RequestHandler }` — Secured external trigger for booking maintenance — the swap-in for the
|
|
696
745
|
- `createPortalHandler(): { GET: RequestHandler; POST: RequestHandler }` — Self-service portal. Mount at `GET|POST /api/booking/portal/[token]`.
|
|
697
746
|
- `createWebhookHandler(): { POST: RequestHandler }` — Payment-provider webhook. Mount at `POST /api/booking/webhook/[provider]`.
|
|
698
747
|
- `editBooking(bookingId: string, changes: BookingEditChanges, opts: { actor: 'staff' | 'portal'; now?: Date }): Promise<typeof bookingsTable.$inferSelect>` — Edit a booking (staff or portal). Recomputes totals per-participant + derived
|
|
699
748
|
- `getBookingDb(): <inferred>` — Returns the Drizzle handle, asserting the postgres adapter is in use.
|
|
700
749
|
- `getBookingMaintenanceStatus(): { running: boolean; lastRun: Date | null }`
|
|
750
|
+
- `interface InstallmentPaymentOptions`
|
|
701
751
|
- `listBookingAdjustments(bookingId: string): Promise<BookingAdjustment[]>` — List all adjustments (active + voided) for a booking, newest first.
|
|
702
752
|
- `pickEmailTemplate(kind: BookingEmailKind): string` — Template file name for a kind (1:1 — kept as a function for future indirection).
|
|
703
753
|
- `portalAddTrips(token: string, additions: Array<{ tripRef: string; assignments: string[] }>): <inferred>` — Self-service dobieranie wycieczki: doczytaj obecny stan, scal z `additions`
|
|
704
754
|
- `portalEdit(token: string, changes: BookingEditChanges): <inferred>` — Portal self-service edit (actor:'portal' guards). Throws → handler maps to 409/400.
|
|
705
|
-
- `portalPay(token: string
|
|
755
|
+
- `portalPay(token: string): <inferred>` — Portal: create a balance payment link. The amount is NOT taken from the request —
|
|
706
756
|
- `portalUpdateParticipant(token: string, assignmentId: string, data: Record<string, unknown>): <inferred>` — Portal self-service: customer fills their own participant data. Verifies the
|
|
707
757
|
- `portalView(token: string): <inferred>` — Portal view: booking + items + participants + ledger (no staff data).
|
|
708
758
|
- `priceFromExistingRows(tx: BookingTx, bookingId: string, config: ResolvedBookingConfig): Promise<ParticipantPriceResult>` — Price a booking from its CURRENTLY persisted people/items/assignments (no
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.37.2 — 2026-07-16
|
|
7
|
+
|
|
8
|
+
Powierzchnia SEO/AEO rdzenia — reużywalne helpery, które raz naprawione w rdzeniu działają u każdego klienta (wyszły z wdrożenia SEO u pod-zwrotnikami). W tym samym patchu domyka się też łatka bezpieczeństwa bookingu (kwota płatności).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Buildery JSON-LD (schema.org) w `includio-cms/core`.** Typowane, czyste funkcje: `organization`, `localBusiness`, `website`, `webPage`, `breadcrumbList`, `article`, `blogPosting`, `faqPage` + generyk `thing` i `jsonLdGraph`. Opt-in — nic nie emitują domyślnie. `undefined`/puste wartości wypadają z outputu, więc częściowe dane dają poprawny minimalny JSON-LD. Bespoke typy (np. `TouristTrip + Offer`, `Product`) składasz po stronie projektu przez `thing("TouristTrip", { ... })` — rdzeń zostaje generyczny.
|
|
12
|
+
- **Komponent `<JsonLd>` w `includio-cms/sveltekit`.** Renderuje `<script type="application/ld+json">` w `<svelte:head>` (SSR), escapuje `<` → breakout ze `<script>` jest niemożliwy. Przyjmuje obiekt albo tablicę (np. wynik `jsonLdGraph`).
|
|
13
|
+
- **`buildSitemap` / `buildRobots` w `includio-cms/sveltekit/server`.** Czyste buildery: `buildSitemap(entries)` → string `sitemap.xml` (URL-e XML-escaped, `lastmod` `Date`→ISO lub string, opcjonalne `changefreq`/`priority`), `buildRobots(options)` → `robots.txt` (domyślnie allow-all + link do sitemapy, konfigurowalne reguły). Projekt posiada route i wpina helper: `export const GET = async () => new Response(buildSitemap(entries), { headers: { "content-type": "application/xml" } });`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Slugifikacja nazw plików mediów.** Upload i zmiana nazwy przepuszczają basename przez `slugifyFilename` (nowy helper w `includio-cms/core`): `"IMG-1234 (1).jpeg"` → `img-1234-1.jpeg`, polskie znaki transliterowane, spacje/wielkość/nawiasy usunięte. Tylko nowe pliki — istniejące URL-e w bazie nietknięte (bez migracji). Cap 200 znaków, path-traversal niemożliwy (slug nie zawiera `/` ani `.`).
|
|
17
|
+
- **`<Seo>` dopełniony i wyeksportowany z `includio-cms/sveltekit`.** Wcześniej komponent istniał, ale nie był eksportowany (martwy). Bugfix: `og:url` i `og:type` używają `property=` zamiast `name=` (wymóg Open Graph). Nowe propsy: `ogType` (domyślnie `website`; `article` dokłada `article:published_time`/`modified_time`/`author`/`section`/`tag`), `noindex` (→ `<meta name="robots" content="noindex">`), `siteName` (→ `og:site_name`). `ogImage` typu `MediaFile` emituje `og:image:width`/`height`/`alt`.
|
|
18
|
+
- **Typografia — łamanie sierot przyimkowych.** `fixOrphans` wstawia NBSP nie tylko po jednoliterowych spójnikach (`i/w/z/o/u/a/e`), ale też po krótkich przyimkach (`do na od po we za ze ku nad pod bez dla przy`). Lista konfigurowalna parametrem `words` + eksport `DEFAULT_ORPHAN_WORDS`. Domyślnie konserwatywnie (bez spójników typu `oraz/lub`), żeby nie NBSP-ować za dużo.
|
|
19
|
+
- **Bezpieczeństwo (booking): kwota dopłaty przestaje pochodzić z żądania HTTP.** Domknięcie łuku 0.37.1 (cena szła z katalogu, ale kwota wpłaty — nie): `portalPay` podawał `amount` z ciała żądania prosto do `createInstallmentPayment`, bez konfrontacji z saldem — przeglądarka dyktowała kwotę (nadpłata do ręcznego zwrotu albo link na grosz). Bramka kwotowa oparta na aktorze (bliźniak bramki cenowej): `portal` NIE dyktuje (serwer obciąża `totals.balance`, fail-closed gdy brak salda), `staff` może. Portalowa wpłata zapisuje się teraz z `createdBy` aktora — nie zahardkodowanym `staff` (koniec kłamstwa w audycie).
|
|
20
|
+
- **Booking: `ledger.balance` przestaje gubić rabaty.** `portalView` liczył `ledger` z `totals.gross` → `balance = gross − paid`, pomijając `discount`/`surcharge`/`manualDiscount`; portal pokazywał dwa różne salda i przycisk płatności brał gorzej policzone. Teraz `toPortalView` karmi `ledgerSummary` wartością netto (`netOf(totals)`), więc `ledger.balance === totals.balance`.
|
|
21
|
+
|
|
22
|
+
### Breaking
|
|
23
|
+
- **`createInstallmentPayment(bookingId, amount)` → `createInstallmentPayment(bookingId, { actor, amount? })`.** `actor: "portal" | "staff"` jest wymagany. Dla `portal` kwota jest ignorowana (serwer obciąża saldo); dla `staff` podajesz `amount`. `portalPay(token, amount)` → `portalPay(token)` (kwota nie jest już parametrem). Dotyczy tylko konsumentów wołających te funkcje bezpośrednio — ścieżka przez handler portalu/remote admina zaktualizowana w rdzeniu.
|
|
24
|
+
|
|
6
25
|
## 0.37.1 — 2026-07-14
|
|
7
26
|
|
|
8
27
|
Dwie łatki cenowe w bookingu. (1) Cena rezerwacji przestaje pochodzić z żądania HTTP — do 0.37.0 publiczne endpointy przyjmowały `unitPrice` prosto z ciała żądania i nikt nie konfrontował go z katalogiem, więc wyprawę dało się zarezerwować za grosz. (2) Data wyceny zostaje zamrożona na rezerwacji — dotąd każde jej dotknięcie (edycja, ale też zaksięgowanie korekty) przeliczało reguły zależne od czasu wg DZISIEJSZEJ daty, przez co early-bird sprzed miesięcy cicho znikał i klient robił się winien pieniądze.
|
package/DOCS.md
CHANGED
package/ROADMAP.md
CHANGED
|
@@ -40,6 +40,23 @@
|
|
|
40
40
|
- [x] `[fix]` `[breaking]` `[P0]` **Data wyceny zamrożona na rezerwacji** (`bookings.priced_at`). Dotąd cena bazowa była zamrożona, ale data — nie: `editBooking` i `priceFromExistingRows` (ścieżka **korekt**) przeliczały reguły zależne od czasu wg `new Date()`. Obsługa księgowała dopłatę ustaloną telefonicznie tydzień przed wylotem → `daysUntil(anchor, now)` spadało poniżej progu → **early-bird sprzed miesięcy cicho znikał, a klient robił się winien pieniądze**. Bomba tykała sama, bez żadnej zmiany cennika. Rezerwacje sprzed 0.37.1: `priced_at = NULL` → fallback na `created_at` (zero migracji danych). Pełny snapshot **wartości** polityki (`pricing_snapshot`) zostaje w 0.38.0 §4.4 — wymaga świadomej zmiany cennika przez klientkę, więc da się ją uprzedzić.
|
|
41
41
|
- [x] `[fix]` `[breaking]` `[P0]` **Cena rezerwacji przestaje pochodzić z żądania HTTP.** Do 0.37.0 trzy publiczne drogi wpuszczały `unitPrice` z ciała żądania i nikt nie konfrontował go z katalogiem (`create-handler.ts:37`, portalowe `add-trips`, portalowe `edit` → `editBooking`) — wyprawę dało się zarezerwować za grosz. Bramka stoi teraz w rdzeniu (`createBooking`/`editBooking`), oparta na aktorze: `public`/`portal` nie dyktują ceny (nowa pozycja → szew `resolvePrice` z katalogu, istniejąca → jej `unitPriceSnapshot`), `staff` nadal może korygować ręcznie. Domyślny aktor to `public`, więc przyszły publiczny endpoint jest bezpieczny bez pamiętania o niczym. Fail-closed: brak `resolvePrice` = odmowa wyceny.
|
|
42
42
|
|
|
43
|
+
## 0.37.2 — SEO/AEO surface + kwota płatności
|
|
44
|
+
|
|
45
|
+
> Domknięcie tego samego łuku co 0.37.1: **cena** przestała pochodzić z żądania HTTP, ale **kwota płatności** wciąż pochodzi.
|
|
46
|
+
> Zgłoszone z pod-zwrotnikami (2026-07-14) — klient z rabatem pakietowym płacił w portalu za dużo. Konsument załatał się u siebie
|
|
47
|
+
> (`totals.balance` zamiast `ledger.balance`), ale przyczyna siedzi w rdzeniu i każdy kolejny konsument wdepnie w to samo.
|
|
48
|
+
|
|
49
|
+
- [x] `[fix]` `[P0]` **Kwota dopłaty przestaje pochodzić z żądania HTTP.** `portalPay(token, amount)` podaje `amount` prosto do `createInstallmentPayment(bookingId, amount)` (`payments.ts:149`), a ta wkłada go do `totalGross` adaptera i insertuje `booking_payments` — **bez żadnej konfrontacji z `totals.balance`**. Przeglądarka dyktuje, ile zapłaci: może za dużo (nadpłata do zwrotu ręcznego), może grosz (link płatności na 0,01 zł). Bramka z 0.37.1 stanęła na `unitPrice`, ale nie na kwocie wpłaty. Fix: kwotę wyznacza/weryfikuje serwer względem `totals.balance` (aktor `portal` nie dyktuje kwoty, `staff` może — symetrycznie do bramki cenowej), fail-closed przy rozjeździe. Przy okazji: portalowa wpłata zapisuje się z `createdBy: 'staff'` (`payments.ts:187`), co kłamie w audycie. <!-- files: src/lib/booking/server/portal.ts, src/lib/booking/server/payments.ts -->
|
|
50
|
+
- [x] `[fix]` `[P0]` **`ledger.balance` gubi rabaty — i kusi nazwą.** `portalView` woła `ledgerSummary(booking.totals.gross, payments)` (`server/portal.ts:48`), a `ledgerSummary` liczy `balance = gross - paid` (`ledger.ts:87`), z pominięciem `discount` / `surcharge` / `manualDiscount`. Autorytatywne jest `totals.balance` (`netOf(base) - paid`, `totals.ts:73`) i portalView zwraca je obok — więc widok podaje konsumentowi **dwa różne salda, a to gorzej policzone nosi bardziej oczywistą nazwę**. Efekt u konsumenta: portal pokazywał „Pozostało 532 zł" i „Do dopłaty 610 zł" na jednym ekranie, a przycisk płatności brał tę drugą. Fix: `ledgerSummary` liczy saldo z netto (albo `portalView` przestaje zwracać `ledger.balance` i zostawia sam `overpaid`). Test regresyjny: rezerwacja z rabatem → `ledger.balance === totals.balance`. <!-- files: src/lib/booking/ledger.ts, src/lib/booking/server/portal.ts, src/lib/booking/totals.ts -->
|
|
51
|
+
|
|
52
|
+
> **SEO/AEO surface** (bundlowane w tym samym patchu, 2026-07-16). Reużywalne w rdzeniu — raz naprawione, działa u każdego klienta. Spec: `docs/superpowers/specs/2026-07-15-seo-aeo-core-surface-design.md`, plan: `docs/superpowers/plans/2026-07-16-seo-aeo-core-surface.md`.
|
|
53
|
+
|
|
54
|
+
- [x] `[feature]` `[P1]` **Buildery JSON-LD + `<JsonLd>`** — typowane, czyste buildery schema.org w `includio-cms/core` (`organization`/`localBusiness`/`website`/`webPage`/`breadcrumbList`/`article`/`blogPosting`/`faqPage` + generyk `thing`/`jsonLdGraph`) i komponent `<JsonLd>` (SSR w `<svelte:head>`, escape `<`). Opt-in. <!-- files: src/lib/core/fields/jsonLd/, src/lib/sveltekit/components/json-ld.svelte -->
|
|
55
|
+
- [x] `[fix]` `[P1]` **Slugifikacja nazw plików mediów** — `slugifyFilename` w core; `files-local` slugifikuje basename przy uploadzie/rename (tylko nowe pliki, non-breaking). <!-- files: src/lib/core/fields/slugifyFilename.ts, src/lib/files-local/index.ts -->
|
|
56
|
+
- [x] `[fix]` `[P1]` **`<Seo>` dopełniony + wyeksportowany** — export z `sveltekit`; bugfix `og:url`/`og:type` `name→property`; `ogType`/`noindex`/`siteName`/`article` props; og:image width/height/alt z `MediaFile`. <!-- files: src/lib/sveltekit/components/seo.svelte, src/lib/sveltekit/index.ts -->
|
|
57
|
+
- [x] `[fix]` `[P2]` **fixOrphans: przyimki wieloliterowe** — domyślny zestaw rozszerzony o 2–3-literowe przyimki, konfigurowalny `words` param + `DEFAULT_ORPHAN_WORDS`. <!-- files: src/lib/core/server/fields/utils/fixOrphans.ts -->
|
|
58
|
+
- [x] `[feature]` `[P1]` **`buildSitemap`/`buildRobots`** — czyste buildery w `sveltekit/server`; projekt posiada route. <!-- files: src/lib/sveltekit/server/sitemap.ts -->
|
|
59
|
+
|
|
43
60
|
## 0.38.0 — Moduły: kontrakt vs polityka
|
|
44
61
|
|
|
45
62
|
> **WSTRZYMANE (2026-07-14).** Łuk jest dojrzały, ale klientka nie zobaczy z niego nic, a produktyzacja
|
|
@@ -86,6 +103,7 @@ Spec: `docs/superpowers/specs/2026-07-13-moduly-kontrakt-vs-polityka-design.md`.
|
|
|
86
103
|
|
|
87
104
|
## Backlog
|
|
88
105
|
|
|
106
|
+
- [ ] `[feature]` `[P2]` **Panel SEO w sidebarze admina (Yoast-like)** — osobny brainstorm/spec. Guided, niestechniczny panel (odwrotność przytłaczającego Yoasta, on-brand): globalna konfiguracja SEO, per-wpisowy box (snippet preview, noindex toggle, focus keyword → wtedy świadome rozszerzenie pola `seo`), pobranie sitemapy (nakładka na `buildSitemap`). **Przekierowania (redirects)** prawdopodobnie własny spec (tabela DB + hook 301/302 + CRUD). Szwy pod to = SEO surface 0.37.2.
|
|
89
107
|
- [ ] `[fix]` `[P1]` Wyciek puli połączeń DB w dev (HMR) — pojedynczy proces `vite dev` konsumenta trzymał **100 połączeń** do Postgresa (cały `max_connections`), po czym baza odbijała wszystko: `FATAL: sorry, too many clients already` (zaobserwowane 2026-07-13 na pod-zwrotnikami). Hipoteza: każde przeładowanie modułu przez HMR tworzy nowy klient `postgres.js` (pula domyślnie 10), a stary nie jest zamykany — 10 przeładowań wyczerpuje limit. Sprawdzić zamykanie klienta w `import.meta.hot.dispose` / singleton na `globalThis` w adapterze. Dotyka każdego, kto dłużej pracuje w dev. <!-- files: src/lib/db-postgres/index.ts -->
|
|
90
108
|
- [ ] `[chore]` `[P1]` CMS doctor — `validateLayout()` jest napisany i przetestowany, ale nie jest wołany z żadnego miejsca produkcyjnego: literówka w nazwie pola w tabie przechodzi po cichu (pole ląduje w orphanach). Podpiąć go do `defineConfig` obok `validatePreviews`, dołożyć `pnpm cms check` w CLI i zestaw reguł zdrowia configu (martwe pola, `pathTemplate` bez `previewUrl`/`previews`, itp.). <!-- files: src/lib/core/fields/layoutUtils.ts, src/lib/sveltekit/config.ts, src/lib/core/config/, src/lib/cli/index.ts -->
|
|
91
109
|
- [ ] `[feature]` `[P2]` Date/datetime field — przebudowa na shadcn-svelte (bits-ui Calendar/DatePicker) zamiast natywnego inputu. Zgłoszone w QA Etap 5a (4/5); funkcjonalnie OK, odłożone jako osobny redesign pola daty. <!-- files: src/lib/admin/components/fields/date-field.svelte, datetime-field.svelte -->
|
|
@@ -209,7 +209,7 @@ export const editBookingStaff = command(z.object({ bookingId: z.string().uuid(),
|
|
|
209
209
|
/** Staff: generate an installment/balance payment link for any amount. @public */
|
|
210
210
|
export const addPaymentLink = command(z.object({ bookingId: z.string().uuid(), amount: z.number().int().positive() }), async ({ bookingId, amount }) => {
|
|
211
211
|
requireAuth();
|
|
212
|
-
const result = await createInstallmentPayment(bookingId, amount);
|
|
212
|
+
const result = await createInstallmentPayment(bookingId, { actor: 'staff', amount });
|
|
213
213
|
if (result.paymentLink) {
|
|
214
214
|
await sendBookingEmail(bookingId, 'payment-link', {
|
|
215
215
|
amount,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure amount gate for installment/balance payment links — the payment-side twin
|
|
3
|
+
* of the 0.37.1 price gate. Keeps the *amount* from being dictated by an untrusted
|
|
4
|
+
* HTTP request. All amounts are in **minor units** (grosze).
|
|
5
|
+
*/
|
|
6
|
+
export type PaymentActor = 'portal' | 'staff';
|
|
7
|
+
/** Thrown when a requested payment amount is not allowed for the actor. */
|
|
8
|
+
export declare class PaymentAmountError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the amount to charge on an installment/balance link.
|
|
13
|
+
*
|
|
14
|
+
* - `portal` (untrusted): the server dictates the amount = current outstanding
|
|
15
|
+
* `balance`; any `requestedAmount` from the browser is ignored. Fail-closed
|
|
16
|
+
* when nothing is due (`balance <= 0`).
|
|
17
|
+
* - `staff` (trusted): honors `requestedAmount` (positive integer minor units) —
|
|
18
|
+
* staff may bill a custom amount, e.g. a partial installment or a prepayment.
|
|
19
|
+
*
|
|
20
|
+
* @throws {PaymentAmountError} portal with nothing due, or staff with a
|
|
21
|
+
* non-positive / fractional / missing amount.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveInstallmentAmount(balance: number, actor: PaymentActor, requestedAmount?: number): number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure amount gate for installment/balance payment links — the payment-side twin
|
|
3
|
+
* of the 0.37.1 price gate. Keeps the *amount* from being dictated by an untrusted
|
|
4
|
+
* HTTP request. All amounts are in **minor units** (grosze).
|
|
5
|
+
*/
|
|
6
|
+
/** Thrown when a requested payment amount is not allowed for the actor. */
|
|
7
|
+
export class PaymentAmountError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'PaymentAmountError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the amount to charge on an installment/balance link.
|
|
15
|
+
*
|
|
16
|
+
* - `portal` (untrusted): the server dictates the amount = current outstanding
|
|
17
|
+
* `balance`; any `requestedAmount` from the browser is ignored. Fail-closed
|
|
18
|
+
* when nothing is due (`balance <= 0`).
|
|
19
|
+
* - `staff` (trusted): honors `requestedAmount` (positive integer minor units) —
|
|
20
|
+
* staff may bill a custom amount, e.g. a partial installment or a prepayment.
|
|
21
|
+
*
|
|
22
|
+
* @throws {PaymentAmountError} portal with nothing due, or staff with a
|
|
23
|
+
* non-positive / fractional / missing amount.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveInstallmentAmount(balance, actor, requestedAmount) {
|
|
26
|
+
if (actor === 'staff') {
|
|
27
|
+
if (!Number.isInteger(requestedAmount) || requestedAmount <= 0) {
|
|
28
|
+
throw new PaymentAmountError('Kwota wpłaty musi być dodatnią liczbą całkowitą (grosze).');
|
|
29
|
+
}
|
|
30
|
+
return requestedAmount;
|
|
31
|
+
}
|
|
32
|
+
// portal: browser does not dictate the amount — the server charges the balance.
|
|
33
|
+
if (balance <= 0) {
|
|
34
|
+
throw new PaymentAmountError('Brak salda do zapłaty.');
|
|
35
|
+
}
|
|
36
|
+
return balance;
|
|
37
|
+
}
|
|
@@ -65,7 +65,8 @@ export function createPortalHandler() {
|
|
|
65
65
|
}
|
|
66
66
|
try {
|
|
67
67
|
if (action === 'pay') {
|
|
68
|
-
|
|
68
|
+
// Kwota NIE pochodzi z żądania — serwer obciąża saldo (bramka kwotowa w rdzeniu).
|
|
69
|
+
const out = await portalPay(token);
|
|
69
70
|
return out ? json(out) : json({ error: 'Not found' }, { status: 404 });
|
|
70
71
|
}
|
|
71
72
|
if (action === 'edit') {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PaymentEvent } from '../../shop/types.js';
|
|
2
2
|
import type { PaymentKind } from '../ledger.js';
|
|
3
|
+
import { type PaymentActor } from '../payment-amount.js';
|
|
3
4
|
/**
|
|
4
5
|
* Create the deposit payment for a freshly-created booking: ask the first
|
|
5
6
|
* configured adapter for a payment (charging only the deposit amount via an
|
|
@@ -22,12 +23,21 @@ export declare function applyWebhookPayment(event: PaymentEvent): Promise<{
|
|
|
22
23
|
bookingId: string | null;
|
|
23
24
|
kind: PaymentKind | null;
|
|
24
25
|
}>;
|
|
26
|
+
export interface InstallmentPaymentOptions {
|
|
27
|
+
/** Trust level of the caller. `portal` cannot dictate the amount; `staff` can. */
|
|
28
|
+
actor: PaymentActor;
|
|
29
|
+
/** Required for `staff` (positive minor units). Ignored for `portal` (server charges the balance). */
|
|
30
|
+
amount?: number;
|
|
31
|
+
}
|
|
25
32
|
/**
|
|
26
|
-
* Generate an installment/balance payment link
|
|
27
|
-
*
|
|
28
|
-
*
|
|
33
|
+
* Generate an installment/balance payment link. Records a pending ledger row keyed
|
|
34
|
+
* by the provider ref; the webhook settles it later.
|
|
35
|
+
*
|
|
36
|
+
* The charged amount is gated by {@link resolveInstallmentAmount}: for `actor:'portal'`
|
|
37
|
+
* the browser cannot dictate it — the server charges the outstanding `totals.balance`
|
|
38
|
+
* (fail-closed when nothing is due); `actor:'staff'` may bill a custom `amount`.
|
|
29
39
|
*/
|
|
30
|
-
export declare function createInstallmentPayment(bookingId: string,
|
|
40
|
+
export declare function createInstallmentPayment(bookingId: string, opts: InstallmentPaymentOptions): Promise<{
|
|
31
41
|
paymentLink: string | null;
|
|
32
42
|
}>;
|
|
33
43
|
/** Record a manual cash payment (staff). Settles immediately and recomputes status. */
|
|
@@ -6,6 +6,7 @@ import { applyPaymentEvent } from '../webhook-logic.js';
|
|
|
6
6
|
import { deriveStatus } from '../state-machine.js';
|
|
7
7
|
import { netOf } from '../totals.js';
|
|
8
8
|
import { buildPaymentContext } from '../payment-context.js';
|
|
9
|
+
import { resolveInstallmentAmount } from '../payment-amount.js';
|
|
9
10
|
/**
|
|
10
11
|
* Create the deposit payment for a freshly-created booking: ask the first
|
|
11
12
|
* configured adapter for a payment (charging only the deposit amount via an
|
|
@@ -127,11 +128,14 @@ export async function applyWebhookPayment(event) {
|
|
|
127
128
|
});
|
|
128
129
|
}
|
|
129
130
|
/**
|
|
130
|
-
* Generate an installment/balance payment link
|
|
131
|
-
*
|
|
132
|
-
*
|
|
131
|
+
* Generate an installment/balance payment link. Records a pending ledger row keyed
|
|
132
|
+
* by the provider ref; the webhook settles it later.
|
|
133
|
+
*
|
|
134
|
+
* The charged amount is gated by {@link resolveInstallmentAmount}: for `actor:'portal'`
|
|
135
|
+
* the browser cannot dictate it — the server charges the outstanding `totals.balance`
|
|
136
|
+
* (fail-closed when nothing is due); `actor:'staff'` may bill a custom `amount`.
|
|
133
137
|
*/
|
|
134
|
-
export async function createInstallmentPayment(bookingId,
|
|
138
|
+
export async function createInstallmentPayment(bookingId, opts) {
|
|
135
139
|
const config = requireBookingConfig();
|
|
136
140
|
const db = getBookingDb();
|
|
137
141
|
const [booking] = await db.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
|
|
@@ -140,6 +144,8 @@ export async function createInstallmentPayment(bookingId, amount) {
|
|
|
140
144
|
const adapter = config.payment[0];
|
|
141
145
|
if (!adapter)
|
|
142
146
|
throw new Error('No payment adapter configured');
|
|
147
|
+
// Amount gate (payment-side twin of the 0.37.1 price gate).
|
|
148
|
+
const amount = resolveInstallmentAmount(booking.totals.balance, opts.actor, opts.amount);
|
|
143
149
|
const ctx = {
|
|
144
150
|
...buildPaymentContext({
|
|
145
151
|
accessToken: booking.accessToken,
|
|
@@ -165,7 +171,7 @@ export async function createInstallmentPayment(bookingId, amount) {
|
|
|
165
171
|
status: 'pending',
|
|
166
172
|
providerRef: result.providerRef ?? null,
|
|
167
173
|
paymentLink: result.redirectUrl ?? null,
|
|
168
|
-
createdBy:
|
|
174
|
+
createdBy: opts.actor
|
|
169
175
|
});
|
|
170
176
|
return { paymentLink: result.redirectUrl ?? null };
|
|
171
177
|
}
|
|
@@ -123,8 +123,11 @@ export declare function portalEdit(token: string, changes: BookingEditChanges):
|
|
|
123
123
|
}[];
|
|
124
124
|
ledger: import("../ledger.js").LedgerSummary;
|
|
125
125
|
} | null>;
|
|
126
|
-
/**
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Portal: create a balance payment link. The amount is NOT taken from the request —
|
|
128
|
+
* the server charges the outstanding `totals.balance` (see {@link createInstallmentPayment}).
|
|
129
|
+
*/
|
|
130
|
+
export declare function portalPay(token: string): Promise<{
|
|
128
131
|
paymentLink: string | null;
|
|
129
132
|
} | null>;
|
|
130
133
|
/**
|
|
@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
|
2
2
|
import { getBookingDb } from './db.js';
|
|
3
3
|
import { bookingsTable, bookingItemsTable, bookingPersonsTable, bookingItemPersonsTable, bookingPaymentsTable } from '../../db-postgres/schema/booking/index.js';
|
|
4
4
|
import { ledgerSummary } from '../ledger.js';
|
|
5
|
+
import { netOf } from '../totals.js';
|
|
5
6
|
import { editBooking } from './bookings.js';
|
|
6
7
|
import { createInstallmentPayment } from './payments.js';
|
|
7
8
|
import { resolveItemSchema, saveParticipantData } from './participants.js';
|
|
@@ -29,7 +30,9 @@ export function toPortalView(booking, items, participants, payments, schemaByIte
|
|
|
29
30
|
kind: p.kind,
|
|
30
31
|
paidAt: p.paidAt ?? null
|
|
31
32
|
})),
|
|
32
|
-
|
|
33
|
+
// Ledger balance must fold in discounts/surcharges — feed NET (like `totals.balance`),
|
|
34
|
+
// not the pre-discount `gross`, so `ledger.balance === totals.balance`.
|
|
35
|
+
ledger: ledgerSummary(netOf(booking.totals), payments)
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
/** Resolve a booking by its unguessable portal access token (active only). */
|
|
@@ -138,12 +141,15 @@ export async function portalEdit(token, changes) {
|
|
|
138
141
|
await editBooking(booking.id, changes, { actor: 'portal' });
|
|
139
142
|
return portalView(token);
|
|
140
143
|
}
|
|
141
|
-
/**
|
|
142
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Portal: create a balance payment link. The amount is NOT taken from the request —
|
|
146
|
+
* the server charges the outstanding `totals.balance` (see {@link createInstallmentPayment}).
|
|
147
|
+
*/
|
|
148
|
+
export async function portalPay(token) {
|
|
143
149
|
const booking = await resolveByToken(token);
|
|
144
150
|
if (!booking)
|
|
145
151
|
return null;
|
|
146
|
-
return createInstallmentPayment(booking.id,
|
|
152
|
+
return createInstallmentPayment(booking.id, { actor: 'portal' });
|
|
147
153
|
}
|
|
148
154
|
/**
|
|
149
155
|
* Portal self-service: customer fills their own participant data. Verifies the
|
|
@@ -2,7 +2,7 @@ declare const InputGroupInput: import("svelte").Component<(Omit<import("svelte/e
|
|
|
2
2
|
type: "file";
|
|
3
3
|
files?: FileList;
|
|
4
4
|
} | {
|
|
5
|
-
type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "
|
|
5
|
+
type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "email" | "hidden" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
|
|
6
6
|
files?: undefined;
|
|
7
7
|
})) & {
|
|
8
8
|
ref?: HTMLElement | null | undefined;
|
|
@@ -2,7 +2,7 @@ declare const SidebarInput: import("svelte").Component<(Omit<import("svelte/elem
|
|
|
2
2
|
type: "file";
|
|
3
3
|
files?: FileList;
|
|
4
4
|
} | {
|
|
5
|
-
type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "
|
|
5
|
+
type?: "number" | "image" | "url" | "text" | "date" | "search" | "radio" | "email" | "hidden" | "password" | (string & {}) | "reset" | "color" | "button" | "checkbox" | "tel" | "time" | "month" | "submit" | "datetime-local" | "range" | "week";
|
|
6
6
|
files?: undefined;
|
|
7
7
|
})) & {
|
|
8
8
|
ref?: HTMLElement | null | undefined;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MediaFile } from '../../../types/media.js';
|
|
2
|
+
import { type JsonLdNode } from './graph.js';
|
|
3
|
+
export interface OrganizationInput {
|
|
4
|
+
name: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
logo?: string | MediaFile;
|
|
7
|
+
sameAs?: string[];
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function organization(input: OrganizationInput): JsonLdNode;
|
|
11
|
+
export interface PostalAddressInput {
|
|
12
|
+
streetAddress?: string;
|
|
13
|
+
addressLocality?: string;
|
|
14
|
+
postalCode?: string;
|
|
15
|
+
addressRegion?: string;
|
|
16
|
+
addressCountry?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface GeoInput {
|
|
19
|
+
latitude?: number;
|
|
20
|
+
longitude?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface LocalBusinessInput extends OrganizationInput {
|
|
23
|
+
telephone?: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
image?: string | MediaFile;
|
|
26
|
+
address?: PostalAddressInput;
|
|
27
|
+
geo?: GeoInput;
|
|
28
|
+
openingHours?: string | string[];
|
|
29
|
+
priceRange?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function localBusiness(input: LocalBusinessInput): JsonLdNode;
|
|
32
|
+
export interface WebSiteInput {
|
|
33
|
+
name: string;
|
|
34
|
+
url: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
inLanguage?: string;
|
|
37
|
+
/** e.g. https://site/search?q={search_term_string} */
|
|
38
|
+
searchUrlTemplate?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function website(input: WebSiteInput): JsonLdNode;
|
|
41
|
+
export interface WebPageInput {
|
|
42
|
+
name: string;
|
|
43
|
+
url: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
inLanguage?: string;
|
|
46
|
+
isPartOf?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function webPage(input: WebPageInput): JsonLdNode;
|
|
49
|
+
export declare function breadcrumbList(items: Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
url: string;
|
|
52
|
+
}>): JsonLdNode;
|
|
53
|
+
export interface ArticleInput {
|
|
54
|
+
headline: string;
|
|
55
|
+
url?: string;
|
|
56
|
+
image?: string | MediaFile;
|
|
57
|
+
description?: string;
|
|
58
|
+
datePublished?: string;
|
|
59
|
+
dateModified?: string;
|
|
60
|
+
author?: string | {
|
|
61
|
+
name: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
};
|
|
64
|
+
publisher?: OrganizationInput;
|
|
65
|
+
inLanguage?: string;
|
|
66
|
+
section?: string;
|
|
67
|
+
keywords?: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function article(input: ArticleInput): JsonLdNode;
|
|
70
|
+
export declare function blogPosting(input: ArticleInput): JsonLdNode;
|
|
71
|
+
export declare function faqPage(items: Array<{
|
|
72
|
+
question: string;
|
|
73
|
+
answer: string;
|
|
74
|
+
}>): JsonLdNode;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { thing, imageNode } from './graph.js';
|
|
2
|
+
export function organization(input) {
|
|
3
|
+
return thing('Organization', {
|
|
4
|
+
name: input.name,
|
|
5
|
+
url: input.url,
|
|
6
|
+
logo: input.logo ? imageNode(input.logo) : undefined,
|
|
7
|
+
sameAs: input.sameAs,
|
|
8
|
+
description: input.description
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function localBusiness(input) {
|
|
12
|
+
return thing('LocalBusiness', {
|
|
13
|
+
name: input.name,
|
|
14
|
+
url: input.url,
|
|
15
|
+
logo: input.logo ? imageNode(input.logo) : undefined,
|
|
16
|
+
image: input.image ? imageNode(input.image) : undefined,
|
|
17
|
+
description: input.description,
|
|
18
|
+
sameAs: input.sameAs,
|
|
19
|
+
telephone: input.telephone,
|
|
20
|
+
email: input.email,
|
|
21
|
+
address: input.address ? thing('PostalAddress', { ...input.address }) : undefined,
|
|
22
|
+
geo: input.geo ? thing('GeoCoordinates', { ...input.geo }) : undefined,
|
|
23
|
+
openingHours: input.openingHours,
|
|
24
|
+
priceRange: input.priceRange
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function website(input) {
|
|
28
|
+
return thing('WebSite', {
|
|
29
|
+
name: input.name,
|
|
30
|
+
url: input.url,
|
|
31
|
+
description: input.description,
|
|
32
|
+
inLanguage: input.inLanguage,
|
|
33
|
+
potentialAction: input.searchUrlTemplate
|
|
34
|
+
? thing('SearchAction', {
|
|
35
|
+
target: input.searchUrlTemplate,
|
|
36
|
+
'query-input': 'required name=search_term_string'
|
|
37
|
+
})
|
|
38
|
+
: undefined
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function webPage(input) {
|
|
42
|
+
return thing('WebPage', {
|
|
43
|
+
name: input.name,
|
|
44
|
+
url: input.url,
|
|
45
|
+
description: input.description,
|
|
46
|
+
inLanguage: input.inLanguage,
|
|
47
|
+
isPartOf: input.isPartOf ? { '@id': input.isPartOf } : undefined
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function breadcrumbList(items) {
|
|
51
|
+
return thing('BreadcrumbList', {
|
|
52
|
+
itemListElement: items.map((item, i) => thing('ListItem', { position: i + 1, name: item.name, item: item.url }))
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function authorNode(author) {
|
|
56
|
+
return typeof author === 'string'
|
|
57
|
+
? thing('Person', { name: author })
|
|
58
|
+
: thing('Person', { name: author.name, url: author.url });
|
|
59
|
+
}
|
|
60
|
+
function buildArticle(type, input) {
|
|
61
|
+
return thing(type, {
|
|
62
|
+
headline: input.headline,
|
|
63
|
+
url: input.url,
|
|
64
|
+
image: input.image ? imageNode(input.image) : undefined,
|
|
65
|
+
description: input.description,
|
|
66
|
+
datePublished: input.datePublished,
|
|
67
|
+
dateModified: input.dateModified,
|
|
68
|
+
author: input.author ? authorNode(input.author) : undefined,
|
|
69
|
+
publisher: input.publisher ? organization(input.publisher) : undefined,
|
|
70
|
+
inLanguage: input.inLanguage,
|
|
71
|
+
articleSection: input.section,
|
|
72
|
+
keywords: input.keywords
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function article(input) {
|
|
76
|
+
return buildArticle('Article', input);
|
|
77
|
+
}
|
|
78
|
+
export function blogPosting(input) {
|
|
79
|
+
return buildArticle('BlogPosting', input);
|
|
80
|
+
}
|
|
81
|
+
export function faqPage(items) {
|
|
82
|
+
return thing('FAQPage', {
|
|
83
|
+
mainEntity: items.map((qa) => thing('Question', {
|
|
84
|
+
name: qa.question,
|
|
85
|
+
acceptedAnswer: thing('Answer', { text: qa.answer })
|
|
86
|
+
}))
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaFile } from '../../../types/media.js';
|
|
2
|
+
export type JsonLdNode = Record<string, unknown> & {
|
|
3
|
+
'@type': string | string[];
|
|
4
|
+
};
|
|
5
|
+
/** Recursively drop undefined/null/'' and empty arrays/objects. */
|
|
6
|
+
export declare function pruneEmpty<T>(value: T): T;
|
|
7
|
+
/** Build a schema.org node of `type` from `props`, pruning empty values. */
|
|
8
|
+
export declare function thing(type: string | string[], props?: Record<string, unknown>): JsonLdNode;
|
|
9
|
+
/** Wrap node(s) into a schema.org @graph document. */
|
|
10
|
+
export declare function jsonLdGraph(nodes: JsonLdNode | JsonLdNode[]): {
|
|
11
|
+
'@context': 'https://schema.org';
|
|
12
|
+
'@graph': JsonLdNode[];
|
|
13
|
+
};
|
|
14
|
+
/** Normalize an image to a URL string or an ImageObject with dimensions. */
|
|
15
|
+
export declare function imageNode(img: string | MediaFile): string | JsonLdNode;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function isEmpty(v) {
|
|
2
|
+
if (v === undefined || v === null || v === '')
|
|
3
|
+
return true;
|
|
4
|
+
if (Array.isArray(v))
|
|
5
|
+
return v.length === 0;
|
|
6
|
+
if (typeof v === 'object')
|
|
7
|
+
return Object.keys(v).length === 0;
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
/** Recursively drop undefined/null/'' and empty arrays/objects. */
|
|
11
|
+
export function pruneEmpty(value) {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
return value.map((v) => pruneEmpty(v)).filter((v) => !isEmpty(v));
|
|
14
|
+
}
|
|
15
|
+
if (value && typeof value === 'object') {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [k, v] of Object.entries(value)) {
|
|
18
|
+
const pv = pruneEmpty(v);
|
|
19
|
+
if (!isEmpty(pv))
|
|
20
|
+
out[k] = pv;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/** Build a schema.org node of `type` from `props`, pruning empty values. */
|
|
27
|
+
export function thing(type, props = {}) {
|
|
28
|
+
return pruneEmpty({ '@type': type, ...props });
|
|
29
|
+
}
|
|
30
|
+
/** Wrap node(s) into a schema.org @graph document. */
|
|
31
|
+
export function jsonLdGraph(nodes) {
|
|
32
|
+
return { '@context': 'https://schema.org', '@graph': Array.isArray(nodes) ? nodes : [nodes] };
|
|
33
|
+
}
|
|
34
|
+
/** Normalize an image to a URL string or an ImageObject with dimensions. */
|
|
35
|
+
export function imageNode(img) {
|
|
36
|
+
if (typeof img === 'string')
|
|
37
|
+
return img;
|
|
38
|
+
return thing('ImageObject', {
|
|
39
|
+
url: img.url,
|
|
40
|
+
width: img.width ?? undefined,
|
|
41
|
+
height: img.height ?? undefined
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -46,7 +46,7 @@ export type ResolvedSeo = {
|
|
|
46
46
|
* ```svelte
|
|
47
47
|
* <script lang="ts">
|
|
48
48
|
* import { resolveSeo } from 'includio-cms/core';
|
|
49
|
-
* import { Seo } from 'includio-cms';
|
|
49
|
+
* import { Seo } from 'includio-cms/sveltekit';
|
|
50
50
|
* let { entry, language } = $props();
|
|
51
51
|
* const seo = $derived(resolveSeo(entry, language));
|
|
52
52
|
* </script>
|
|
@@ -45,7 +45,7 @@ void _SEO_KEYS_EXHAUSTIVE;
|
|
|
45
45
|
* ```svelte
|
|
46
46
|
* <script lang="ts">
|
|
47
47
|
* import { resolveSeo } from 'includio-cms/core';
|
|
48
|
-
* import { Seo } from 'includio-cms';
|
|
48
|
+
* import { Seo } from 'includio-cms/sveltekit';
|
|
49
49
|
* let { entry, language } = $props();
|
|
50
50
|
* const seo = $derived(resolveSeo(entry, language));
|
|
51
51
|
* </script>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slugify a media file base name (extension handled by the caller).
|
|
3
|
+
* Empty result falls back to `'file'`. `strict:true` removes dots/slashes,
|
|
4
|
+
* so no path-traversal survives. Capped at 200 chars (trailing `-` trimmed)
|
|
5
|
+
* to stay within filesystem name limits.
|
|
6
|
+
*/
|
|
7
|
+
export declare function slugifyFilename(base: string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import slugify from './slugify.js';
|
|
2
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
3
|
+
/**
|
|
4
|
+
* Slugify a media file base name (extension handled by the caller).
|
|
5
|
+
* Empty result falls back to `'file'`. `strict:true` removes dots/slashes,
|
|
6
|
+
* so no path-traversal survives. Capped at 200 chars (trailing `-` trimmed)
|
|
7
|
+
* to stay within filesystem name limits.
|
|
8
|
+
*/
|
|
9
|
+
export function slugifyFilename(base) {
|
|
10
|
+
const slug = slugify(base, { lower: true, strict: true, trim: true })
|
|
11
|
+
.slice(0, MAX_FILENAME_LENGTH)
|
|
12
|
+
.replace(/-+$/, '');
|
|
13
|
+
return slug || 'file';
|
|
14
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -3,3 +3,6 @@
|
|
|
3
3
|
// browser/component bundle no longer pulls in `cms.js` and its server graph
|
|
4
4
|
// ($env/dynamic/private).
|
|
5
5
|
export { resolveSeo } from './fields/resolveSeo.js';
|
|
6
|
+
// JSON-LD (schema.org) builders + <JsonLd> primitives — client-safe, pure.
|
|
7
|
+
export * from './fields/jsonLd/index.js';
|
|
8
|
+
export { slugifyFilename } from './fields/slugifyFilename.js';
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Krótkie polskie wyrazy, które nie powinny zostawać na końcu wiersza.
|
|
3
|
+
* 1-literowe + wybrane przyimki 2–3-literowe. Konserwatywnie: bez spójników/partykuł
|
|
4
|
+
* (`nie oraz lub że bo to co`) — te można dołożyć przez parametr `words`.
|
|
4
5
|
*/
|
|
5
|
-
export declare
|
|
6
|
+
export declare const DEFAULT_ORPHAN_WORDS: readonly string[];
|
|
7
|
+
/**
|
|
8
|
+
* Zamienia zwykłą spację po krótkim wyrazie na niełamliwą (NBSP, U+00A0).
|
|
9
|
+
* Idempotentna — tekst z NBSP nie jest ponownie modyfikowany (regex wymaga
|
|
10
|
+
* zwykłej spacji). Flaga `i` obsługuje oba przypadki wielkości; `$1` zachowuje
|
|
11
|
+
* oryginalną wielkość dopasowanego wyrazu.
|
|
12
|
+
*
|
|
13
|
+
* @param text - tekst do przetworzenia
|
|
14
|
+
* @param words - opcjonalna lista wyrazów-sierot (domyślnie {@link DEFAULT_ORPHAN_WORDS});
|
|
15
|
+
* rozszerzaj przez `[...DEFAULT_ORPHAN_WORDS, 'oraz']`
|
|
16
|
+
*/
|
|
17
|
+
export declare function fixOrphans(text: string, words?: readonly string[]): string;
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Krótkie polskie wyrazy, które nie powinny zostawać na końcu wiersza.
|
|
3
|
+
* 1-literowe + wybrane przyimki 2–3-literowe. Konserwatywnie: bez spójników/partykuł
|
|
4
|
+
* (`nie oraz lub że bo to co`) — te można dołożyć przez parametr `words`.
|
|
4
5
|
*/
|
|
5
|
-
const
|
|
6
|
+
export const DEFAULT_ORPHAN_WORDS = [
|
|
7
|
+
'a', 'e', 'i', 'o', 'u', 'w', 'z',
|
|
8
|
+
'do', 'na', 'od', 'po', 'we', 'za', 'ze', 'ku',
|
|
9
|
+
'nad', 'pod', 'bez', 'dla', 'przy'
|
|
10
|
+
];
|
|
11
|
+
// Non-breaking space (U+00A0), built from char code to keep the source ASCII-only.
|
|
12
|
+
const NBSP = String.fromCharCode(0xa0);
|
|
13
|
+
function escapeRegex(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
15
|
+
}
|
|
16
|
+
function buildOrphanRegex(words) {
|
|
17
|
+
const alt = [...words]
|
|
18
|
+
.sort((a, b) => b.length - a.length)
|
|
19
|
+
.map(escapeRegex)
|
|
20
|
+
.join('|');
|
|
21
|
+
return new RegExp(`(?<=\\s|^)(${alt}) (?=\\S)`, 'gi');
|
|
22
|
+
}
|
|
6
23
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
24
|
+
* Zamienia zwykłą spację po krótkim wyrazie na niełamliwą (NBSP, U+00A0).
|
|
25
|
+
* Idempotentna — tekst z NBSP nie jest ponownie modyfikowany (regex wymaga
|
|
26
|
+
* zwykłej spacji). Flaga `i` obsługuje oba przypadki wielkości; `$1` zachowuje
|
|
27
|
+
* oryginalną wielkość dopasowanego wyrazu.
|
|
28
|
+
*
|
|
29
|
+
* @param text - tekst do przetworzenia
|
|
30
|
+
* @param words - opcjonalna lista wyrazów-sierot (domyślnie {@link DEFAULT_ORPHAN_WORDS});
|
|
31
|
+
* rozszerzaj przez `[...DEFAULT_ORPHAN_WORDS, 'oraz']`
|
|
9
32
|
*/
|
|
10
|
-
export function fixOrphans(text) {
|
|
11
|
-
return text.replace(
|
|
33
|
+
export function fixOrphans(text, words = DEFAULT_ORPHAN_WORDS) {
|
|
34
|
+
return text.replace(buildOrphanRegex(words), `$1${NBSP}`);
|
|
12
35
|
}
|
|
@@ -4,7 +4,7 @@ import { readFile, writeFile, rename, unlink, mkdir, readdir } from 'node:fs/pro
|
|
|
4
4
|
import sharp from 'sharp';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { dirname } from 'path';
|
|
7
|
-
import {
|
|
7
|
+
import { slugifyFilename } from '../core/fields/slugifyFilename.js';
|
|
8
8
|
import { processVideo, setFfmpegPaths } from './video.js';
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = dirname(__filename);
|
|
@@ -60,7 +60,7 @@ export function local(config) {
|
|
|
60
60
|
const id = randomUUID();
|
|
61
61
|
const ext = extname(file.name); // File extension
|
|
62
62
|
const rawName = basename(file.name, ext); // File name without extension
|
|
63
|
-
let filename =
|
|
63
|
+
let filename = slugifyFilename(rawName);
|
|
64
64
|
let filepath = ''; // Full file path
|
|
65
65
|
const MAX_COLLISION_ATTEMPTS = 100;
|
|
66
66
|
let attempt = 0;
|
|
@@ -77,7 +77,7 @@ export function local(config) {
|
|
|
77
77
|
try {
|
|
78
78
|
await readFile(resolvedFilePath);
|
|
79
79
|
// File exists, append counter
|
|
80
|
-
filename = `${
|
|
80
|
+
filename = `${slugifyFilename(rawName)}-${++attempt}`;
|
|
81
81
|
}
|
|
82
82
|
catch {
|
|
83
83
|
// File doesn't exist, safe to use
|
|
@@ -147,7 +147,7 @@ export function local(config) {
|
|
|
147
147
|
name: oldName
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
-
const sanitizedNewName =
|
|
150
|
+
const sanitizedNewName = slugifyFilename(newName);
|
|
151
151
|
const ext = extname(url);
|
|
152
152
|
// Derive old filename from URL to avoid double extension
|
|
153
153
|
const oldFilename = url.split('/').pop() || `${oldName}${ext}`;
|
|
@@ -199,7 +199,7 @@ export function local(config) {
|
|
|
199
199
|
await ensureDir(privateDir);
|
|
200
200
|
const ext = extname(file.name);
|
|
201
201
|
const rawName = basename(file.name, ext);
|
|
202
|
-
const safeName =
|
|
202
|
+
const safeName = slugifyFilename(rawName);
|
|
203
203
|
const filename = `${randomUUID().slice(0, 8)}-${safeName}${ext}`;
|
|
204
204
|
const filepath = resolve(privateDir, filename);
|
|
205
205
|
if (!filepath.startsWith(resolve(privateDir))) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** Wstrzykuje blok schema.org JSON-LD do <head>. `data` = obiekt lub tablica (np. z jsonLdGraph). */
|
|
3
|
+
let { data }: { data: unknown } = $props();
|
|
4
|
+
// Escape `<` → <: breakout ze <script> niemożliwy. Dane pochodzą od dewelopera (schema.org), nie od usera.
|
|
5
|
+
const json = $derived(JSON.stringify(data).replaceAll('<', '\\u003c'));
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<svelte:head>
|
|
9
|
+
<!-- Bezpieczne: `<` w danych zescapowane, `<\/script>` chroni źródło przed wczesnym zamknięciem tagu. -->
|
|
10
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags, no-useless-escape -->
|
|
11
|
+
{@html `<script type="application/ld+json">${json}<\/script>`}
|
|
12
|
+
</svelte:head>
|
|
@@ -8,9 +8,33 @@
|
|
|
8
8
|
keywords?: string;
|
|
9
9
|
ogImage?: MediaFile | string;
|
|
10
10
|
canonicalUrl?: string;
|
|
11
|
+
ogType?: string;
|
|
12
|
+
noindex?: boolean;
|
|
13
|
+
siteName?: string;
|
|
14
|
+
article?: {
|
|
15
|
+
publishedTime?: string;
|
|
16
|
+
modifiedTime?: string;
|
|
17
|
+
author?: string;
|
|
18
|
+
section?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
};
|
|
11
21
|
};
|
|
12
22
|
|
|
13
|
-
let {
|
|
23
|
+
let {
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
keywords,
|
|
27
|
+
ogImage,
|
|
28
|
+
canonicalUrl,
|
|
29
|
+
ogType = 'website',
|
|
30
|
+
noindex = false,
|
|
31
|
+
siteName,
|
|
32
|
+
article
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
const url = $derived(canonicalUrl ?? page.url.toString());
|
|
36
|
+
const imageUrl = $derived(ogImage ? (typeof ogImage === 'string' ? ogImage : ogImage.url) : undefined);
|
|
37
|
+
const imageMeta = $derived(ogImage && typeof ogImage !== 'string' ? ogImage : undefined);
|
|
14
38
|
</script>
|
|
15
39
|
|
|
16
40
|
<svelte:head>
|
|
@@ -20,10 +44,13 @@
|
|
|
20
44
|
<meta property="og:title" content={title} />
|
|
21
45
|
<meta name="twitter:title" content={title} />
|
|
22
46
|
{/if}
|
|
23
|
-
<meta
|
|
24
|
-
<meta
|
|
47
|
+
<meta property="og:url" content={url} />
|
|
48
|
+
<meta property="og:type" content={ogType} />
|
|
49
|
+
{#if siteName}
|
|
50
|
+
<meta property="og:site_name" content={siteName} />
|
|
51
|
+
{/if}
|
|
25
52
|
<meta property="twitter:domain" content={page.url.hostname} />
|
|
26
|
-
<meta property="twitter:url" content={
|
|
53
|
+
<meta property="twitter:url" content={url} />
|
|
27
54
|
{#if description}
|
|
28
55
|
<meta name="description" content={description} />
|
|
29
56
|
<meta property="og:description" content={description} />
|
|
@@ -32,17 +59,41 @@
|
|
|
32
59
|
{#if keywords}
|
|
33
60
|
<meta name="keywords" content={keywords} />
|
|
34
61
|
{/if}
|
|
62
|
+
{#if noindex}
|
|
63
|
+
<meta name="robots" content="noindex" />
|
|
64
|
+
{/if}
|
|
35
65
|
{#if canonicalUrl}
|
|
36
66
|
<link rel="canonical" href={canonicalUrl} />
|
|
37
67
|
{/if}
|
|
38
|
-
{#if
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
{#if imageUrl}
|
|
69
|
+
<meta property="og:image" content={imageUrl} />
|
|
70
|
+
<meta name="twitter:image" content={imageUrl} />
|
|
71
|
+
{#if imageMeta?.width}
|
|
72
|
+
<meta property="og:image:width" content={String(imageMeta.width)} />
|
|
73
|
+
{/if}
|
|
74
|
+
{#if imageMeta?.height}
|
|
75
|
+
<meta property="og:image:height" content={String(imageMeta.height)} />
|
|
76
|
+
{/if}
|
|
77
|
+
{#if imageMeta?.alt}
|
|
78
|
+
<meta property="og:image:alt" content={imageMeta.alt} />
|
|
45
79
|
{/if}
|
|
46
80
|
<meta name="twitter:card" content="summary_large_image" />
|
|
47
81
|
{/if}
|
|
82
|
+
{#if ogType === 'article' && article}
|
|
83
|
+
{#if article.publishedTime}
|
|
84
|
+
<meta property="article:published_time" content={article.publishedTime} />
|
|
85
|
+
{/if}
|
|
86
|
+
{#if article.modifiedTime}
|
|
87
|
+
<meta property="article:modified_time" content={article.modifiedTime} />
|
|
88
|
+
{/if}
|
|
89
|
+
{#if article.author}
|
|
90
|
+
<meta property="article:author" content={article.author} />
|
|
91
|
+
{/if}
|
|
92
|
+
{#if article.section}
|
|
93
|
+
<meta property="article:section" content={article.section} />
|
|
94
|
+
{/if}
|
|
95
|
+
{#each article.tags ?? [] as tag, i (i)}
|
|
96
|
+
<meta property="article:tag" content={tag} />
|
|
97
|
+
{/each}
|
|
98
|
+
{/if}
|
|
48
99
|
</svelte:head>
|
|
@@ -5,6 +5,16 @@ type Props = {
|
|
|
5
5
|
keywords?: string;
|
|
6
6
|
ogImage?: MediaFile | string;
|
|
7
7
|
canonicalUrl?: string;
|
|
8
|
+
ogType?: string;
|
|
9
|
+
noindex?: boolean;
|
|
10
|
+
siteName?: string;
|
|
11
|
+
article?: {
|
|
12
|
+
publishedTime?: string;
|
|
13
|
+
modifiedTime?: string;
|
|
14
|
+
author?: string;
|
|
15
|
+
section?: string;
|
|
16
|
+
tags?: string[];
|
|
17
|
+
};
|
|
8
18
|
};
|
|
9
19
|
declare const Seo: import("svelte").Component<Props, {}, "">;
|
|
10
20
|
type Seo = ReturnType<typeof Seo>;
|
|
@@ -8,6 +8,8 @@ export { default as Video } from './components/video.svelte';
|
|
|
8
8
|
export { default as CmsProvider } from './components/cms-provider.svelte';
|
|
9
9
|
export { default as Media } from './components/media.svelte';
|
|
10
10
|
export { default as StructuredContent } from './components/structured-content.svelte';
|
|
11
|
+
export { default as JsonLd } from './components/json-ld.svelte';
|
|
12
|
+
export { default as Seo } from './components/seo.svelte';
|
|
11
13
|
export { enableHybridEditing } from './components/hybrid-context.js';
|
|
12
14
|
export { setPreferMp4 } from './components/video-context.js';
|
|
13
15
|
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { default as Video } from './components/video.svelte';
|
|
|
8
8
|
export { default as CmsProvider } from './components/cms-provider.svelte';
|
|
9
9
|
export { default as Media } from './components/media.svelte';
|
|
10
10
|
export { default as StructuredContent } from './components/structured-content.svelte';
|
|
11
|
+
export { default as JsonLd } from './components/json-ld.svelte';
|
|
12
|
+
export { default as Seo } from './components/seo.svelte';
|
|
11
13
|
export { enableHybridEditing } from './components/hybrid-context.js';
|
|
12
14
|
export { setPreferMp4 } from './components/video-context.js';
|
|
13
15
|
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
|
@@ -10,3 +10,4 @@ export { createRestApiHandler } from '../../admin/api/rest/handler.js';
|
|
|
10
10
|
export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.js';
|
|
11
11
|
export { createAdminApiHandler } from '../../admin/api/handler.js';
|
|
12
12
|
export { extractBlocks, extractInlineBlocks, extractText, extractMediaRefs } from '../../core/server/fields/queryStructuredContent.js';
|
|
13
|
+
export { buildSitemap, buildRobots, type SitemapEntry, type RobotsRule, type RobotsOptions } from './sitemap.js';
|
|
@@ -14,3 +14,5 @@ export { generateApiKey } from '../../admin/api/rest/middleware/generateApiKey.j
|
|
|
14
14
|
// Folded from `./admin/api/handler` (dropped as separate export in 0.26.1)
|
|
15
15
|
export { createAdminApiHandler } from '../../admin/api/handler.js';
|
|
16
16
|
export { extractBlocks, extractInlineBlocks, extractText, extractMediaRefs } from '../../core/server/fields/queryStructuredContent.js';
|
|
17
|
+
// SEO: sitemap.xml / robots.txt builders — pure, projekt posiada route.
|
|
18
|
+
export { buildSitemap, buildRobots } from './sitemap.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SitemapEntry {
|
|
2
|
+
url: string;
|
|
3
|
+
lastmod?: string | Date;
|
|
4
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
5
|
+
priority?: number;
|
|
6
|
+
}
|
|
7
|
+
/** Build a sitemap.xml document from a list of URL entries. */
|
|
8
|
+
export declare function buildSitemap(entries: SitemapEntry[]): string;
|
|
9
|
+
export interface RobotsRule {
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
allow?: string[];
|
|
12
|
+
disallow?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RobotsOptions {
|
|
15
|
+
sitemapUrl?: string;
|
|
16
|
+
rules?: RobotsRule[];
|
|
17
|
+
}
|
|
18
|
+
/** Build a robots.txt document. Defaults to allow-all + optional sitemap link. */
|
|
19
|
+
export declare function buildRobots(options?: RobotsOptions): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function escapeXml(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
function formatLastmod(v) {
|
|
10
|
+
return v instanceof Date ? v.toISOString() : v;
|
|
11
|
+
}
|
|
12
|
+
/** Build a sitemap.xml document from a list of URL entries. */
|
|
13
|
+
export function buildSitemap(entries) {
|
|
14
|
+
const urls = entries
|
|
15
|
+
.map((e) => {
|
|
16
|
+
const parts = [` <loc>${escapeXml(e.url)}</loc>`];
|
|
17
|
+
if (e.lastmod !== undefined)
|
|
18
|
+
parts.push(` <lastmod>${escapeXml(formatLastmod(e.lastmod))}</lastmod>`);
|
|
19
|
+
if (e.changefreq)
|
|
20
|
+
parts.push(` <changefreq>${e.changefreq}</changefreq>`);
|
|
21
|
+
if (e.priority !== undefined)
|
|
22
|
+
parts.push(` <priority>${e.priority.toFixed(1)}</priority>`);
|
|
23
|
+
return ` <url>\n${parts.join('\n')}\n </url>`;
|
|
24
|
+
})
|
|
25
|
+
.join('\n');
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls ? '\n' + urls + '\n' : ''}</urlset>`;
|
|
27
|
+
}
|
|
28
|
+
/** Build a robots.txt document. Defaults to allow-all + optional sitemap link. */
|
|
29
|
+
export function buildRobots(options = {}) {
|
|
30
|
+
const rules = options.rules && options.rules.length > 0 ? options.rules : [{ userAgent: '*', allow: ['/'] }];
|
|
31
|
+
const blocks = rules.map((r) => {
|
|
32
|
+
const lines = [`User-agent: ${r.userAgent ?? '*'}`];
|
|
33
|
+
for (const a of r.allow ?? [])
|
|
34
|
+
lines.push(`Allow: ${a}`);
|
|
35
|
+
for (const d of r.disallow ?? [])
|
|
36
|
+
lines.push(`Disallow: ${d}`);
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
});
|
|
39
|
+
let out = blocks.join('\n\n');
|
|
40
|
+
if (options.sitemapUrl)
|
|
41
|
+
out += `\n\nSitemap: ${options.sitemapUrl}`;
|
|
42
|
+
return out + '\n';
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.37.2',
|
|
3
|
+
date: '2026-07-16',
|
|
4
|
+
description: 'Powierzchnia SEO/AEO rdzenia — reużywalne helpery, które raz naprawione w rdzeniu działają u każdego klienta (wyszły z wdrożenia SEO u pod-zwrotnikami). W tym samym patchu domyka się też łatka bezpieczeństwa bookingu (kwota płatności).',
|
|
5
|
+
features: [
|
|
6
|
+
'**Buildery JSON-LD (schema.org) w `includio-cms/core`.** Typowane, czyste funkcje: `organization`, `localBusiness`, `website`, `webPage`, `breadcrumbList`, `article`, `blogPosting`, `faqPage` + generyk `thing` i `jsonLdGraph`. Opt-in — nic nie emitują domyślnie. `undefined`/puste wartości wypadają z outputu, więc częściowe dane dają poprawny minimalny JSON-LD. Bespoke typy (np. `TouristTrip + Offer`, `Product`) składasz po stronie projektu przez `thing("TouristTrip", { ... })` — rdzeń zostaje generyczny.',
|
|
7
|
+
'**Komponent `<JsonLd>` w `includio-cms/sveltekit`.** Renderuje `<script type="application/ld+json">` w `<svelte:head>` (SSR), escapuje `<` → breakout ze `<script>` jest niemożliwy. Przyjmuje obiekt albo tablicę (np. wynik `jsonLdGraph`).',
|
|
8
|
+
'**`buildSitemap` / `buildRobots` w `includio-cms/sveltekit/server`.** Czyste buildery: `buildSitemap(entries)` → string `sitemap.xml` (URL-e XML-escaped, `lastmod` `Date`→ISO lub string, opcjonalne `changefreq`/`priority`), `buildRobots(options)` → `robots.txt` (domyślnie allow-all + link do sitemapy, konfigurowalne reguły). Projekt posiada route i wpina helper: `export const GET = async () => new Response(buildSitemap(entries), { headers: { "content-type": "application/xml" } });`.'
|
|
9
|
+
],
|
|
10
|
+
fixes: [
|
|
11
|
+
'**Slugifikacja nazw plików mediów.** Upload i zmiana nazwy przepuszczają basename przez `slugifyFilename` (nowy helper w `includio-cms/core`): `"IMG-1234 (1).jpeg"` → `img-1234-1.jpeg`, polskie znaki transliterowane, spacje/wielkość/nawiasy usunięte. Tylko nowe pliki — istniejące URL-e w bazie nietknięte (bez migracji). Cap 200 znaków, path-traversal niemożliwy (slug nie zawiera `/` ani `.`).',
|
|
12
|
+
'**`<Seo>` dopełniony i wyeksportowany z `includio-cms/sveltekit`.** Wcześniej komponent istniał, ale nie był eksportowany (martwy). Bugfix: `og:url` i `og:type` używają `property=` zamiast `name=` (wymóg Open Graph). Nowe propsy: `ogType` (domyślnie `website`; `article` dokłada `article:published_time`/`modified_time`/`author`/`section`/`tag`), `noindex` (→ `<meta name="robots" content="noindex">`), `siteName` (→ `og:site_name`). `ogImage` typu `MediaFile` emituje `og:image:width`/`height`/`alt`.',
|
|
13
|
+
'**Typografia — łamanie sierot przyimkowych.** `fixOrphans` wstawia NBSP nie tylko po jednoliterowych spójnikach (`i/w/z/o/u/a/e`), ale też po krótkich przyimkach (`do na od po we za ze ku nad pod bez dla przy`). Lista konfigurowalna parametrem `words` + eksport `DEFAULT_ORPHAN_WORDS`. Domyślnie konserwatywnie (bez spójników typu `oraz/lub`), żeby nie NBSP-ować za dużo.',
|
|
14
|
+
'**Bezpieczeństwo (booking): kwota dopłaty przestaje pochodzić z żądania HTTP.** Domknięcie łuku 0.37.1 (cena szła z katalogu, ale kwota wpłaty — nie): `portalPay` podawał `amount` z ciała żądania prosto do `createInstallmentPayment`, bez konfrontacji z saldem — przeglądarka dyktowała kwotę (nadpłata do ręcznego zwrotu albo link na grosz). Bramka kwotowa oparta na aktorze (bliźniak bramki cenowej): `portal` NIE dyktuje (serwer obciąża `totals.balance`, fail-closed gdy brak salda), `staff` może. Portalowa wpłata zapisuje się teraz z `createdBy` aktora — nie zahardkodowanym `staff` (koniec kłamstwa w audycie).',
|
|
15
|
+
'**Booking: `ledger.balance` przestaje gubić rabaty.** `portalView` liczył `ledger` z `totals.gross` → `balance = gross − paid`, pomijając `discount`/`surcharge`/`manualDiscount`; portal pokazywał dwa różne salda i przycisk płatności brał gorzej policzone. Teraz `toPortalView` karmi `ledgerSummary` wartością netto (`netOf(totals)`), więc `ledger.balance === totals.balance`.'
|
|
16
|
+
],
|
|
17
|
+
breakingChanges: [
|
|
18
|
+
'**`createInstallmentPayment(bookingId, amount)` → `createInstallmentPayment(bookingId, { actor, amount? })`.** `actor: "portal" | "staff"` jest wymagany. Dla `portal` kwota jest ignorowana (serwer obciąża saldo); dla `staff` podajesz `amount`. `portalPay(token, amount)` → `portalPay(token)` (kwota nie jest już parametrem). Dotyczy tylko konsumentów wołających te funkcje bezpośrednio — ścieżka przez handler portalu/remote admina zaktualizowana w rdzeniu.'
|
|
19
|
+
]
|
|
20
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -77,6 +77,7 @@ import { update as update0368 } from './0.36.8/index.js';
|
|
|
77
77
|
import { update as update0369 } from './0.36.9/index.js';
|
|
78
78
|
import { update as update0370 } from './0.37.0/index.js';
|
|
79
79
|
import { update as update0371 } from './0.37.1/index.js';
|
|
80
|
+
import { update as update0372 } from './0.37.2/index.js';
|
|
80
81
|
export const updates = [
|
|
81
82
|
update0065,
|
|
82
83
|
update0066,
|
|
@@ -156,7 +157,8 @@ export const updates = [
|
|
|
156
157
|
update0368,
|
|
157
158
|
update0369,
|
|
158
159
|
update0370,
|
|
159
|
-
update0371
|
|
160
|
+
update0371,
|
|
161
|
+
update0372
|
|
160
162
|
];
|
|
161
163
|
export const getUpdatesFrom = (fromVersion) => {
|
|
162
164
|
const fromParts = fromVersion.split('.').map(Number);
|