includio-cms 0.37.0 → 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 +57 -7
- package/CHANGELOG.md +42 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +32 -0
- package/dist/admin/remote/booking.remote.d.ts +3 -0
- package/dist/admin/remote/booking.remote.js +5 -2
- package/dist/booking/client/index.d.ts +5 -2
- package/dist/booking/client/index.js +5 -1
- package/dist/booking/client/use-booking.svelte.d.ts +0 -1
- package/dist/booking/config.d.ts +20 -0
- package/dist/booking/config.js +1 -0
- package/dist/booking/draft.d.ts +23 -4
- package/dist/booking/payment-amount.d.ts +23 -0
- package/dist/booking/payment-amount.js +37 -0
- package/dist/booking/server/adjustments.js +5 -2
- package/dist/booking/server/bookings.d.ts +7 -1
- package/dist/booking/server/bookings.js +59 -11
- package/dist/booking/server/http/create-handler.js +4 -2
- package/dist/booking/server/http/portal-handler.js +7 -2
- 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 -3
- package/dist/booking/server/portal.js +13 -5
- package/dist/booking/server/price-source.d.ts +73 -0
- package/dist/booking/server/price-source.js +94 -0
- 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/db-postgres/schema/booking/bookings.d.ts +17 -0
- package/dist/db-postgres/schema/booking/bookings.js +12 -0
- 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.1/index.d.ts +2 -0
- package/dist/updates/0.37.1/index.js +24 -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 +5 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -132,6 +132,23 @@ export declare const bookingsTable: import("drizzle-orm/pg-core/table", { with:
|
|
|
132
132
|
}, {}, {
|
|
133
133
|
$type: BookingTotals;
|
|
134
134
|
}>;
|
|
135
|
+
pricedAt: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
136
|
+
name: "priced_at";
|
|
137
|
+
tableName: "bookings";
|
|
138
|
+
dataType: "date";
|
|
139
|
+
columnType: "PgTimestamp";
|
|
140
|
+
data: Date;
|
|
141
|
+
driverParam: string;
|
|
142
|
+
notNull: false;
|
|
143
|
+
hasDefault: false;
|
|
144
|
+
isPrimaryKey: false;
|
|
145
|
+
isAutoincrement: false;
|
|
146
|
+
hasRuntimeDefault: false;
|
|
147
|
+
enumValues: undefined;
|
|
148
|
+
baseColumn: never;
|
|
149
|
+
identity: undefined;
|
|
150
|
+
generated: undefined;
|
|
151
|
+
}, {}, {}>;
|
|
135
152
|
accessToken: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
136
153
|
name: "access_token";
|
|
137
154
|
tableName: "bookings";
|
|
@@ -22,6 +22,18 @@ export const bookingsTable = pgTable('bookings', {
|
|
|
22
22
|
perks: [],
|
|
23
23
|
appliedRules: []
|
|
24
24
|
}),
|
|
25
|
+
/**
|
|
26
|
+
* **Moment wyceny — zamrożony.** Każde przeliczenie istniejącej rezerwacji (edycja,
|
|
27
|
+
* korekta) liczy reguły zależne od czasu (early-bird) na tę datę, **nigdy na `new Date()`**.
|
|
28
|
+
*
|
|
29
|
+
* Bez tego cena bazowa była zamrożona, ale data — nie: obsługa księgowała dopłatę
|
|
30
|
+
* tydzień przed wylotem, `daysUntil(anchor, now)` spadało poniżej progu, early-bird
|
|
31
|
+
* cicho znikał i klient robił się winien pieniądze.
|
|
32
|
+
*
|
|
33
|
+
* NULL dla rezerwacji sprzed 0.37.1 ⇒ czytamy `createdAt`, czyli dokładnie to, czym
|
|
34
|
+
* było `now` w chwili ich wyceny (zero zmiany zachowania, brak migracji danych).
|
|
35
|
+
*/
|
|
36
|
+
pricedAt: timestamp('priced_at', { withTimezone: true }),
|
|
25
37
|
/** Unguessable token for the self-service portal (`/rezerwacja/[token]`). */
|
|
26
38
|
accessToken: uuid('access_token').defaultRandom().notNull(),
|
|
27
39
|
staffNotes: text('staff_notes'),
|
|
@@ -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,24 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.37.1',
|
|
3
|
+
date: '2026-07-14',
|
|
4
|
+
description: '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.',
|
|
5
|
+
fixes: [
|
|
6
|
+
'**Bezpieczeństwo (data wyceny).** Nowa kolumna `bookings.priced_at` zamraża moment wyceny. `editBooking` i `priceFromExistingRows` (ścieżka korekt — `adjustments.ts:65` i `:155`) liczą teraz early-bird na tę datę, nie na `new Date()`. Wcześniej wystarczyło, że obsługa zaksięguje dopłatę ustaloną telefonicznie tydzień przed wylotem, a rabat za wczesną rezerwację przepadał. `priceFromExistingRows` straciło parametr `now` — kompilator nie pozwoli już podać tam zegara.',
|
|
7
|
+
'Rezerwacje sprzed 0.37.1 mają `priced_at = NULL` i wyceniają się z `created_at` — dokładnie tym, czym było wtedy `now`. Zero migracji danych, zero zmiany zachowania.',
|
|
8
|
+
'**Bezpieczeństwo (ceny).** Trzy publiczne drogi wpuszczały cenę z zewnątrz: tworzenie rezerwacji (`createBookingHandler`), dokładanie wycieczki przez portal (`add-trips`) i portalowa edycja pozycji (`portalEdit` → `editBooking`). Bramka stoi teraz w rdzeniu (`createBooking`/`editBooking`), nie w handlerach, więc każdy przyszły publiczny endpoint jest chroniony domyślnie.',
|
|
9
|
+
'Cena pozycji już zapisanej w rezerwacji jest brana z jej `unitPriceSnapshot`, a nie przeliczana z katalogu — dotknięcie rezerwacji przez portal nie podniesie klientowi ceny, gdy cennik w międzyczasie poszedł w górę.',
|
|
10
|
+
'Odczyt katalogu w `editBooking` odbywa się przed otwarciem transakcji: szew klienta robi własne I/O, a wołany pod blokadą `FOR UPDATE` trzymałby wiersz rezerwacji i sięgał po drugie połączenie z tej samej puli.',
|
|
11
|
+
'Ceny ujemne i niecałkowite (ułamek grosza) są odrzucane — również dla obsługi.'
|
|
12
|
+
],
|
|
13
|
+
features: [
|
|
14
|
+
'`defineBooking({ resolvePrice })` — serwerowy szew zwracający cenę bazową (dorosłego) pozycji katalogu w groszach. Rdzeń nie zna kształtu katalogu, więc cenę podaje projekt: `resolvePrice: async (tripRef) => Math.round(Number((await resolveEntry({ id: tripRef, status: "published" }))?.price ?? 0) * 100)`.',
|
|
15
|
+
'Model zaufania oparty na aktorze: `createBooking(input, { actor })` przyjmuje `public | portal | staff`. Domyślny aktor to `public` (niezaufany) — pominięcie parametru nie otwiera dziury. Zalogowana obsługa (`staff`) nadal może podać cenę ręcznie: korekta i rabat negocjowany telefonicznie zostają możliwe.',
|
|
16
|
+
'Typ `PricedCreateInput` wymusza kolejność: rezerwacji nie da się zbudować z pozycji, której nie wyceniła bramka — pilnuje tego kompilator, nie dyscyplina.'
|
|
17
|
+
],
|
|
18
|
+
breakingChanges: [
|
|
19
|
+
'Nowa kolumna `bookings.priced_at` — wymagana migracja (`drizzle-kit push`). Nullable, bez backfillu.',
|
|
20
|
+
'**`defineBooking({ resolvePrice })` jest teraz wymagany, gdy booking wystawia publiczne endpointy** (`createBookingHandler`, portal). Bez niego rdzeń odmawia wyceny (fail-closed) zamiast po cichu zaufać cenie z żądania. Instalacje używające wyłącznie panelu admina działają bez zmian.',
|
|
21
|
+
'`BookingItemInput.unitPrice` jest opcjonalne i **honorowane wyłącznie dla `actor: "staff"`**. Dla klienta końcowego jest ignorowane — cenę ustala katalog.',
|
|
22
|
+
'Publiczne API klienta: `createBookingClient().addTrips(token, additions)` oraz `useBooking().addTrips(additions)` nie przyjmują już `unitPrice` w pozycjach (`{ tripRef, assignments }`). Storefronty przekazujące cenę muszą ją usunąć z ładunku — była i tak ignorowana po stronie serwera.'
|
|
23
|
+
]
|
|
24
|
+
};
|