Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ All notable changes to LexiconKit are documented here.

## Unreleased

### Added

- Add optional, language-independent grammatical gender metadata to `LexiconTerm` with backward-compatible Codable behavior.

## 0.1.1 — 2026-09-13

### Changed
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@

LexiconKit is a reusable, UI-agnostic domain package for personal vocabulary collections, definitions, provenance, lightweight metadata, and opaque references to host-owned media.

LexiconKit provides persistence-friendly values for vocabulary entries, terms, definitions, definition provenance, tags, and opaque host-owned media references. Dictionary lookup, translation, exercises, persistence frameworks, synchronization, and UI remain outside its boundary.
LexiconKit provides persistence-friendly values for vocabulary entries, terms, optional grammatical gender, definitions, definition provenance, tags, and opaque host-owned media references. Dictionary lookup, translation, language-specific display articles, exercises, persistence frameworks, synchronization, and UI remain outside its boundary.

```swift
let entry = LexiconEntry(
term: LexiconTerm(text: "Haus", languageCode: "de"),
term: LexiconTerm(
text: "Haus",
languageCode: "de",
grammaticalGender: .neuter
),
definitions: [
LexiconDefinition(
text: "house",
Expand Down
11 changes: 11 additions & 0 deletions Sources/LexiconKit/LexiconGrammaticalGender.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// A grammatical gender category associated with a lexical term.
public enum LexiconGrammaticalGender: String, Codable, Hashable, Sendable {
/// The feminine grammatical gender.
case feminine

/// The masculine grammatical gender.
case masculine

/// The neuter grammatical gender.
case neuter
}
3 changes: 2 additions & 1 deletion Sources/LexiconKit/LexiconKit.docc/LexiconKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Model persistence-friendly vocabulary values without coupling consumers to UI, l

LexiconKit models vocabulary independently from persistence, synchronization, user interface, and learning progression. Hosts decide how entries are validated, deduplicated, stored, synchronized, and presented.

Create a ``LexiconEntry`` from a ``LexiconTerm`` and one or more ``LexiconDefinition`` values. Definitions retain their ``LexiconDefinitionSource`` so application-provided and user-provided content remain distinguishable after serialization. ``LexiconTag`` values and an optional ``LexiconAssetReference`` carry host-defined metadata without leaking application or media-framework types into the domain.
Create a ``LexiconEntry`` from a ``LexiconTerm`` and one or more ``LexiconDefinition`` values. A term can carry an optional ``LexiconGrammaticalGender`` category without encoding a language-specific display article. Definitions retain their ``LexiconDefinitionSource`` so application-provided and user-provided content remain distinguishable after serialization. ``LexiconTag`` values and an optional ``LexiconAssetReference`` carry host-defined metadata without leaking application or media-framework types into the domain.

All public values are `Codable`, `Hashable`, and `Sendable`. Stable entry and definition identifiers plus explicit creation and modification timestamps let a host persist and merge values using its own policy.

Expand All @@ -16,6 +16,7 @@ All public values are `Codable`, `Hashable`, and `Sendable`. Stable entry and de

- ``LexiconEntry``
- ``LexiconTerm``
- ``LexiconGrammaticalGender``

### Definitions

Expand Down
11 changes: 10 additions & 1 deletion Sources/LexiconKit/LexiconTerm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import Foundation

/// A collected lexical form or phrase and its language metadata.
public struct LexiconTerm: Codable, Hashable, Sendable {
/// The term's optional grammatical gender.
public var grammaticalGender: LexiconGrammaticalGender?

public var languageCode: String
public var text: String

Expand All @@ -10,7 +13,13 @@ public struct LexiconTerm: Codable, Hashable, Sendable {
/// - Parameters:
/// - text: The collected lexical form or phrase.
/// - languageCode: The BCP 47 language code for the text.
public init(text: String, languageCode: String) {
/// - grammaticalGender: An optional language-independent grammatical gender category.
public init(
text: String,
languageCode: String,
grammaticalGender: LexiconGrammaticalGender? = nil
) {
self.grammaticalGender = grammaticalGender
self.languageCode = languageCode
self.text = text
}
Expand Down
87 changes: 87 additions & 0 deletions Tests/LexiconKitTests/LexiconEntryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,78 @@ import Testing
@testable import LexiconKit

struct LexiconEntryTests {
@Test func legacyEntryWithoutGrammaticalGenderDecodesWithNil() throws {
let payload = Data(
#"{"createdAt":0,"definitions":[],"id":"D2E60778-E44B-48BE-B539-268D1F15229A","modifiedAt":0,"tags":[],"term":{"languageCode":"de","text":"Haus"}}"#
.utf8
)

let entry = try JSONDecoder().decode(LexiconEntry.self, from: payload)

#expect(entry.term.text == "Haus")
#expect(entry.term.grammaticalGender == nil)
}

@Test(
"Every supported grammatical gender round-trips",
arguments: [
LexiconGrammaticalGender.feminine,
.masculine,
.neuter,
]
)
func grammaticalGenderRoundTrips(_ gender: LexiconGrammaticalGender) throws {
let term = LexiconTerm(
text: "Haus",
languageCode: "de",
grammaticalGender: gender
)

let data = try JSONEncoder().encode(term)
let decoded = try JSONDecoder().decode(LexiconTerm.self, from: data)

#expect(decoded == term)
#expect(decoded.grammaticalGender == gender)
}

@Test func existingInitializerDefaultsGrammaticalGenderToNil() {
let term = LexiconTerm(text: "laufen", languageCode: "de")

#expect(term.grammaticalGender == nil)
}

@Test func equalityAndHashingIncludeGrammaticalGender() {
let masculine = LexiconTerm(
text: "See",
languageCode: "de",
grammaticalGender: .masculine
)
let feminine = LexiconTerm(
text: "See",
languageCode: "de",
grammaticalGender: .feminine
)

#expect(masculine != feminine)
#expect(Set([masculine, feminine]).count == 2)
}

@Test func legacyDecoderIgnoresNewGrammaticalGenderKey() throws {
let entry = LexiconEntry(
term: LexiconTerm(
text: "Haus",
languageCode: "de",
grammaticalGender: .neuter
),
definitions: []
)

let data = try JSONEncoder().encode(entry)
let legacyEntry = try JSONDecoder().decode(LegacyEntry.self, from: data)

#expect(legacyEntry.term == LegacyTerm(languageCode: "de", text: "Haus"))
}

@Test func completeEntryRoundTripsThroughCodable() throws {
// Given
let entryID = UUID(uuidString: "D2E60778-E44B-48BE-B539-268D1F15229A")!
Expand Down Expand Up @@ -88,3 +160,18 @@ struct LexiconEntryTests {
#expect(entry.definitions.map(\.text) == ["book"])
}
}

private struct LegacyTerm: Codable, Equatable {
var languageCode: String
var text: String
}

private struct LegacyEntry: Codable {
let id: UUID
var assetReference: LexiconAssetReference?
var createdAt: Date
var definitions: [LexiconDefinition]
var modifiedAt: Date
var tags: Set<LexiconTag>
var term: LegacyTerm
}