koguma 2.3.6 → 2.3.8

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/README.md CHANGED
@@ -145,29 +145,55 @@ Koguma uses a `content/` directory for file-based content that lives in your git
145
145
 
146
146
  ```
147
147
  content/
148
- ├── post/ # folder = content type ID
149
- │ ├── hello-world.md # file = one entry
150
- └── our-mission.md
151
- ├── siteSettings/
152
- │ └── index.md # singletons use index.md
153
- └── media/ # optional local images
148
+ ├── post/ # collection one file per entry
149
+ │ ├── hello-world.md # slug = "hello-world"
150
+ ├── our-mission.md
151
+ │ └── our-mission.heroBody.md # sibling markdown field
152
+ ├── landingPage/ # singleton always index.md
153
+ │ ├── index.md # slug = content type ID
154
+ │ ├── heroBody.md # sibling markdown field
155
+ │ └── ctaBody.md # sibling markdown field
156
+ └── media/ # local images (synced to R2)
154
157
  └── hero-banner.jpg
155
158
  ```
156
159
 
157
- **Markdown files with frontmatter:**
160
+ ### File Format
161
+
162
+ Every content file uses **YAML frontmatter + markdown body**:
158
163
 
159
164
  ```markdown
160
165
  ---
161
166
  title: Our Mission
162
167
  slug: our-mission
163
- heroImage: hero-banner.jpg
168
+ heroImage: media-hero-banner
164
169
  published: true
165
170
  date: 2026-03-01
166
171
  ---
167
172
 
168
- ## Who We Are
173
+ This body content maps to the **first** `field.markdown()` in the content type.
174
+ ```
175
+
176
+ ### Singletons vs Collections
177
+
178
+ - **Collections** (default): Each entry is a file named `{slug}.md`
179
+ - **Singletons** (`singleton: true`): One entry, always `index.md`. The slug is auto-set to the content type ID.
180
+
181
+ ### Sibling Markdown Fields
169
182
 
170
- Rich text body, stored as markdown.
183
+ When a content type has **multiple** `field.markdown()` fields, the first one maps to the file body. Additional markdown fields are stored as **sibling files** (pure markdown, no frontmatter):
184
+
185
+ | | Collections | Singletons |
186
+ | ------------------- | --------------------- | -------------- |
187
+ | **Main file** | `{slug}.md` | `index.md` |
188
+ | **Sibling pattern** | `{slug}.{fieldId}.md` | `{fieldId}.md` |
189
+
190
+ Example singleton with 3 markdown fields:
191
+
192
+ ```
193
+ content/landingPage/
194
+ ├── index.md # frontmatter + 1st markdown field body
195
+ ├── heroBody.md # 2nd markdown field (pure markdown)
196
+ └── ctaBody.md # 3rd markdown field (pure markdown)
171
197
  ```
172
198
 
173
199
  **Git is your version history.** No custom versioning table needed.
package/cli/content.ts CHANGED
@@ -611,9 +611,55 @@ export function validateContent(
611
611
  if (dotIdx > 0 && extraMdFieldIds.has(name.slice(dotIdx + 1))) continue;
612
612
  }
613
613
 
614
- const entry = parseContentFile(filePath, subdir);
615
614
  const relPath = `${subdir}/${file}`;
616
615
 
616
+ // Check for orphan / misnamed files
617
+ if (ctInfo.singleton) {
618
+ // Singletons: only index.md is a valid entry
619
+ if (name !== 'index') {
620
+ // Catch the index.{fieldId}.md mistake
621
+ if (name.startsWith('index.')) {
622
+ const possibleFieldId = name.slice('index.'.length);
623
+ if (extraMdFieldIds.has(possibleFieldId)) {
624
+ warnings.push({
625
+ level: 'warn',
626
+ file: relPath,
627
+ message: `misnamed sibling — singleton siblings should be "${possibleFieldId}.md", not "index.${possibleFieldId}.md"`
628
+ });
629
+ } else {
630
+ warnings.push({
631
+ level: 'warn',
632
+ file: relPath,
633
+ message: `unrecognized file in singleton — only index.md and {fieldId}.md siblings are expected`
634
+ });
635
+ }
636
+ } else if (!extraMdFieldIds.has(name)) {
637
+ warnings.push({
638
+ level: 'warn',
639
+ file: relPath,
640
+ message: `unrecognized file in singleton — expected index.md or a sibling like {fieldId}.md`
641
+ });
642
+ }
643
+ continue;
644
+ }
645
+ } else {
646
+ // Collections: warn about dotted names that aren't valid siblings
647
+ const dotIdx = name.lastIndexOf('.');
648
+ if (dotIdx > 0) {
649
+ const possibleFieldId = name.slice(dotIdx + 1);
650
+ if (!extraMdFieldIds.has(possibleFieldId)) {
651
+ warnings.push({
652
+ level: 'warn',
653
+ file: relPath,
654
+ message: `unrecognized dotted filename — not a known sibling field. Did you mean "{slug}.md"?`
655
+ });
656
+ continue;
657
+ }
658
+ }
659
+ }
660
+
661
+ const entry = parseContentFile(filePath, subdir);
662
+
617
663
  // Check 2: Unknown frontmatter keys
