feat: flip canonical type field — make type: primary, Is A: the alias

The Rust parser now treats `type:` as the canonical frontmatter field
for entity type, with `Is A:` and `is_a:` accepted as legacy aliases.
Previously it was the other way around, creating an asymmetric
read/write cycle since the frontend and all 8800+ vault notes already
use `type:`.

- Flip serde attribute: rename="type", alias="Is A", alias="is_a"
- Update theme defaults, getting-started vault, and type definitions
- Add round-trip tests for both type: and Is A: parsing
- Update mock data and TypeScript tests to use canonical form

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-08 23:38:17 +01:00
parent aef98f17eb
commit 1c8542bd01
11 changed files with 115 additions and 39 deletions

View File

@@ -164,7 +164,7 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
@@ -220,7 +220,7 @@ The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
@@ -276,7 +276,7 @@ A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
@@ -332,7 +332,7 @@ High contrast, minimal chrome. Monospace typography throughout.\n";
/// Type definition for the Theme note type.
pub const THEME_TYPE_DEFINITION: &str = "---\n\
Is A: Type\n\
type: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\

View File

@@ -181,7 +181,7 @@ mod tests {
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("type: Theme"));
}
#[test]
@@ -190,7 +190,7 @@ mod tests {
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
@@ -227,7 +227,7 @@ mod tests {
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("type: Theme"));
}
#[test]
@@ -236,7 +236,7 @@ mod tests {
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
@@ -265,7 +265,7 @@ mod tests {
"restore must create type/theme.md"
);
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
assert!(type_content.contains("Is A: Type"));
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("icon: palette"));
}
@@ -280,7 +280,7 @@ mod tests {
let path = vault.join("type").join("theme.md");
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("type: Type"));
assert!(content.contains("icon: palette"));
}
@@ -290,7 +290,7 @@ mod tests {
let vault = dir.path().join("vault");
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).unwrap();
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(type_dir.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();

View File

@@ -51,7 +51,7 @@ YAML frontmatter between `---` delimiters defines metadata:
```yaml
---
Is A: Project
type: Project
Status: Active
Owner: "[[person/jane-doe]]"
Belongs to: "[[quarter/24q1]]"
@@ -65,7 +65,7 @@ Related to:
| Field | Purpose |
|-------|---------|
| `Is A` | Entity type (usually inferred from folder) |
| `type` | Entity type (usually inferred from folder) |
| `Status` | Active, Done, Paused, Archived, Dropped |
| `Owner` | Person responsible (wikilink) |
| `Belongs to` | Parent relationship(s) |
@@ -98,7 +98,7 @@ Files in `type/` define entity types and control how they appear in the sidebar:
```yaml
---
Is A: Type
type: Type
icon: rocket-launch
color: purple
order: 1
@@ -119,32 +119,32 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n
const SAMPLE_FILES: &[SampleFile] = &[
SampleFile {
rel_path: "type/project.md",
content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
},
SampleFile {
rel_path: "type/note.md",
content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
},
SampleFile {
rel_path: "type/person.md",
content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
},
SampleFile {
rel_path: "type/topic.md",
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
content: "---\ntype: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
},
SampleFile {
rel_path: "type/theme.md",
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
content: "---\ntype: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
},
SampleFile {
rel_path: "type/config.md",
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
Is A: Note
type: Note
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
@@ -179,7 +179,7 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l
SampleFile {
rel_path: "note/editor-basics.md",
content: r#"---
Is A: Note
type: Note
Related to: "[[note/welcome-to-laputa]]"
---
@@ -227,7 +227,7 @@ function hello() {
SampleFile {
rel_path: "note/using-properties.md",
content: r#"---
Is A: Note
type: Note
Status: Active
Related to:
- "[[note/welcome-to-laputa]]"
@@ -240,7 +240,7 @@ Every note can have **properties** defined in the YAML frontmatter at the top of
## Common properties
- **Is A** — The note's type (Project, Note, Person, etc.)
- **type** — The note's type (Project, Note, Person, etc.)
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
- **Related to** — Lateral connections to other notes
@@ -261,7 +261,7 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa
SampleFile {
rel_path: "note/wiki-links-and-relationships.md",
content: r#"---
Is A: Note
type: Note
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/using-properties]]"
@@ -300,7 +300,7 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced
SampleFile {
rel_path: "project/sample-project.md",
content: r#"---
Is A: Project
type: Project
Status: Active
Owner: "[[person/sample-collaborator]]"
Related to: "[[topic/getting-started]]"
@@ -329,7 +329,7 @@ This project is owned by [[person/sample-collaborator]] and relates to [[topic/g
SampleFile {
rel_path: "person/sample-collaborator.md",
content: r#"---
Is A: Person
type: Person
---
# Sample Collaborator
@@ -350,7 +350,7 @@ This person is the owner of [[project/sample-project]]. Check the **Referenced B
SampleFile {
rel_path: "topic/getting-started.md",
content: r#"---
Is A: Topic
type: Topic
---
# Getting Started

View File

@@ -94,7 +94,7 @@ pub struct VaultEntry {
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A", alias = "type")]
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
is_a: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
@@ -1444,6 +1444,32 @@ Company: Acme Corp
assert!(entry.properties.get("visible").is_none());
}
// --- round-trip: canonical `type:` field and `Is A:` alias ---
#[test]
fn test_roundtrip_type_key_parses_correctly() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_snake_case_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nis_a: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -155,7 +155,7 @@ mod tests {
create_test_file(
dir.path(),
"normal.md",
"---\nIs A: Note\n---\n# Normal Note\n",
"---\ntype: Note\n---\n# Normal Note\n",
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();

View File

@@ -543,7 +543,7 @@ Status: Active
<Inspector
{...defaultProps}
entry={typeEntry}
content="---\nIs A: Type\n---\n# Responsibility\n"
content="---\ntype: Type\n---\n# Responsibility\n"
entries={[typeEntry, essayEntry]}
/>

View File

@@ -8,7 +8,7 @@ function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
themes: [],
activeThemeId: '/vault/_themes/My Theme.md',
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue(''),

View File

@@ -6,7 +6,7 @@ const THEME_PATH_DEFAULT = '/vault/theme/default.md'
const THEME_PATH_DARK = '/vault/theme/dark.md'
const DEFAULT_THEME_CONTENT = `---
Is A: Theme
type: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
@@ -19,7 +19,7 @@ text-primary: "#37352F"
`
const DARK_THEME_CONTENT = `---
Is A: Theme
type: Theme
Description: Dark theme
background: "#0f0f1a"
foreground: "#e0e0e0"
@@ -342,7 +342,7 @@ describe('useThemeManager', () => {
})
const newContent = {
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
[THEME_PATH_DEFAULT]: `---\ntype: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
}
rerender({ content: newContent })

View File

@@ -734,7 +734,7 @@ rating: 5
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
'/Users/luca/Laputa/theme/default.md': `---
Is A: Theme
type: Theme
title: Default
primary: "#155DFF"
background: "#FFFFFF"
@@ -755,7 +755,7 @@ line-height-base: 1.6
Light theme with warm, paper-like tones.
`,
'/Users/luca/Laputa/theme/dark.md': `---
Is A: Theme
type: Theme
title: Dark
primary: "#155DFF"
background: "#0f0f1a"
@@ -776,7 +776,7 @@ line-height-base: 1.6
Dark variant with deep navy tones.
`,
'/Users/luca/Laputa/theme/minimal.md': `---
Is A: Theme
type: Theme
title: Minimal
primary: "#000000"
background: "#FAFAFA"

View File

@@ -314,7 +314,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
const path = `${args.vaultPath}/theme/${slug}.md`
MOCK_CONTENT[path] = `---
Is A: Theme
type: Theme
title: ${displayName}
primary: "#155DFF"
background: "#FFFFFF"

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
}
test.describe('Canonical type field', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('sidebar shows type sections parsed from type: field', async ({ page }) => {
// The sidebar groups notes by type — this confirms the type: field is parsed correctly
// by the mock layer (which mirrors what the Rust parser does with serde rename)
const projectSection = page.getByText('Project').first()
await expect(projectSection).toBeVisible({ timeout: 5000 })
// Also verify Note and Person sections exist (these use type: in mock data)
const noteSection = page.getByText('Note').first()
await expect(noteSection).toBeVisible({ timeout: 5000 })
})
test('theme notes display correctly with type: Theme', async ({ page }) => {
// Navigate to a theme note — themes now use type: Theme in their frontmatter
await openNoteViaQuickOpen(page, 'Default Theme')
// Verify the note loaded and the editor/title area shows the theme title
const heading = page.getByText('Default Theme').first()
await expect(heading).toBeVisible({ timeout: 5000 })
})
test('type definition notes display correctly with type: Type', async ({ page }) => {
// Open a Type definition note (e.g. "Project" type definition)
await openNoteViaQuickOpen(page, 'Project')
// Type definitions should still show the Instances section
const instancesLabel = page.getByText(/Instances \(\d+\)/)
await expect(instancesLabel).toBeVisible({ timeout: 5000 })
})
})