@wollycms/astro 0.1.0
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/package.json +40 -0
- package/src/client.ts +125 -0
- package/src/components/BlockRenderer.astro +31 -0
- package/src/components/PreviewBlockRenderer.astro +43 -0
- package/src/components/RichText.astro +13 -0
- package/src/components/SpacelyImage.astro +34 -0
- package/src/helpers/menu.ts +75 -0
- package/src/helpers/richtext.ts +114 -0
- package/src/helpers/seo.ts +123 -0
- package/src/helpers/tracking.ts +33 -0
- package/src/index.ts +6 -0
- package/src/types.ts +214 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wollycms/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Astro.js integration for WollyCMS — components, helpers, and route generation",
|
|
6
|
+
"author": "Chad Wollenberg",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/wollycms/wollycms.git",
|
|
11
|
+
"directory": "packages/astro"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/wollycms/wollycms",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/wollycms/wollycms/issues"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./components/BlockRenderer.astro": "./src/components/BlockRenderer.astro",
|
|
20
|
+
"./components/WollyImage.astro": "./src/components/WollyImage.astro",
|
|
21
|
+
"./components/RichText.astro": "./src/components/RichText.astro",
|
|
22
|
+
"./components/PreviewBlockRenderer.astro": "./src/components/PreviewBlockRenderer.astro",
|
|
23
|
+
"./helpers/tracking.js": "./src/helpers/tracking.ts"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"astro",
|
|
30
|
+
"withastro",
|
|
31
|
+
"astro-component",
|
|
32
|
+
"cms",
|
|
33
|
+
"wollycms",
|
|
34
|
+
"headless-cms",
|
|
35
|
+
"block-editor"
|
|
36
|
+
],
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"astro": "^5.0.0 || ^6.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WollyConfig,
|
|
3
|
+
PageListParams,
|
|
4
|
+
PageSummary,
|
|
5
|
+
Page,
|
|
6
|
+
Menu,
|
|
7
|
+
Term,
|
|
8
|
+
MediaInfo,
|
|
9
|
+
MediaVariant,
|
|
10
|
+
Redirect,
|
|
11
|
+
SiteConfig,
|
|
12
|
+
Schemas,
|
|
13
|
+
ApiResponse,
|
|
14
|
+
PaginatedResponse,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
import type { TrackingScriptsData } from './helpers/tracking.js';
|
|
17
|
+
|
|
18
|
+
export class WollyClient {
|
|
19
|
+
private baseUrl: string;
|
|
20
|
+
|
|
21
|
+
constructor(config: WollyConfig) {
|
|
22
|
+
this.baseUrl = config.apiUrl.replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async fetch<T>(path: string): Promise<T> {
|
|
26
|
+
const url = `${this.baseUrl}${path}`;
|
|
27
|
+
const res = await fetch(url);
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(`WollyCMS API error: ${res.status} ${res.statusText} (${url})`);
|
|
30
|
+
}
|
|
31
|
+
return res.json() as Promise<T>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
readonly pages = {
|
|
35
|
+
list: async (params?: PageListParams): Promise<PaginatedResponse<PageSummary>> => {
|
|
36
|
+
const searchParams = new URLSearchParams();
|
|
37
|
+
if (params?.type) searchParams.set('type', params.type);
|
|
38
|
+
if (params?.taxonomy) searchParams.set('taxonomy', params.taxonomy);
|
|
39
|
+
if (params?.sort) searchParams.set('sort', params.sort);
|
|
40
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
41
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
42
|
+
const qs = searchParams.toString();
|
|
43
|
+
return this.fetch(`/pages${qs ? `?${qs}` : ''}`);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
getBySlug: async (slug: string): Promise<Page> => {
|
|
47
|
+
const res = await this.fetch<ApiResponse<Page>>(`/pages/${slug}`);
|
|
48
|
+
return res.data;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
readonly menus = {
|
|
53
|
+
get: async (slug: string, depth?: number): Promise<Menu> => {
|
|
54
|
+
const qs = depth != null ? `?depth=${depth}` : '';
|
|
55
|
+
const res = await this.fetch<ApiResponse<Menu>>(`/menus/${slug}${qs}`);
|
|
56
|
+
return res.data;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
readonly taxonomies = {
|
|
61
|
+
getTerms: async (slug: string): Promise<Term[]> => {
|
|
62
|
+
const res = await this.fetch<ApiResponse<Term[]>>(`/taxonomies/${slug}/terms`);
|
|
63
|
+
return res.data;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
readonly media = {
|
|
68
|
+
getInfo: async (id: number): Promise<MediaInfo> => {
|
|
69
|
+
const res = await this.fetch<ApiResponse<MediaInfo>>(`/media/${id}/info`);
|
|
70
|
+
return res.data;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
getVariant: async (id: number, variant: string): Promise<MediaVariant> => {
|
|
74
|
+
const res = await this.fetch<ApiResponse<MediaVariant>>(`/media/${id}/${variant}`);
|
|
75
|
+
return res.data;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
url: (id: number, variant: string = 'original'): string => {
|
|
79
|
+
return `${this.baseUrl}/media/${id}/${variant}`;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
readonly search = {
|
|
84
|
+
query: async (q: string, options?: { type?: string; limit?: number }): Promise<{ data: Array<{ id: number; type: string; title: string; slug: string; description: string | null }>; meta: { total: number; query: string } }> => {
|
|
85
|
+
const params = new URLSearchParams({ q });
|
|
86
|
+
if (options?.type) params.set('type', options.type);
|
|
87
|
+
if (options?.limit) params.set('limit', String(options.limit));
|
|
88
|
+
return this.fetch(`/search?${params}`);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
readonly redirects = {
|
|
93
|
+
list: async (): Promise<Redirect[]> => {
|
|
94
|
+
const res = await this.fetch<ApiResponse<Redirect[]>>('/redirects');
|
|
95
|
+
return res.data;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
readonly config = {
|
|
100
|
+
get: async (): Promise<SiteConfig> => {
|
|
101
|
+
const res = await this.fetch<ApiResponse<SiteConfig>>('/config');
|
|
102
|
+
return res.data;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
readonly schemas = {
|
|
107
|
+
get: async (): Promise<Schemas> => {
|
|
108
|
+
const res = await this.fetch<ApiResponse<Schemas>>('/schemas');
|
|
109
|
+
return res.data;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
readonly trackingScripts = {
|
|
114
|
+
/** Get active tracking scripts, optionally filtered for a specific page slug. */
|
|
115
|
+
getForPage: async (slug?: string): Promise<TrackingScriptsData> => {
|
|
116
|
+
const qs = slug ? `?page=${encodeURIComponent(slug)}` : '';
|
|
117
|
+
const res = await this.fetch<ApiResponse<TrackingScriptsData>>(`/tracking-scripts${qs}`);
|
|
118
|
+
return res.data;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createClient(config: WollyConfig): WollyClient {
|
|
124
|
+
return new WollyClient(config);
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ResolvedBlock } from '../types.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
blocks: ResolvedBlock[];
|
|
6
|
+
region: string;
|
|
7
|
+
components: Record<string, any>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { blocks, region, components } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
{blocks.map((block, index) => {
|
|
14
|
+
const Component = components[block.block_type];
|
|
15
|
+
if (!Component) {
|
|
16
|
+
return <div class="wolly-block-unknown" data-type={block.block_type}>Unknown block type: {block.block_type}</div>;
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<Component
|
|
20
|
+
fields={block.fields}
|
|
21
|
+
block={{
|
|
22
|
+
id: block.id,
|
|
23
|
+
type: block.block_type,
|
|
24
|
+
title: block.title,
|
|
25
|
+
is_shared: block.is_shared,
|
|
26
|
+
}}
|
|
27
|
+
region={region}
|
|
28
|
+
position={index}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
})}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PreviewBlockRenderer — Like BlockRenderer but wraps each block in a
|
|
4
|
+
* clickable wrapper div for the admin inline-editing bridge.
|
|
5
|
+
* Only used in preview pages, not in production rendering.
|
|
6
|
+
*/
|
|
7
|
+
import type { ResolvedBlock } from '../types.js';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
blocks: ResolvedBlock[];
|
|
11
|
+
region: string;
|
|
12
|
+
components: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { blocks, region, components } = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{blocks.map((block, index) => {
|
|
19
|
+
const Component = components[block.block_type];
|
|
20
|
+
if (!Component) {
|
|
21
|
+
return <div class="wolly-block wolly-block-unknown" data-wolly-pb={block.id} data-wolly-region={region} data-wolly-type={block.block_type}>Unknown block type: {block.block_type}</div>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
class="wolly-block"
|
|
26
|
+
data-wolly-pb={block.id}
|
|
27
|
+
data-wolly-region={region}
|
|
28
|
+
data-wolly-type={block.block_type}
|
|
29
|
+
>
|
|
30
|
+
<Component
|
|
31
|
+
fields={block.fields}
|
|
32
|
+
block={{
|
|
33
|
+
id: block.id,
|
|
34
|
+
type: block.block_type,
|
|
35
|
+
title: block.title,
|
|
36
|
+
is_shared: block.is_shared,
|
|
37
|
+
}}
|
|
38
|
+
region={region}
|
|
39
|
+
position={index}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { renderRichText } from '../helpers/richtext.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
content: any;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { content, class: className } = Astro.props;
|
|
10
|
+
const html = renderRichText(content);
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div class:list={['rich-text', className]} set:html={html} />
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
src: string;
|
|
4
|
+
alt?: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
srcset?: string;
|
|
8
|
+
sizes?: string;
|
|
9
|
+
loading?: 'lazy' | 'eager';
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
src,
|
|
15
|
+
alt = '',
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
srcset,
|
|
19
|
+
sizes,
|
|
20
|
+
loading = 'lazy',
|
|
21
|
+
class: className,
|
|
22
|
+
} = Astro.props;
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<img
|
|
26
|
+
src={src}
|
|
27
|
+
alt={alt}
|
|
28
|
+
width={width}
|
|
29
|
+
height={height}
|
|
30
|
+
srcset={srcset}
|
|
31
|
+
sizes={sizes}
|
|
32
|
+
loading={loading}
|
|
33
|
+
class={className}
|
|
34
|
+
/>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MenuItem } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/** Get the href for a menu item */
|
|
4
|
+
export function getItemHref(item: MenuItem): string | null {
|
|
5
|
+
if (item.url) return item.url;
|
|
6
|
+
if (item.page_slug) return `/${item.page_slug}`;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Check if a menu item or any of its children match the current path */
|
|
11
|
+
export function isActive(item: MenuItem, currentPath: string): boolean {
|
|
12
|
+
const href = getItemHref(item);
|
|
13
|
+
const normalized = currentPath.replace(/\/+$/, '') || '/';
|
|
14
|
+
|
|
15
|
+
if (href) {
|
|
16
|
+
const normalizedHref = href.replace(/\/+$/, '') || '/';
|
|
17
|
+
if (normalizedHref === normalized) return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return item.children.some((child) => isActive(child, currentPath));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get the breadcrumb trail from a menu tree to the current path */
|
|
24
|
+
export function getBreadcrumbs(
|
|
25
|
+
items: MenuItem[],
|
|
26
|
+
currentPath: string,
|
|
27
|
+
): MenuItem[] {
|
|
28
|
+
const normalized = currentPath.replace(/\/+$/, '') || '/';
|
|
29
|
+
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
const href = getItemHref(item);
|
|
32
|
+
const normalizedHref = href ? href.replace(/\/+$/, '') || '/' : null;
|
|
33
|
+
|
|
34
|
+
if (normalizedHref === normalized) {
|
|
35
|
+
return [item];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (item.children.length > 0) {
|
|
39
|
+
const trail = getBreadcrumbs(item.children, currentPath);
|
|
40
|
+
if (trail.length > 0) {
|
|
41
|
+
return [item, ...trail];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get direct children of a menu item by its slug or URL */
|
|
50
|
+
export function getChildren(
|
|
51
|
+
items: MenuItem[],
|
|
52
|
+
identifier: string,
|
|
53
|
+
): MenuItem[] {
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const href = getItemHref(item);
|
|
56
|
+
if (href === identifier || href === `/${identifier}`) {
|
|
57
|
+
return item.children;
|
|
58
|
+
}
|
|
59
|
+
const found = getChildren(item.children, identifier);
|
|
60
|
+
if (found.length > 0) return found;
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Flatten a menu tree into a list */
|
|
66
|
+
export function flattenMenu(items: MenuItem[]): MenuItem[] {
|
|
67
|
+
const result: MenuItem[] = [];
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
result.push(item);
|
|
70
|
+
if (item.children.length > 0) {
|
|
71
|
+
result.push(...flattenMenu(item.children));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** TipTap JSON node types */
|
|
2
|
+
interface TipTapNode {
|
|
3
|
+
type: string;
|
|
4
|
+
content?: TipTapNode[];
|
|
5
|
+
text?: string;
|
|
6
|
+
attrs?: Record<string, unknown>;
|
|
7
|
+
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Escape HTML special characters */
|
|
11
|
+
function escapeHtml(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Render marks (bold, italic, links, etc.) around text */
|
|
20
|
+
function renderMarks(text: string, marks?: TipTapNode['marks']): string {
|
|
21
|
+
if (!marks || marks.length === 0) return escapeHtml(text);
|
|
22
|
+
|
|
23
|
+
let result = escapeHtml(text);
|
|
24
|
+
for (const mark of marks) {
|
|
25
|
+
switch (mark.type) {
|
|
26
|
+
case 'bold':
|
|
27
|
+
result = `<strong>${result}</strong>`;
|
|
28
|
+
break;
|
|
29
|
+
case 'italic':
|
|
30
|
+
result = `<em>${result}</em>`;
|
|
31
|
+
break;
|
|
32
|
+
case 'underline':
|
|
33
|
+
result = `<u>${result}</u>`;
|
|
34
|
+
break;
|
|
35
|
+
case 'strike':
|
|
36
|
+
result = `<s>${result}</s>`;
|
|
37
|
+
break;
|
|
38
|
+
case 'code':
|
|
39
|
+
result = `<code>${result}</code>`;
|
|
40
|
+
break;
|
|
41
|
+
case 'link': {
|
|
42
|
+
const href = escapeHtml(String(mark.attrs?.href ?? ''));
|
|
43
|
+
const target = mark.attrs?.target ? ` target="${escapeHtml(String(mark.attrs.target))}"` : '';
|
|
44
|
+
result = `<a href="${href}"${target}>${result}</a>`;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'subscript':
|
|
48
|
+
result = `<sub>${result}</sub>`;
|
|
49
|
+
break;
|
|
50
|
+
case 'superscript':
|
|
51
|
+
result = `<sup>${result}</sup>`;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Render a TipTap JSON node to HTML */
|
|
59
|
+
function renderNode(node: TipTapNode): string {
|
|
60
|
+
if (node.type === 'text') {
|
|
61
|
+
return renderMarks(node.text ?? '', node.marks);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const children = node.content?.map(renderNode).join('') ?? '';
|
|
65
|
+
|
|
66
|
+
switch (node.type) {
|
|
67
|
+
case 'doc':
|
|
68
|
+
return children;
|
|
69
|
+
case 'paragraph':
|
|
70
|
+
return `<p>${children}</p>`;
|
|
71
|
+
case 'heading': {
|
|
72
|
+
const level = Math.min(Math.max(Number(node.attrs?.level ?? 2), 1), 6);
|
|
73
|
+
return `<h${level}>${children}</h${level}>`;
|
|
74
|
+
}
|
|
75
|
+
case 'bulletList':
|
|
76
|
+
return `<ul>${children}</ul>`;
|
|
77
|
+
case 'orderedList':
|
|
78
|
+
return `<ol>${children}</ol>`;
|
|
79
|
+
case 'listItem':
|
|
80
|
+
return `<li>${children}</li>`;
|
|
81
|
+
case 'blockquote':
|
|
82
|
+
return `<blockquote>${children}</blockquote>`;
|
|
83
|
+
case 'codeBlock': {
|
|
84
|
+
const lang = node.attrs?.language ? ` class="language-${escapeHtml(String(node.attrs.language))}"` : '';
|
|
85
|
+
return `<pre><code${lang}>${children}</code></pre>`;
|
|
86
|
+
}
|
|
87
|
+
case 'horizontalRule':
|
|
88
|
+
return '<hr />';
|
|
89
|
+
case 'hardBreak':
|
|
90
|
+
return '<br />';
|
|
91
|
+
case 'image': {
|
|
92
|
+
const src = escapeHtml(String(node.attrs?.src ?? ''));
|
|
93
|
+
const alt = escapeHtml(String(node.attrs?.alt ?? ''));
|
|
94
|
+
const title = node.attrs?.title ? ` title="${escapeHtml(String(node.attrs.title))}"` : '';
|
|
95
|
+
return `<img src="${src}" alt="${alt}"${title} />`;
|
|
96
|
+
}
|
|
97
|
+
case 'table':
|
|
98
|
+
return `<table>${children}</table>`;
|
|
99
|
+
case 'tableRow':
|
|
100
|
+
return `<tr>${children}</tr>`;
|
|
101
|
+
case 'tableHeader':
|
|
102
|
+
return `<th>${children}</th>`;
|
|
103
|
+
case 'tableCell':
|
|
104
|
+
return `<td>${children}</td>`;
|
|
105
|
+
default:
|
|
106
|
+
return children;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Convert TipTap JSON document to HTML string */
|
|
111
|
+
export function renderRichText(doc: TipTapNode | null | undefined): string {
|
|
112
|
+
if (!doc || doc.type !== 'doc') return '';
|
|
113
|
+
return renderNode(doc);
|
|
114
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Page, SiteConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export interface SeoMeta {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
ogImage?: string;
|
|
7
|
+
canonicalUrl?: string;
|
|
8
|
+
robots?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate meta tag objects from a page's SEO fields.
|
|
13
|
+
* Use with Astro's <head> to render meta tags.
|
|
14
|
+
*/
|
|
15
|
+
export function getPageSeo(page: Page, siteConfig?: SiteConfig): SeoMeta {
|
|
16
|
+
const seo = page.seo;
|
|
17
|
+
const siteName = siteConfig?.siteName || 'WollyCMS';
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
title: seo?.meta_title || `${page.title} | ${siteName}`,
|
|
21
|
+
description: seo?.meta_description || undefined,
|
|
22
|
+
ogImage: seo?.og_image || undefined,
|
|
23
|
+
canonicalUrl: seo?.canonical_url || undefined,
|
|
24
|
+
robots: seo?.robots || undefined,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Article structured data (JSON-LD) */
|
|
29
|
+
export function articleJsonLd(page: Page, options: {
|
|
30
|
+
siteUrl: string;
|
|
31
|
+
siteName?: string;
|
|
32
|
+
authorName?: string;
|
|
33
|
+
ogImage?: string;
|
|
34
|
+
}): Record<string, unknown> {
|
|
35
|
+
const seo = page.seo;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
'@context': 'https://schema.org',
|
|
39
|
+
'@type': 'Article',
|
|
40
|
+
headline: seo?.meta_title || page.title,
|
|
41
|
+
description: seo?.meta_description || undefined,
|
|
42
|
+
image: seo?.og_image || options.ogImage || undefined,
|
|
43
|
+
datePublished: page.meta.published_at || page.meta.created_at,
|
|
44
|
+
dateModified: page.meta.updated_at,
|
|
45
|
+
url: `${options.siteUrl.replace(/\/$/, '')}/${page.slug}`,
|
|
46
|
+
publisher: {
|
|
47
|
+
'@type': 'Organization',
|
|
48
|
+
name: options.siteName || 'WollyCMS',
|
|
49
|
+
},
|
|
50
|
+
author: options.authorName ? {
|
|
51
|
+
'@type': 'Person',
|
|
52
|
+
name: options.authorName,
|
|
53
|
+
} : undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** WebPage structured data (JSON-LD) */
|
|
58
|
+
export function webPageJsonLd(page: Page, options: {
|
|
59
|
+
siteUrl: string;
|
|
60
|
+
siteName?: string;
|
|
61
|
+
}): Record<string, unknown> {
|
|
62
|
+
const seo = page.seo;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
'@context': 'https://schema.org',
|
|
66
|
+
'@type': 'WebPage',
|
|
67
|
+
name: seo?.meta_title || page.title,
|
|
68
|
+
description: seo?.meta_description || undefined,
|
|
69
|
+
url: `${options.siteUrl.replace(/\/$/, '')}/${page.slug}`,
|
|
70
|
+
datePublished: page.meta.published_at || page.meta.created_at,
|
|
71
|
+
dateModified: page.meta.updated_at,
|
|
72
|
+
isPartOf: {
|
|
73
|
+
'@type': 'WebSite',
|
|
74
|
+
name: options.siteName || 'WollyCMS',
|
|
75
|
+
url: options.siteUrl,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** BreadcrumbList structured data from menu hierarchy */
|
|
81
|
+
export function breadcrumbJsonLd(
|
|
82
|
+
breadcrumbs: Array<{ title: string; url: string }>,
|
|
83
|
+
siteUrl: string,
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
const baseUrl = siteUrl.replace(/\/$/, '');
|
|
86
|
+
return {
|
|
87
|
+
'@context': 'https://schema.org',
|
|
88
|
+
'@type': 'BreadcrumbList',
|
|
89
|
+
itemListElement: breadcrumbs.map((crumb, i) => ({
|
|
90
|
+
'@type': 'ListItem',
|
|
91
|
+
position: i + 1,
|
|
92
|
+
name: crumb.title,
|
|
93
|
+
item: crumb.url.startsWith('http') ? crumb.url : `${baseUrl}${crumb.url}`,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Organization structured data */
|
|
99
|
+
export function organizationJsonLd(config: SiteConfig, options: {
|
|
100
|
+
siteUrl: string;
|
|
101
|
+
logoUrl?: string;
|
|
102
|
+
}): Record<string, unknown> {
|
|
103
|
+
const sameAs: string[] = [];
|
|
104
|
+
if (config.social?.facebook) sameAs.push(config.social.facebook);
|
|
105
|
+
if (config.social?.twitter) sameAs.push(config.social.twitter);
|
|
106
|
+
if (config.social?.instagram) sameAs.push(config.social.instagram);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
'@context': 'https://schema.org',
|
|
110
|
+
'@type': 'Organization',
|
|
111
|
+
name: config.siteName,
|
|
112
|
+
url: options.siteUrl,
|
|
113
|
+
logo: options.logoUrl || undefined,
|
|
114
|
+
sameAs: sameAs.length > 0 ? sameAs : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Serialize JSON-LD to a script tag string for use in <head> */
|
|
119
|
+
export function jsonLdScript(data: Record<string, unknown>): string {
|
|
120
|
+
// Remove undefined values for cleaner output
|
|
121
|
+
const cleaned = JSON.parse(JSON.stringify(data));
|
|
122
|
+
return `<script type="application/ld+json">${JSON.stringify(cleaned)}</script>`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Tracking script entry from content API */
|
|
2
|
+
export interface TrackingScript {
|
|
3
|
+
id: number;
|
|
4
|
+
name: string;
|
|
5
|
+
code: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Tracking scripts grouped by position */
|
|
9
|
+
export interface TrackingScriptsData {
|
|
10
|
+
head: TrackingScript[];
|
|
11
|
+
body: TrackingScript[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render head tracking scripts as raw HTML for use in Astro's <head>.
|
|
16
|
+
* Each script is wrapped with an HTML comment for debuggability.
|
|
17
|
+
*/
|
|
18
|
+
export function renderHeadScripts(scripts: TrackingScriptsData): string {
|
|
19
|
+
if (!scripts.head.length) return '';
|
|
20
|
+
return scripts.head
|
|
21
|
+
.map((s) => `<!-- WollyCMS Tracking: ${s.name} -->\n${s.code}`)
|
|
22
|
+
.join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render body tracking scripts as raw HTML for use before </body>.
|
|
27
|
+
*/
|
|
28
|
+
export function renderBodyScripts(scripts: TrackingScriptsData): string {
|
|
29
|
+
if (!scripts.body.length) return '';
|
|
30
|
+
return scripts.body
|
|
31
|
+
.map((s) => `<!-- WollyCMS Tracking: ${s.name} -->\n${s.code}`)
|
|
32
|
+
.join('\n');
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { WollyClient, createClient } from './client.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export * as menuHelpers from './helpers/menu.js';
|
|
4
|
+
export { renderRichText } from './helpers/richtext.js';
|
|
5
|
+
export * as seoHelpers from './helpers/seo.js';
|
|
6
|
+
export * as trackingHelpers from './helpers/tracking.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** API response wrapper */
|
|
2
|
+
export interface ApiResponse<T> {
|
|
3
|
+
data: T;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Paginated list response */
|
|
7
|
+
export interface PaginatedResponse<T> {
|
|
8
|
+
data: T[];
|
|
9
|
+
meta: {
|
|
10
|
+
total: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
offset: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Page metadata */
|
|
17
|
+
export interface PageMeta {
|
|
18
|
+
created_at: string;
|
|
19
|
+
updated_at: string;
|
|
20
|
+
published_at: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Page summary (from list endpoint) */
|
|
24
|
+
/** Taxonomy term attached to a page */
|
|
25
|
+
export interface PageTerm {
|
|
26
|
+
taxonomy: string;
|
|
27
|
+
term: string;
|
|
28
|
+
weight: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PageSummary {
|
|
32
|
+
id: number;
|
|
33
|
+
type: string;
|
|
34
|
+
title: string;
|
|
35
|
+
slug: string;
|
|
36
|
+
status: 'draft' | 'published' | 'archived';
|
|
37
|
+
fields: Record<string, unknown>;
|
|
38
|
+
terms?: PageTerm[];
|
|
39
|
+
meta: PageMeta;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolved block in a page region */
|
|
43
|
+
export interface ResolvedBlock {
|
|
44
|
+
id: string;
|
|
45
|
+
block_type: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
is_shared?: boolean;
|
|
48
|
+
block_id?: number;
|
|
49
|
+
fields: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** SEO metadata for a page (matches content API snake_case keys) */
|
|
53
|
+
export interface PageSeo {
|
|
54
|
+
meta_title: string | null;
|
|
55
|
+
meta_description: string | null;
|
|
56
|
+
og_image: string | null;
|
|
57
|
+
canonical_url: string | null;
|
|
58
|
+
robots: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Full page with resolved regions */
|
|
62
|
+
export interface Page extends PageSummary {
|
|
63
|
+
regions: Record<string, ResolvedBlock[]>;
|
|
64
|
+
seo?: PageSeo;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Menu item (recursive tree) */
|
|
68
|
+
export interface MenuItem {
|
|
69
|
+
id: number;
|
|
70
|
+
title: string;
|
|
71
|
+
url: string | null;
|
|
72
|
+
page_slug: string | null;
|
|
73
|
+
target: string;
|
|
74
|
+
attributes: Record<string, unknown> | null;
|
|
75
|
+
children: MenuItem[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Menu */
|
|
79
|
+
export interface Menu {
|
|
80
|
+
id: number;
|
|
81
|
+
name: string;
|
|
82
|
+
slug: string;
|
|
83
|
+
items: MenuItem[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Taxonomy term */
|
|
87
|
+
export interface Term {
|
|
88
|
+
id: number;
|
|
89
|
+
name: string;
|
|
90
|
+
slug: string;
|
|
91
|
+
weight: number;
|
|
92
|
+
fields: Record<string, unknown> | null;
|
|
93
|
+
children?: Term[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Media info */
|
|
97
|
+
export interface MediaInfo {
|
|
98
|
+
id: number;
|
|
99
|
+
filename: string;
|
|
100
|
+
originalName: string;
|
|
101
|
+
mimeType: string;
|
|
102
|
+
size: number;
|
|
103
|
+
width: number | null;
|
|
104
|
+
height: number | null;
|
|
105
|
+
altText: string | null;
|
|
106
|
+
title: string | null;
|
|
107
|
+
variants: Record<string, string>;
|
|
108
|
+
metadata: Record<string, unknown> | null;
|
|
109
|
+
createdAt: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Media variant reference */
|
|
113
|
+
export interface MediaVariant {
|
|
114
|
+
id: number;
|
|
115
|
+
variant: string;
|
|
116
|
+
mimeType: string;
|
|
117
|
+
path: string;
|
|
118
|
+
width: number | null;
|
|
119
|
+
height: number | null;
|
|
120
|
+
altText: string | null;
|
|
121
|
+
title: string | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Redirect */
|
|
125
|
+
export interface Redirect {
|
|
126
|
+
id: number;
|
|
127
|
+
fromPath: string;
|
|
128
|
+
toPath: string;
|
|
129
|
+
statusCode: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Site config */
|
|
133
|
+
export interface SiteConfig {
|
|
134
|
+
siteName: string;
|
|
135
|
+
tagline: string;
|
|
136
|
+
logo: string | null;
|
|
137
|
+
footer: { text: string };
|
|
138
|
+
social: {
|
|
139
|
+
facebook: string | null;
|
|
140
|
+
twitter: string | null;
|
|
141
|
+
instagram: string | null;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Field schema definition */
|
|
146
|
+
export interface FieldDefinition {
|
|
147
|
+
name: string;
|
|
148
|
+
label: string;
|
|
149
|
+
type: string;
|
|
150
|
+
required?: boolean;
|
|
151
|
+
default?: unknown;
|
|
152
|
+
options?: Array<{ value: string; label: string }>;
|
|
153
|
+
fields?: FieldDefinition[];
|
|
154
|
+
min?: number;
|
|
155
|
+
max?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Content type schema */
|
|
159
|
+
export interface ContentTypeSchema {
|
|
160
|
+
id: number;
|
|
161
|
+
name: string;
|
|
162
|
+
slug: string;
|
|
163
|
+
description: string | null;
|
|
164
|
+
fieldsSchema: FieldDefinition[];
|
|
165
|
+
regions: Array<{
|
|
166
|
+
name: string;
|
|
167
|
+
label: string;
|
|
168
|
+
allowed_types: string[] | null;
|
|
169
|
+
}>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Block type schema */
|
|
173
|
+
export interface BlockTypeSchema {
|
|
174
|
+
id: number;
|
|
175
|
+
name: string;
|
|
176
|
+
slug: string;
|
|
177
|
+
description: string | null;
|
|
178
|
+
fieldsSchema: FieldDefinition[];
|
|
179
|
+
icon: string | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Schemas response */
|
|
183
|
+
export interface Schemas {
|
|
184
|
+
contentTypes: ContentTypeSchema[];
|
|
185
|
+
blockTypes: BlockTypeSchema[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Block component props */
|
|
189
|
+
export interface BlockProps {
|
|
190
|
+
fields: Record<string, unknown>;
|
|
191
|
+
block: {
|
|
192
|
+
id: string;
|
|
193
|
+
type: string;
|
|
194
|
+
title?: string;
|
|
195
|
+
is_shared?: boolean;
|
|
196
|
+
};
|
|
197
|
+
region: string;
|
|
198
|
+
position: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Client configuration */
|
|
202
|
+
export interface WollyConfig {
|
|
203
|
+
apiUrl: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Page list query parameters */
|
|
207
|
+
export interface PageListParams {
|
|
208
|
+
type?: string;
|
|
209
|
+
taxonomy?: string;
|
|
210
|
+
sort?: string;
|
|
211
|
+
limit?: number;
|
|
212
|
+
offset?: number;
|
|
213
|
+
status?: string;
|
|
214
|
+
}
|