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:
@@ -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\
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]}
|
||||
|
||||
/>
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
tests/smoke/flip-canonical-type-field.spec.ts
Normal file
50
tests/smoke/flip-canonical-type-field.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user