@mezzanine-stack/astro-renderer 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.
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { BlogDocument } from "@mezzanine-stack/content-document";
3
+ import DocumentBody from "./DocumentBody.astro";
4
+
5
+ export interface Props {
6
+ doc: BlogDocument;
7
+ }
8
+
9
+ const { doc } = Astro.props;
10
+
11
+ const pubDate = doc.pubDate ? new Date(doc.pubDate) : undefined;
12
+ const hasValidPubDate = pubDate && !Number.isNaN(pubDate.getTime());
13
+
14
+ function formatJaDate(d: Date): string {
15
+ return d.toLocaleDateString("ja-JP", {
16
+ year: "numeric",
17
+ month: "long",
18
+ day: "numeric",
19
+ });
20
+ }
21
+ ---
22
+
23
+ <article>
24
+ <h1 class="mz-field-title">{doc.title}</h1>
25
+
26
+ {doc.description ? <p class="mz-field-description">{doc.description}</p> : null}
27
+
28
+ {doc.heroImage && doc.heroImage.src ? (
29
+ <img class="mz-field-hero-image" src={doc.heroImage.src} alt={doc.heroImage.alt ?? ""} loading="lazy" />
30
+ ) : null}
31
+
32
+ {hasValidPubDate ? <time class="mz-field-date">{formatJaDate(pubDate!)}</time> : null}
33
+
34
+ {doc.tags?.length ? (
35
+ <div class="mz-field-tags cluster">
36
+ {doc.tags.map((t) => (
37
+ <span class="mz-tag">{t}</span>
38
+ ))}
39
+ </div>
40
+ ) : null}
41
+
42
+ <DocumentBody nodes={doc.body} />
43
+ </article>
44
+
@@ -0,0 +1,200 @@
1
+ ---
2
+ import SectionComp from "@mezzanine-stack/ui/Section.astro";
3
+ import Pagination from "@mezzanine-stack/ui/Pagination.astro";
4
+ import { getCollection, type CollectionEntry } from "astro:content";
5
+
6
+ export interface Props {
7
+ /** Show/hide this component */
8
+ show?: boolean;
9
+ /**
10
+ * Single collection name. Use this OR `collectionNames`, not both.
11
+ * If provided, renders only this collection.
12
+ */
13
+ name?: string;
14
+ /**
15
+ * Multiple collection names to render. Ignored when `name` is provided.
16
+ */
17
+ collectionNames?: string[];
18
+ /**
19
+ * Map of collection name to the date field used for sorting.
20
+ * Example: { blog: 'pubDate', works: 'date', gallery: 'date' }
21
+ * Collections not in this map are sorted by 'date' as fallback.
22
+ */
23
+ dateFieldMap?: Record<string, string>;
24
+ /** Max items to display per collection (no pagination) */
25
+ maxItems?: number;
26
+ /** Sort direction */
27
+ sortOrder?: "asc" | "desc";
28
+ /** Current page number (1-based) */
29
+ page?: number;
30
+ /** Items per page. When set, enables pagination. */
31
+ perPage?: number;
32
+ /** Show "see more" link when item count exceeds maxItems */
33
+ showMoreLink?: boolean;
34
+ }
35
+
36
+ const {
37
+ show = true,
38
+ name,
39
+ collectionNames = [],
40
+ dateFieldMap = {},
41
+ maxItems,
42
+ sortOrder = "desc",
43
+ page = 1,
44
+ perPage,
45
+ showMoreLink = true,
46
+ } = Astro.props;
47
+
48
+ if (!show) return;
49
+
50
+ const targetNames: string[] = name ? [name] : collectionNames;
51
+
52
+ type CollectionData = {
53
+ collectionName: string;
54
+ content: CollectionEntry<any>[];
55
+ count: number;
56
+ displayCount: number;
57
+ totalPages: number;
58
+ currentPage: number;
59
+ pages: { url: string; pageNumber: number }[];
60
+ };
61
+
62
+ const allCollectionsData: CollectionData[] = await Promise.all(
63
+ targetNames.map(async (collectionName): Promise<CollectionData> => {
64
+ try {
65
+ const dateField = dateFieldMap[collectionName] ?? "date";
66
+ const content = await getCollection(collectionName as any, (item: CollectionEntry<any>) => !item.data.draft);
67
+
68
+ const sortedContent = content.sort((a: CollectionEntry<any>, b: CollectionEntry<any>) => {
69
+ const dateA = new Date((a.data[dateField] as Date | string | undefined) ?? 0);
70
+ const dateB = new Date((b.data[dateField] as Date | string | undefined) ?? 0);
71
+ return sortOrder === "desc"
72
+ ? dateB.getTime() - dateA.getTime()
73
+ : dateA.getTime() - dateB.getTime();
74
+ });
75
+
76
+ const effectivePerPage = perPage;
77
+ let paginatedContent = sortedContent;
78
+ let totalPages = 1;
79
+
80
+ if (effectivePerPage) {
81
+ totalPages = Math.max(1, Math.ceil(sortedContent.length / effectivePerPage));
82
+ const safeCurrentPage = Math.min(Math.max(page, 1), totalPages);
83
+ const start = (safeCurrentPage - 1) * effectivePerPage;
84
+ paginatedContent = sortedContent.slice(start, start + effectivePerPage);
85
+ }
86
+
87
+ const limitedContent =
88
+ !effectivePerPage && maxItems
89
+ ? sortedContent.slice(0, maxItems)
90
+ : paginatedContent;
91
+
92
+ const pages = Array.from({ length: totalPages }, (_, i) => ({
93
+ pageNumber: i + 1,
94
+ url: `/${collectionName}/${i === 0 ? "" : `page/${i + 1}/`}`,
95
+ }));
96
+
97
+ return {
98
+ collectionName,
99
+ content: limitedContent,
100
+ count: sortedContent.length,
101
+ displayCount: limitedContent.length,
102
+ totalPages,
103
+ currentPage: Math.min(Math.max(page, 1), totalPages),
104
+ pages,
105
+ };
106
+ } catch {
107
+ return {
108
+ collectionName,
109
+ content: [],
110
+ count: 0,
111
+ displayCount: 0,
112
+ totalPages: 1,
113
+ currentPage: 1,
114
+ pages: [{ url: `/${collectionName}/`, pageNumber: 1 }],
115
+ };
116
+ }
117
+ }),
118
+ );
119
+
120
+ const collectionsWithContent = allCollectionsData.filter((c) => c.count > 0);
121
+ ---
122
+
123
+ {collectionsWithContent.length > 0 ? (
124
+ collectionsWithContent.map((collection) => (
125
+ <SectionComp
126
+ title={collection.collectionName}
127
+ lead={
128
+ perPage
129
+ ? `${collection.count} 件 (ページ ${collection.currentPage}/${collection.totalPages})`
130
+ : maxItems && collection.count > maxItems
131
+ ? `${collection.displayCount} 件 (全 ${collection.count} 件)`
132
+ : `${collection.count} 件`
133
+ }
134
+ class="stack"
135
+ >
136
+ <div class="grid grid-3 grid-md-1">
137
+ {collection.content.map((item) => (
138
+ <article class="mz-card stack">
139
+ <h3 class="content-title">
140
+ <a href={`/${collection.collectionName}/${item.slug}/`}>
141
+ {(item.data.title as string) || (item.data.displayName as string) || "タイトルなし"}
142
+ </a>
143
+ </h3>
144
+
145
+ {(item.data.date || item.data.pubDate) && (
146
+ <time
147
+ datetime={(item.data.date as Date || item.data.pubDate as Date).toISOString()}
148
+ style="font-size:var(--text-sm);opacity:.8"
149
+ >
150
+ {(item.data.date as Date || item.data.pubDate as Date).toLocaleDateString("ja-JP", {
151
+ year: "numeric",
152
+ month: "long",
153
+ day: "numeric",
154
+ })}
155
+ </time>
156
+ )}
157
+
158
+ {item.data.tags && (item.data.tags as string[]).length > 0 && (
159
+ <div class="cluster">
160
+ {(item.data.tags as string[]).map((tag) => (
161
+ <span class="mz-tag">{tag}</span>
162
+ ))}
163
+ </div>
164
+ )}
165
+ </article>
166
+ ))}
167
+ </div>
168
+
169
+ {perPage ? (
170
+ collection.totalPages > 1 && (
171
+ <Pagination pages={collection.pages} currentPage={collection.currentPage} />
172
+ )
173
+ ) : (
174
+ maxItems && collection.count > maxItems && showMoreLink && (
175
+ <p class="more-link">
176
+ <a href={`/${collection.collectionName}/`} class="link-reset">もっと見る →</a>
177
+ </p>
178
+ )
179
+ )}
180
+ </SectionComp>
181
+ ))
182
+ ) : (
183
+ <SectionComp>
184
+ <p style="opacity:.8;font-style:italic">まだコンテンツがありません。</p>
185
+ </SectionComp>
186
+ )}
187
+
188
+ <style>
189
+ .link-reset {
190
+ color: inherit;
191
+ text-decoration: none;
192
+ }
193
+ .link-reset:hover {
194
+ color: var(--color-primary);
195
+ }
196
+ section.stack + section.stack {
197
+ border-top: 1px solid var(--color-border);
198
+ padding-top: var(--space-6);
199
+ }
200
+ </style>
@@ -0,0 +1,144 @@
1
+ ---
2
+ import type { MezzFolderCollection } from "@mezzanine-stack/content-schema";
3
+ import FieldRenderer from "./FieldRenderer.astro";
4
+
5
+ export interface Props {
6
+ /** CollectionEntry from astro:content */
7
+ content: {
8
+ data: Record<string, unknown>;
9
+ slug: string;
10
+ };
11
+ /**
12
+ * Schema definition for the collection.
13
+ * When provided, fields are rendered in declaration order.
14
+ * When omitted, falls back to Object.keys(content.data).
15
+ */
16
+ collection?: MezzFolderCollection;
17
+ /**
18
+ * Collection name used to generate the back link (e.g. "blog").
19
+ * When omitted, no back link is rendered.
20
+ */
21
+ collectionName?: string;
22
+ }
23
+
24
+ const { content, collection, collectionName } = Astro.props;
25
+ const data = content.data;
26
+
27
+ // Fields that are not rendered via FieldRenderer (handled by the slot / layout)
28
+ const SKIP_FIELDS = new Set(["draft"]);
29
+
30
+ const displayFields = collection
31
+ ? collection.fields.filter((f) => !SKIP_FIELDS.has(f.name))
32
+ : Object.keys(data)
33
+ .filter((k) => !SKIP_FIELDS.has(k))
34
+ .map((k) => ({ name: k, label: k, type: "text" as const }));
35
+ ---
36
+
37
+ <div class="collection-template" data-collection={collectionName}>
38
+ {displayFields.map((field) => {
39
+ const val = data[field.name];
40
+ if (val === null || val === undefined) return null;
41
+ return <FieldRenderer field={field as any} value={val} />;
42
+ })}
43
+
44
+ <div class="content-body">
45
+ <slot />
46
+ </div>
47
+
48
+ {collectionName && (
49
+ <p class="back-link">
50
+ <a href={`/${collectionName}/`}>← {collectionName} 一覧へ</a>
51
+ </p>
52
+ )}
53
+ </div>
54
+
55
+ <style>
56
+ .collection-template {
57
+ max-width: 800px;
58
+ margin: 0 auto;
59
+ padding: 2rem;
60
+ }
61
+
62
+ .content-body {
63
+ margin-top: 2rem;
64
+ margin-bottom: 2rem;
65
+ }
66
+
67
+ .content-body :global(h1) {
68
+ font-size: var(--text-xl, 1.75rem);
69
+ font-weight: 700;
70
+ margin-top: 2rem;
71
+ margin-bottom: 1rem;
72
+ line-height: 1.3;
73
+ }
74
+
75
+ .content-body :global(h2) {
76
+ font-size: var(--text-lg, 1.375rem);
77
+ font-weight: 600;
78
+ margin-top: 1.75rem;
79
+ margin-bottom: 0.75rem;
80
+ }
81
+
82
+ .content-body :global(h3) {
83
+ font-size: var(--text-md, 1.125rem);
84
+ font-weight: 600;
85
+ margin-top: 1.5rem;
86
+ margin-bottom: 0.5rem;
87
+ }
88
+
89
+ .content-body :global(p) {
90
+ margin-bottom: 1rem;
91
+ line-height: var(--leading, 1.7);
92
+ }
93
+
94
+ .content-body :global(ul, ol) {
95
+ margin: 1rem 0;
96
+ padding-left: 1.5rem;
97
+ }
98
+
99
+ .content-body :global(li) {
100
+ margin-bottom: 0.5rem;
101
+ }
102
+
103
+ .content-body :global(blockquote) {
104
+ border-left: 4px solid var(--color-primary, #6366f1);
105
+ margin: 1.5rem 0;
106
+ padding: 1rem 1.5rem;
107
+ font-style: italic;
108
+ }
109
+
110
+ .content-body :global(code) {
111
+ font-family: var(--code-font, monospace);
112
+ font-size: 0.9em;
113
+ padding: 0.2em 0.4em;
114
+ }
115
+
116
+ .content-body :global(pre) {
117
+ padding: 1rem;
118
+ margin: 1.5rem 0;
119
+ overflow-x: auto;
120
+ border-radius: var(--radius-md, 0.5rem);
121
+ }
122
+
123
+ .content-body :global(img) {
124
+ max-width: 100%;
125
+ height: auto;
126
+ border-radius: var(--radius-md, 0.5rem);
127
+ margin: 1.5rem 0;
128
+ }
129
+
130
+ .back-link {
131
+ margin-top: 2rem;
132
+ padding-top: 1rem;
133
+ border-top: 1px solid var(--color-border, #e5e7eb);
134
+ }
135
+
136
+ .back-link a {
137
+ color: inherit;
138
+ text-decoration: none;
139
+ }
140
+
141
+ .back-link a:hover {
142
+ color: var(--color-primary, #6366f1);
143
+ }
144
+ </style>
@@ -0,0 +1,59 @@
1
+ ---
2
+ import type { BodyNode } from "@mezzanine-stack/content-document";
3
+ import DocumentInline from "./DocumentInline.astro";
4
+
5
+ export interface Props {
6
+ nodes: BodyNode[];
7
+ }
8
+
9
+ const { nodes } = Astro.props;
10
+ ---
11
+
12
+ <div class="mz-field-body">
13
+ {
14
+ nodes.map((node) => (
15
+ <>
16
+ {node.type === "heading" ? (
17
+ node.level === 1 ? (
18
+ <h1>{node.text}</h1>
19
+ ) : node.level === 2 ? (
20
+ <h2>{node.text}</h2>
21
+ ) : node.level === 3 ? (
22
+ <h3>{node.text}</h3>
23
+ ) : (
24
+ <h4>{node.text}</h4>
25
+ )
26
+ ) : null}
27
+ {node.type === "paragraph" ? (
28
+ <p>{node.children.map((c) => <DocumentInline node={c} />)}</p>
29
+ ) : null}
30
+ {node.type === "image" ? (
31
+ <img src={node.src} alt={node.alt ?? ""} loading="lazy" />
32
+ ) : null}
33
+ {node.type === "quote" ? (
34
+ <blockquote>{node.children.map((c) => <DocumentInline node={c} />)}</blockquote>
35
+ ) : null}
36
+ {node.type === "code" ? (
37
+ <pre>
38
+ <code>{node.code}</code>
39
+ </pre>
40
+ ) : null}
41
+ {node.type === "list" ? (
42
+ node.ordered ? (
43
+ <ol>
44
+ {node.items.map((item) => (
45
+ <li>{item.map((c) => <DocumentInline node={c} />)}</li>
46
+ ))}
47
+ </ol>
48
+ ) : (
49
+ <ul>
50
+ {node.items.map((item) => (
51
+ <li>{item.map((c) => <DocumentInline node={c} />)}</li>
52
+ ))}
53
+ </ul>
54
+ )
55
+ ) : null}
56
+ </>
57
+ ))
58
+ }
59
+ </div>
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { InlineNode } from "@mezzanine-stack/content-document";
3
+ import DocumentInline from "./DocumentInline.astro";
4
+
5
+ export interface Props {
6
+ node: InlineNode;
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ ---
11
+
12
+ {
13
+ node.type === "text" ? (
14
+ <>
15
+ {node.text.split("\n").map((part, i, arr) => (
16
+ <>
17
+ {part}
18
+ {i < arr.length - 1 ? <br /> : null}
19
+ </>
20
+ ))}
21
+ </>
22
+ ) : null
23
+ }
24
+ {node.type === "strong" ? (
25
+ <strong>{node.children.map((c) => <DocumentInline node={c} />)}</strong>
26
+ ) : null}
27
+ {node.type === "em" ? <em>{node.children.map((c) => <DocumentInline node={c} />)}</em> : null}
28
+ {node.type === "link" ? (
29
+ <a href={node.href} target="_blank" rel="noopener noreferrer">
30
+ {node.children.map((c) => <DocumentInline node={c} />)}
31
+ </a>
32
+ ) : null}
@@ -0,0 +1,136 @@
1
+ ---
2
+ import type { MezzField, MezzListField, MezzSelectField } from "@mezzanine-stack/content-schema";
3
+
4
+ export interface Props {
5
+ field: MezzField;
6
+ value: unknown;
7
+ }
8
+
9
+ const { field, value } = Astro.props;
10
+
11
+ // Empty value — render nothing
12
+ if (
13
+ value === null ||
14
+ value === undefined ||
15
+ (Array.isArray(value) && value.length === 0) ||
16
+ (typeof value === "string" && value.trim() === "")
17
+ ) {
18
+ return;
19
+ }
20
+
21
+ // Helpers derived in frontmatter to avoid JSX parsing issues with generics
22
+ const isTitleField = field.name === "title" || field.name === "name";
23
+ const isDescField = field.name === "description" || field.name === "bio";
24
+ const isBodyField = field.name === "body" || field.name === "content";
25
+
26
+ const isMultiSelect =
27
+ field.type === "select" && (field as MezzSelectField).multiple === true;
28
+
29
+ const isObjectList =
30
+ field.type === "list" &&
31
+ !!((field as MezzListField).fields) &&
32
+ ((field as MezzListField).fields?.length ?? 0) > 0;
33
+
34
+ const dateValue = field.type === "date" ? (value as Date) : undefined;
35
+ const selectValues = isMultiSelect ? (value as string[]) : undefined;
36
+ const stringListValues =
37
+ field.type === "list" && !isObjectList ? (value as string[]) : undefined;
38
+ const objectListValues = isObjectList
39
+ ? (value as Record<string, unknown>[])
40
+ : undefined;
41
+ const objectValues =
42
+ field.type === "object" ? (value as Record<string, unknown>) : undefined;
43
+ ---
44
+
45
+ {field.type === "text" ? (
46
+ isTitleField ? (
47
+ <h1 class="mz-field-title">{value as string}</h1>
48
+ ) : isDescField ? (
49
+ <p class="mz-field-description">{value as string}</p>
50
+ ) : isBodyField ? (
51
+ <div class="mz-field-body">{value as string}</div>
52
+ ) : (
53
+ <div class="mz-field-text">
54
+ <span class="mz-field-label">{field.label}:</span>
55
+ <span class="mz-field-value">{value as string}</span>
56
+ </div>
57
+ )
58
+ ) : field.type === "rich-text" ? (
59
+ <div class="mz-field-richtext prose" set:html={value as string} />
60
+ ) : field.type === "date" && dateValue ? (
61
+ <time datetime={dateValue.toISOString()} class="mz-field-date">
62
+ {dateValue.toLocaleDateString("ja-JP", {
63
+ year: "numeric",
64
+ month: "long",
65
+ day: "numeric",
66
+ })}
67
+ </time>
68
+ ) : field.type === "boolean" ? (
69
+ (value as boolean) && (
70
+ <span class="mz-field-boolean mz-tag">{field.label}</span>
71
+ )
72
+ ) : field.type === "image" ? (
73
+ <img
74
+ src={value as string}
75
+ alt={field.label}
76
+ class="mz-field-image"
77
+ loading="lazy"
78
+ />
79
+ ) : field.type === "file" ? (
80
+ <a
81
+ href={value as string}
82
+ class="mz-field-file"
83
+ target="_blank"
84
+ rel="noopener noreferrer"
85
+ >
86
+ {field.label}
87
+ </a>
88
+ ) : field.type === "select" && isMultiSelect && selectValues ? (
89
+ <div class="mz-field-tags cluster">
90
+ {selectValues.map((tag) => (
91
+ <span class="mz-tag">{tag}</span>
92
+ ))}
93
+ </div>
94
+ ) : field.type === "select" && !isMultiSelect ? (
95
+ <div class="mz-field-select">
96
+ <span class="mz-field-label">{field.label}:</span>
97
+ <span class="mz-field-value">{value as string}</span>
98
+ </div>
99
+ ) : field.type === "list" && isObjectList && objectListValues ? (
100
+ <ul class="mz-field-list-objects stack">
101
+ {objectListValues.map((item) => (
102
+ <li class="mz-field-list-item">
103
+ {Object.entries(item).map(([k, v]) => (
104
+ v != null && String(v).trim() !== "" && (
105
+ <div class="mz-field-kv">
106
+ <span class="mz-field-label">{k}:</span>
107
+ <span class="mz-field-value">{String(v)}</span>
108
+ </div>
109
+ )
110
+ ))}
111
+ </li>
112
+ ))}
113
+ </ul>
114
+ ) : field.type === "list" && !isObjectList && stringListValues ? (
115
+ <ul class="mz-field-list cluster">
116
+ {stringListValues.map((item) => (
117
+ <li class="mz-tag">{item}</li>
118
+ ))}
119
+ </ul>
120
+ ) : field.type === "object" && objectValues ? (
121
+ <div class="mz-field-object">
122
+ {Object.entries(objectValues).map(([k, v]) => (
123
+ v != null && String(v).trim() !== "" && (
124
+ <div class="mz-field-kv">
125
+ <span class="mz-field-label">{k}:</span>
126
+ <span class="mz-field-value">{String(v)}</span>
127
+ </div>
128
+ )
129
+ ))}
130
+ </div>
131
+ ) : field.type === "relation" ? (
132
+ <div class="mz-field-relation">
133
+ <span class="mz-field-label">{field.label}:</span>
134
+ <span class="mz-field-value">{value as string}</span>
135
+ </div>
136
+ ) : null}
package/Hero.astro ADDED
@@ -0,0 +1,54 @@
1
+ ---
2
+ interface Props {
3
+ siteTitle: string;
4
+ showTitle?: boolean;
5
+ description?: string;
6
+ backgroundImage?: string;
7
+ backgroundSize?: 'center' | 'full';
8
+ }
9
+ const {
10
+ siteTitle,
11
+ showTitle = true,
12
+ description,
13
+ backgroundImage,
14
+ backgroundSize = 'full',
15
+ }: Props = Astro.props;
16
+
17
+ const style =
18
+ backgroundImage
19
+ ? `background-image:url(${backgroundImage});background-position:center;background-repeat:no-repeat;background-size:${backgroundSize === 'full' ? '100% 100%' : 'auto'};`
20
+ : '';
21
+ ---
22
+ <section class="hero" style={style}>
23
+ {showTitle && <h1>{siteTitle}</h1>}
24
+ {description && <p>{description}</p>}
25
+ </section>
26
+ <style>
27
+ .hero {
28
+ width: 100%;
29
+ min-height: 60vh;
30
+ display: flex;
31
+ flex-direction: column;
32
+ justify-content: center;
33
+ align-items: center;
34
+ text-align: center;
35
+ padding: 2rem 1rem;
36
+ color: #fff;
37
+ }
38
+ .hero h1 {
39
+ margin: 0;
40
+ font-size: 2rem;
41
+ }
42
+ .hero p {
43
+ margin-top: 1rem;
44
+ }
45
+ @media (min-width: 768px) {
46
+ .hero {
47
+ min-height: 70vh;
48
+ padding: 4rem;
49
+ }
50
+ .hero h1 {
51
+ font-size: 3rem;
52
+ }
53
+ }
54
+ </style>
@@ -0,0 +1,122 @@
1
+ ---
2
+ import SectionComp from "@mezzanine-stack/ui/Section.astro";
3
+ import { getCollection, type CollectionEntry } from "astro:content";
4
+
5
+ export interface Props {
6
+ /** Collection name to list */
7
+ name: string;
8
+ /**
9
+ * Front-matter field name used for date sorting.
10
+ * Example: 'pubDate' for blog, 'date' for works/gallery.
11
+ */
12
+ dateField?: string;
13
+ /** Number of items to load per batch */
14
+ batchSize?: number;
15
+ /** Sort direction */
16
+ sortOrder?: "asc" | "desc";
17
+ }
18
+
19
+ const {
20
+ name,
21
+ dateField = "date",
22
+ batchSize = 10,
23
+ sortOrder = "desc",
24
+ } = Astro.props;
25
+
26
+ const content = await getCollection(name as any, (item: CollectionEntry<any>) => !item.data.draft);
27
+
28
+ const sortedContent = content.sort((a: CollectionEntry<any>, b: CollectionEntry<any>) => {
29
+ const dateA = new Date((a.data[dateField] as Date | string | undefined) ?? 0);
30
+ const dateB = new Date((b.data[dateField] as Date | string | undefined) ?? 0);
31
+ return sortOrder === "desc"
32
+ ? dateB.getTime() - dateA.getTime()
33
+ : dateA.getTime() - dateB.getTime();
34
+ });
35
+
36
+ type ClientItem = {
37
+ slug: string;
38
+ title: string;
39
+ date: string | null;
40
+ tags: string[];
41
+ };
42
+
43
+ const items: ClientItem[] = sortedContent.map(
44
+ (item: CollectionEntry<any>): ClientItem => ({
45
+ slug: item.slug,
46
+ title: (item.data.title as string) || (item.data.displayName as string) || "タイトルなし",
47
+ date: item.data[dateField]
48
+ ? (item.data[dateField] as Date).toISOString()
49
+ : null,
50
+ tags: (item.data.tags as string[]) || [],
51
+ }),
52
+ );
53
+
54
+ const initialItems = items.slice(0, batchSize);
55
+ ---
56
+
57
+ <SectionComp title={name} lead={`${items.length} 件`} class="stack">
58
+ <div id={`list-${name}`} class="grid grid-3 grid-md-1">
59
+ {initialItems.map((item) => (
60
+ <article class="mz-card stack" style={`view-transition-name: card-${item.slug}`}>
61
+ <h3 class="content-title">
62
+ <a href={`/${name}/${item.slug}/`}>{item.title}</a>
63
+ </h3>
64
+ {item.date && (
65
+ <time datetime={item.date} style="font-size:var(--text-sm);opacity:.8">
66
+ {new Date(item.date).toLocaleDateString("ja-JP", {
67
+ year: "numeric",
68
+ month: "long",
69
+ day: "numeric",
70
+ })}
71
+ </time>
72
+ )}
73
+ {item.tags.length > 0 && (
74
+ <div class="cluster">
75
+ {item.tags.map((tag: string) => (
76
+ <span class="mz-tag">{tag}</span>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </article>
81
+ ))}
82
+ </div>
83
+ <div id={`sentinel-${name}`}></div>
84
+ <script
85
+ type="module"
86
+ define:vars={{
87
+ collectionName: name,
88
+ allItems: items,
89
+ batchSize,
90
+ initialIndex: initialItems.length,
91
+ }}
92
+ >
93
+ const listEl = document.getElementById(`list-${collectionName}`);
94
+ const sentinel = document.getElementById(`sentinel-${collectionName}`);
95
+ let index = initialIndex;
96
+
97
+ function render(item) {
98
+ const article = document.createElement("article");
99
+ article.className = "mz-card stack";
100
+ article.style.viewTransitionName = `card-${item.slug}`;
101
+ article.innerHTML = `
102
+ <h3 class="content-title"><a href="/${collectionName}/${item.slug}/">${item.title}</a></h3>
103
+ ${item.date ? `<time datetime="${item.date}" style="font-size:var(--text-sm);opacity:.8">${new Date(item.date).toLocaleDateString("ja-JP", { year: "numeric", month: "long", day: "numeric" })}</time>` : ""}
104
+ ${item.tags.length ? `<div class="cluster">${item.tags.map((t) => `<span class='mz-tag'>${t}</span>`).join("")}</div>` : ""}
105
+ `;
106
+ listEl.appendChild(article);
107
+ }
108
+
109
+ function loadMore() {
110
+ const nextItems = allItems.slice(index, index + batchSize);
111
+ nextItems.forEach(render);
112
+ index += nextItems.length;
113
+ if (index >= allItems.length) observer.disconnect();
114
+ }
115
+
116
+ const observer = new IntersectionObserver((entries) => {
117
+ if (entries[0].isIntersecting) loadMore();
118
+ });
119
+
120
+ observer.observe(sentinel);
121
+ </script>
122
+ </SectionComp>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import type { MarkdownPageDocument } from "@mezzanine-stack/content-document";
3
+ import DocumentBody from "./DocumentBody.astro";
4
+
5
+ export interface Props {
6
+ doc: MarkdownPageDocument;
7
+ }
8
+
9
+ const { doc } = Astro.props;
10
+ ---
11
+
12
+ <article>
13
+ <h1 class="mz-field-title">{doc.title}</h1>
14
+ {doc.description ? <p class="mz-field-description">{doc.description}</p> : null}
15
+ <DocumentBody nodes={doc.body} />
16
+ </article>
17
+
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # @mezzanine-stack/astro-renderer
2
+
3
+ Mezzanine Platform の Astro 描画層 headless component 群。
4
+ `@mezzanine-stack/content-schema` の `MezzFolderCollection` / `MezzField` を props として受け取り、コンテンツを描画する。
5
+
6
+ ## 設計方針
7
+
8
+ - **props 駆動**: app 固有ファイル (`config-generated.js`, `template-utils.js` など) への相対 import を持たない。
9
+ - **schema 正本**: フィールド定義は `@mezzanine-stack/content-schema` の DSL 型 (`MezzField`) が正本。カスタム fieldType 文字列は使わない。
10
+ - **headless**: 標準的な HTML 要素と CSS クラスを出力するが、スタイルの責務はホスト app の CSS に委ねる。
11
+
12
+ ## 公開 API
13
+
14
+ | コンポーネント | 用途 |
15
+ |---|---|
16
+ | `CollectionList.astro` | folder collection の一覧をページネーション付きで表示 |
17
+ | `InfiniteCollectionList.astro` | IntersectionObserver による無限スクロール一覧 |
18
+ | `CollectionTemplate.astro` | コレクションエントリの詳細表示(フィールド順序制御 + slot) |
19
+ | `FieldRenderer.astro` | `MezzField` 型ごとの描画分岐 |
20
+ | `WorkCard.astro` | `works` コレクション専用カード |
21
+ | `Hero.astro` | サイトトップ用 hero セクション |
22
+
23
+ ## CollectionList.astro
24
+
25
+ ```astro
26
+ ---
27
+ import CollectionList from "@mezzanine-stack/astro-renderer/CollectionList.astro";
28
+ ---
29
+
30
+ <!-- 単一コレクション -->
31
+ <CollectionList
32
+ name="blog"
33
+ dateFieldMap={{ blog: "pubDate" }}
34
+ maxItems={5}
35
+ />
36
+
37
+ <!-- 複数コレクションを一括表示 -->
38
+ <CollectionList
39
+ collectionNames={["works", "blog", "gallery"]}
40
+ dateFieldMap={{ works: "date", blog: "pubDate", gallery: "date" }}
41
+ maxItems={3}
42
+ />
43
+ ```
44
+
45
+ ### Props
46
+
47
+ | Prop | 型 | デフォルト | 説明 |
48
+ |---|---|---|---|
49
+ | `name` | `string` | — | 単一コレクション名。`collectionNames` と排他 |
50
+ | `collectionNames` | `string[]` | `[]` | 複数コレクション名。`name` が優先される |
51
+ | `dateFieldMap` | `Record<string, string>` | `{}` | コレクション名 → 日付フィールド名のマップ |
52
+ | `maxItems` | `number` | — | コレクションあたりの最大表示件数 |
53
+ | `sortOrder` | `'asc' \| 'desc'` | `'desc'` | 日付ソート順 |
54
+ | `page` | `number` | `1` | ページ番号 (1 始まり) |
55
+ | `perPage` | `number` | — | 1 ページあたりの件数(設定するとページネーション有効) |
56
+ | `showMoreLink` | `boolean` | `true` | maxItems 超過時の「もっと見る」リンク表示 |
57
+
58
+ ## InfiniteCollectionList.astro
59
+
60
+ ```astro
61
+ <InfiniteCollectionList name="blog" dateField="pubDate" batchSize={10} />
62
+ ```
63
+
64
+ ### Props
65
+
66
+ | Prop | 型 | デフォルト | 説明 |
67
+ |---|---|---|---|
68
+ | `name` | `string` | — | コレクション名 (必須) |
69
+ | `dateField` | `string` | `'date'` | 日付フィールド名 |
70
+ | `batchSize` | `number` | `10` | 一度に追加読み込みする件数 |
71
+ | `sortOrder` | `'asc' \| 'desc'` | `'desc'` | 日付ソート順 |
72
+
73
+ ## CollectionTemplate.astro
74
+
75
+ ```astro
76
+ ---
77
+ import { getCollection } from "astro:content";
78
+ import { blogCollection } from "@mezzanine-stack/content-schema";
79
+ import CollectionTemplate from "@mezzanine-stack/astro-renderer/CollectionTemplate.astro";
80
+
81
+ const entry = ...; // getCollection から取得した entry
82
+ const { Content } = await entry.render();
83
+ ---
84
+
85
+ <CollectionTemplate content={entry} collection={blogCollection} collectionName="blog">
86
+ <Content />
87
+ </CollectionTemplate>
88
+ ```
89
+
90
+ ### Props
91
+
92
+ | Prop | 型 | デフォルト | 説明 |
93
+ |---|---|---|---|
94
+ | `content` | `CollectionEntry` | — | Astro content collection entry (必須) |
95
+ | `collection` | `MezzFolderCollection` | — | スキーマ定義。フィールド表示順序に使用 |
96
+ | `collectionName` | `string` | — | 一覧ページへの戻りリンクに使用 |
97
+
98
+ ## FieldRenderer.astro
99
+
100
+ ```astro
101
+ ---
102
+ import type { MezzField } from "@mezzanine-stack/content-schema";
103
+ import FieldRenderer from "@mezzanine-stack/astro-renderer/FieldRenderer.astro";
104
+
105
+ const field: MezzField = { type: "date", name: "pubDate", label: "公開日" };
106
+ const value = new Date("2026-01-01");
107
+ ---
108
+
109
+ <FieldRenderer field={field} value={value} />
110
+ ```
111
+
112
+ ### Props
113
+
114
+ | Prop | 型 | 説明 |
115
+ |---|---|---|
116
+ | `field` | `MezzField` | `@mezzanine-stack/content-schema` の DSL フィールド定義 |
117
+ | `value` | `unknown` | フィールドの値 |
118
+
119
+ ## WorkCard.astro
120
+
121
+ ```astro
122
+ ---
123
+ import { getCollection } from "astro:content";
124
+ import WorkCard from "@mezzanine-stack/astro-renderer/WorkCard.astro";
125
+
126
+ const works = await getCollection("works");
127
+ ---
128
+
129
+ {works.map((work) => <WorkCard work={work} />)}
130
+ ```
131
+
132
+ `works` コレクションのフィールド名: `title`, `date`, `thumbnail`, `tags`
133
+
134
+ ## 注意事項
135
+
136
+ - このパッケージは `apps/site-starter-astro` 側の collection metadata glue (`src/lib/content/collection-derived.ts`) と組み合わせて使う前提で設計されている。
137
+ - `@mezzanine-stack/ui/Section.astro` と `@mezzanine-stack/ui/Pagination.astro` を内部で使用しているため、ホスト app に `@mezzanine-stack/ui` が必要。
package/WorkCard.astro ADDED
@@ -0,0 +1,47 @@
1
+ ---
2
+ import type { CollectionEntry } from "astro:content";
3
+
4
+ export interface Props {
5
+ work: CollectionEntry<"works">;
6
+ /** 一覧カードのリンク先プレフィックス(末尾 `/`)。既定は `/works/` */
7
+ collectionBase?: string;
8
+ }
9
+
10
+ const { work, collectionBase = "/works/" } = Astro.props;
11
+ const data = work.data;
12
+ const base = collectionBase.endsWith("/") ? collectionBase : `${collectionBase}/`;
13
+ ---
14
+
15
+ <a class="mz-card" href={`${base}${work.slug}/`}>
16
+ {data.thumbnail && (
17
+ <img
18
+ src={data.thumbnail as string}
19
+ alt={data.title as string}
20
+ loading="lazy"
21
+ class="work-thumbnail"
22
+ />
23
+ )}
24
+ <h3 style="margin:8px 0 4px">{data.title as string}</h3>
25
+ {data.date && (
26
+ <time datetime={(data.date as Date).toISOString()}>
27
+ {(data.date as Date).toLocaleDateString("ja-JP")}
28
+ </time>
29
+ )}
30
+ {data.tags && (data.tags as string[]).length > 0 && (
31
+ <div class="cluster" style="margin-top:8px">
32
+ {(data.tags as string[]).map((t) => (
33
+ <span class="mz-tag">{t}</span>
34
+ ))}
35
+ </div>
36
+ )}
37
+ </a>
38
+
39
+ <style>
40
+ .work-thumbnail {
41
+ width: 100%;
42
+ aspect-ratio: 16 / 9;
43
+ object-fit: cover;
44
+ border-radius: var(--radius-md, 0.5rem);
45
+ margin-bottom: 0.5rem;
46
+ }
47
+ </style>
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mezzanine-stack/astro-renderer",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "files": [
10
+ "*.astro"
11
+ ],
12
+ "exports": {
13
+ "./CollectionList.astro": "./CollectionList.astro",
14
+ "./CollectionTemplate.astro": "./CollectionTemplate.astro",
15
+ "./FieldRenderer.astro": "./FieldRenderer.astro",
16
+ "./Hero.astro": "./Hero.astro",
17
+ "./InfiniteCollectionList.astro": "./InfiniteCollectionList.astro",
18
+ "./WorkCard.astro": "./WorkCard.astro",
19
+ "./DocumentBody.astro": "./DocumentBody.astro",
20
+ "./BlogDocumentPage.astro": "./BlogDocumentPage.astro",
21
+ "./MarkdownDocumentPage.astro": "./MarkdownDocumentPage.astro"
22
+ },
23
+ "peerDependencies": {
24
+ "astro": ">=4.0.0"
25
+ },
26
+ "dependencies": {
27
+ "@mezzanine-stack/content-schema": "0.1.0",
28
+ "@mezzanine-stack/content-document": "0.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@mezzanine-stack/ui": "0.1.0",
32
+ "@mezzanine-stack/css": "0.1.0"
33
+ }
34
+ }