618
664
  for (const key of Object.keys(entry.fields)) {
619
665
  if (!knownKeys.has(key)) {
package/cli/index.ts CHANGED
@@ -248,14 +248,25 @@ async function syncContentToLocalD1(
248
248
  );
249
249
  }
250
250
 
251
- // Single batch: assets + entries written to one SQL file
252
- const allStatements = [...assetSql, ...entrySql];
253
- if (allStatements.length > 0) {
251
+ // Wipe and rebuild: DELETE all existing entries+assets, then re-insert
252
+ // from content/ files. This eliminates schema drift (stale fields, ghost
253
+ // entries) and ensures the local DB always matches the filesystem.
254
+ const allStatements = [
255
+ 'DELETE FROM entries',
256
+ 'DELETE FROM assets',
257
+ ...assetSql,
258
+ ...entrySql
259
+ ];
260
+ if (allStatements.length > 2) {
261
+ // Only run if there's actual content to sync (beyond the 2 DELETEs)
254
262
  if (s)
255
263
  s.message(
256
264
  `Syncing ${entrySql.length} entries + ${assetSql.length} assets...`
257
265
  );
258
266
  await d1ExecuteBatchSqlAsync(root, dbName, '--local', allStatements);
267
+ } else if (allStatements.length === 2) {
268
+ // No content files — still wipe stale data
269
+ await d1ExecuteBatchSqlAsync(root, dbName, '--local', allStatements);
259
270
  }
260
271
 
261
272
  // Upload media files to local R2
package/cli/scaffold.ts CHANGED
@@ -404,7 +404,9 @@ export function scaffoldContentDirFromTemplate(
404
404
  // Write sibling example files for extra markdown fields
405
405
  if (siblingFiles) {
406
406
  for (const { fieldId, content: siblingContent } of siblingFiles) {
407
- const siblingName = `_example.${fieldId}.md`;
407
+ const siblingName = ct.singleton
408
+ ? `${fieldId}.md`
409
+ : `_example.${fieldId}.md`;
408
410
  writeFileSync(resolve(typeDir, siblingName), siblingContent + '\n');
409
411
  }
410
412
  }
@@ -453,7 +455,9 @@ export function scaffoldContentDir(
453
455
  // Write sibling example files for extra markdown fields
454
456
  if (siblingFiles) {
455
457
  for (const { fieldId, content: siblingContent } of siblingFiles) {
456
- const siblingName = `_example.${fieldId}.md`;
458
+ const siblingName = ct.singleton
459
+ ? `${fieldId}.md`
460
+ : `_example.${fieldId}.md`;
457
461
  writeFileSync(resolve(typeDir, siblingName), siblingContent + '\n');
458
462
  }
459
463
  }
@@ -596,7 +600,9 @@ export function syncContentDirsWithConfig(
596
600
  // Write sibling example files for extra markdown fields
597
601
  if (siblingFiles) {
598
602
  for (const { fieldId, content: siblingContent } of siblingFiles) {
599
- const siblingName = `_example.${fieldId}.md`;
603
+ const siblingName = ct.singleton
604
+ ? `${fieldId}.md`
605
+ : `_example.${fieldId}.md`;
600
606
  writeFileSync(resolve(typeDir, siblingName), siblingContent + '\n');
601
607
  }
602
608
  }
@@ -625,7 +631,9 @@ export function syncContentDirsWithConfig(
625
631
  // Write sibling example files for extra markdown fields
626
632
  if (siblingFiles) {
627
633
  for (const { fieldId, content: siblingContent } of siblingFiles) {
628
- const siblingName = `_example.${fieldId}.md`;
634
+ const siblingName = ct.singleton
635
+ ? `${fieldId}.md`
636
+ : `_example.${fieldId}.md`;
629
637
  writeFileSync(resolve(typeDir, siblingName), siblingContent + '\n');
630
638
  }
631
639
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koguma",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
4
4
  "description": "🐻 A little CMS with big heart — schema-driven, runs on Cloudflare's free tier",
5
5
  "type": "module",
6
6
  "license": "MIT",