Add Spanish Suffixes grammar guide with card-based content layout
Add comprehensive suffix reference (diminutives, augmentatives, verb endings, noun/adjective-forming, adverbs, pejoratives) as grammar note #21 under Word Building category. Refactor grammar detail rendering to group paragraphs with their examples into visual cards, replacing the flat wall-of-text layout. Suffix entries get pill-styled labels with compact inline examples. Bump courseDataVersion to 5. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ struct GrammarNote: Identifiable {
|
|||||||
accentMarksStress,
|
accentMarksStress,
|
||||||
seConstructions,
|
seConstructions,
|
||||||
estarGerundProgressive,
|
estarGerundProgressive,
|
||||||
|
spanishSuffixes,
|
||||||
]
|
]
|
||||||
|
|
||||||
// MARK: - 1. Ser vs Estar
|
// MARK: - 1. Ser vs Estar
|
||||||
@@ -887,4 +888,215 @@ struct GrammarNote: Identifiable {
|
|||||||
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MARK: - 21. Spanish Suffixes
|
||||||
|
|
||||||
|
private static let spanishSuffixes = GrammarNote(
|
||||||
|
id: "spanish-suffixes",
|
||||||
|
title: "Spanish Suffixes",
|
||||||
|
category: "Word Building",
|
||||||
|
body: """
|
||||||
|
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
|
||||||
|
|
||||||
|
**Diminutives — smallness and affection**
|
||||||
|
|
||||||
|
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
|
||||||
|
|
||||||
|
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
|
||||||
|
*perro → perrito* — little dog
|
||||||
|
*casa → casita* — little house
|
||||||
|
*momento → momentito* — just a moment
|
||||||
|
*abuela → abuelita* — grandma (affectionate)
|
||||||
|
|
||||||
|
*-cito / -cita* — used after words ending in a consonant or -e.
|
||||||
|
*café → cafecito* — a nice little coffee
|
||||||
|
*pobre → pobrecito* — poor little thing
|
||||||
|
*amor → amorcito* — sweetheart
|
||||||
|
|
||||||
|
*-illo / -illa* — a slightly less affectionate diminutive.
|
||||||
|
*chico → chiquillo* — little kid
|
||||||
|
*guerra → guerrilla* — small war / guerrilla
|
||||||
|
*pan → panecillo* — bread roll
|
||||||
|
|
||||||
|
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
|
||||||
|
*rato → ratico* — a little while
|
||||||
|
*gato → gatico* — little cat
|
||||||
|
|
||||||
|
**Augmentatives — bigness and intensity**
|
||||||
|
|
||||||
|
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
|
||||||
|
|
||||||
|
*-ón / -ona* — big or intense, sometimes negative.
|
||||||
|
*silla → sillón* — armchair (big chair)
|
||||||
|
*soltar → solterón* — confirmed bachelor
|
||||||
|
*cabeza → cabezón* — big-headed / stubborn
|
||||||
|
|
||||||
|
*-ote / -ota* — big, often clumsy or exaggerated.
|
||||||
|
*grande → grandote* — really big
|
||||||
|
*amigo → amigote* — a big buddy (casual)
|
||||||
|
*palabra → palabrota* — a swear word (a "big word")
|
||||||
|
|
||||||
|
*-azo / -aza* — big; also means a blow or hit with something.
|
||||||
|
*perro → perrazo* — huge dog
|
||||||
|
*puño → puñetazo* — a punch
|
||||||
|
*gol → golazo* — an amazing goal
|
||||||
|
*codo → codazo* — an elbow jab
|
||||||
|
|
||||||
|
*-udo / -uda* — having a lot of something.
|
||||||
|
*pelo → peludo* — hairy
|
||||||
|
*barba → barbudo* — bearded
|
||||||
|
*panza → panzudo* — big-bellied
|
||||||
|
|
||||||
|
**Verb-related endings**
|
||||||
|
|
||||||
|
These endings are built into the verb conjugation system and tell you when and how the action occurs.
|
||||||
|
|
||||||
|
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
|
||||||
|
*hablar → hablando* — speaking
|
||||||
|
*caminar → caminando* — walking
|
||||||
|
*estudiar → estudiando* — studying
|
||||||
|
|
||||||
|
*-iendo* — gerund for -er and -ir verbs.
|
||||||
|
*comer → comiendo* — eating
|
||||||
|
*vivir → viviendo* — living
|
||||||
|
*leer → leyendo* — reading (note the spelling change)
|
||||||
|
|
||||||
|
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
|
||||||
|
*hablar → hablado* — spoken
|
||||||
|
*cerrar → cerrado* — closed
|
||||||
|
*cansar → cansado* — tired
|
||||||
|
|
||||||
|
*-ido* — past participle for -er and -ir verbs.
|
||||||
|
*comer → comido* — eaten
|
||||||
|
*vivir → vivido* — lived
|
||||||
|
*dormir → dormido* — slept
|
||||||
|
|
||||||
|
**Noun-forming suffixes**
|
||||||
|
|
||||||
|
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
|
||||||
|
|
||||||
|
*-ción / -sión* — action or result, equivalent to English "-tion."
|
||||||
|
*actuar → acción* — action
|
||||||
|
*decidir → decisión* — decision
|
||||||
|
*comunicar → comunicación* — communication
|
||||||
|
*expresar → expresión* — expression
|
||||||
|
|
||||||
|
*-miento* — action or result, similar to -ción.
|
||||||
|
*conocer → conocimiento* — knowledge
|
||||||
|
*sentir → sentimiento* — feeling
|
||||||
|
*mover → movimiento* — movement
|
||||||
|
|
||||||
|
*-ería* — a shop, business, or place associated with something.
|
||||||
|
*pan → panadería* — bakery
|
||||||
|
*libro → librería* — bookstore
|
||||||
|
*zapato → zapatería* — shoe store
|
||||||
|
*carne → carnicería* — butcher shop
|
||||||
|
|
||||||
|
*-ero / -era* — a person who does or makes something.
|
||||||
|
*pan → panadero* — baker
|
||||||
|
*cocina → cocinero* — cook
|
||||||
|
*carta → cartero* — mail carrier
|
||||||
|
*enfermo → enfermera* — nurse
|
||||||
|
|
||||||
|
*-dor / -dora* — a person or thing that does something.
|
||||||
|
*jugar → jugador* — player
|
||||||
|
*trabajar → trabajador* — worker
|
||||||
|
*computar → computadora* — computer
|
||||||
|
*lavar → lavadora* — washing machine
|
||||||
|
|
||||||
|
*-ista* — a person devoted to something (gender-neutral form).
|
||||||
|
*arte → artista* — artist
|
||||||
|
*diente → dentista* — dentist
|
||||||
|
*fútbol → futbolista* — soccer player
|
||||||
|
*piano → pianista* — pianist
|
||||||
|
|
||||||
|
*-ura* — a quality or result.
|
||||||
|
*dulce → dulzura* — sweetness
|
||||||
|
*loco → locura* — madness
|
||||||
|
*abrir → abertura* — opening
|
||||||
|
*pintar → pintura* — painting / paint
|
||||||
|
|
||||||
|
*-eza* — an abstract quality.
|
||||||
|
*bello → belleza* — beauty
|
||||||
|
*triste → tristeza* — sadness
|
||||||
|
*puro → pureza* — purity
|
||||||
|
*natural → naturaleza* — nature
|
||||||
|
|
||||||
|
*-dad / -tad* — a quality, equivalent to English "-ty."
|
||||||
|
*libre → libertad* — liberty
|
||||||
|
*real → realidad* — reality
|
||||||
|
*feliz → felicidad* — happiness
|
||||||
|
*amigo → amistad* — friendship
|
||||||
|
|
||||||
|
*-anza* — action or result.
|
||||||
|
*esperar → esperanza* — hope
|
||||||
|
*enseñar → enseñanza* — teaching
|
||||||
|
*confiar → confianza* — trust / confidence
|
||||||
|
|
||||||
|
**Adjective-forming suffixes**
|
||||||
|
|
||||||
|
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
|
||||||
|
|
||||||
|
*-oso / -osa* — full of something, equivalent to English "-ous."
|
||||||
|
*peligro → peligroso* — dangerous
|
||||||
|
*hermosura → hermoso* — beautiful
|
||||||
|
*cariño → cariñoso* — affectionate
|
||||||
|
*éxito → exitoso* — successful
|
||||||
|
|
||||||
|
*-ano / -ana* — relating to a place or group.
|
||||||
|
*México → mexicano* — Mexican
|
||||||
|
*América → americano* — American
|
||||||
|
*cristiano* — Christian
|
||||||
|
*humano* — human
|
||||||
|
|
||||||
|
*-eño / -eña* — from a place.
|
||||||
|
*Panamá → panameño* — Panamanian
|
||||||
|
*Brasil → brasileño* — Brazilian
|
||||||
|
*isla → isleño* — islander
|
||||||
|
|
||||||
|
*-ense* — from a place (often countries or cities).
|
||||||
|
*Costa Rica → costarricense* — Costa Rican
|
||||||
|
*Estados Unidos → estadounidense* — American (USA)
|
||||||
|
*Canadá → canadiense* — Canadian
|
||||||
|
|
||||||
|
*-ble* — able to be, equivalent to English "-ble."
|
||||||
|
*posible* — possible
|
||||||
|
*increíble* — incredible
|
||||||
|
*responsable* — responsible
|
||||||
|
*agradable* — pleasant
|
||||||
|
|
||||||
|
*-ivo / -iva* — tending toward, equivalent to English "-ive."
|
||||||
|
*actuar → activo* — active
|
||||||
|
*crear → creativo* — creative
|
||||||
|
*producir → productivo* — productive
|
||||||
|
|
||||||
|
**Adverb and intensity suffixes**
|
||||||
|
|
||||||
|
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
|
||||||
|
*rápida → rápidamente* — quickly
|
||||||
|
*fácil → fácilmente* — easily
|
||||||
|
*tranquila → tranquilamente* — calmly
|
||||||
|
*completa → completamente* — completely
|
||||||
|
|
||||||
|
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
|
||||||
|
*rápido → rapidísimo* — super fast
|
||||||
|
*bella → bellísima* — extremely beautiful
|
||||||
|
*mucho → muchísimo* — very very much
|
||||||
|
*grande → grandísimo* — enormous
|
||||||
|
|
||||||
|
**Pejorative suffixes — negative or ugly tone**
|
||||||
|
|
||||||
|
These suffixes add a negative, derogatory, or ugly connotation.
|
||||||
|
|
||||||
|
*-ucho / -ucha* — ugly, run-down.
|
||||||
|
*casa → casucha* — a shack, a dump
|
||||||
|
*delgado → delgaducho* — scrawny
|
||||||
|
|
||||||
|
*-aco / -aca* — ugly, derogatory.
|
||||||
|
*pájaro → pajarraco* — ugly bird
|
||||||
|
*libro → libraco* — a terrible book
|
||||||
|
|
||||||
|
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
|
||||||
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ actor DataLoader {
|
|||||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||||
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
||||||
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
||||||
let currentVersion = 4 // Bump this whenever course_data.json changes
|
let currentVersion = 5 // Bump this whenever course_data.json changes
|
||||||
let key = "courseDataVersion"
|
let key = "courseDataVersion"
|
||||||
let shared = UserDefaults.standard
|
let shared = UserDefaults.standard
|
||||||
|
|
||||||
|
|||||||
@@ -101,13 +101,51 @@ struct GrammarNoteDetailView: View {
|
|||||||
private struct FormattedGrammarBody: View {
|
private struct FormattedGrammarBody: View {
|
||||||
let content: String
|
let content: String
|
||||||
|
|
||||||
private var lines: [GrammarLine] {
|
private var sections: [GrammarSection] {
|
||||||
GrammarLine.parse(content)
|
GrammarSection.group(GrammarLine.parse(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
ForEach(sections) { section in
|
||||||
|
if section.heading == nil {
|
||||||
|
// Intro content before the first heading — no card
|
||||||
|
renderLines(section.lines)
|
||||||
|
} else {
|
||||||
|
// Headed section in a card
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(lines) { line in
|
Text(section.heading!)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
renderLines(section.lines)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderLines(_ lines: [GrammarLine]) -> some View {
|
||||||
|
let blocks = ContentBlock.group(lines)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ForEach(blocks) { block in
|
||||||
|
switch block.kind {
|
||||||
|
case .standalone(let line):
|
||||||
|
renderSingleLine(line)
|
||||||
|
case .exampleCard(let header, let examples):
|
||||||
|
renderExampleCard(header: header, examples: examples)
|
||||||
|
case .suffixCard(let suffix, let description, let examples):
|
||||||
|
renderSuffixCard(suffix: suffix, description: description, examples: examples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderSingleLine(_ line: GrammarLine) -> some View {
|
||||||
switch line.kind {
|
switch line.kind {
|
||||||
case .paragraph(let text):
|
case .paragraph(let text):
|
||||||
renderParagraph(text)
|
renderParagraph(text)
|
||||||
@@ -126,14 +164,84 @@ private struct FormattedGrammarBody: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
case .heading(let text):
|
case .heading, .suffixDef:
|
||||||
Text(text)
|
EmptyView()
|
||||||
.font(.headline)
|
}
|
||||||
.padding(.top, 6)
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let header, case .paragraph(let text) = header.kind {
|
||||||
|
renderParagraph(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(examples) { ex in
|
||||||
|
if case .examplePair(let spanish, let english) = ex.kind {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
Text(english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if case .spanishExample(let spanish) = ex.kind {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(suffix)
|
||||||
|
.font(.subheadline.weight(.bold).monospaced())
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.tint.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Text(description)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !examples.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(examples) { ex in
|
||||||
|
if case .examplePair(let spanish, let english) = ex.kind {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
Text(" ")
|
||||||
|
Text(english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if case .spanishExample(let spanish) = ex.kind {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func renderParagraph(_ text: String) -> some View {
|
private func renderParagraph(_ text: String) -> some View {
|
||||||
@@ -200,11 +308,20 @@ private struct GrammarLine: Identifiable {
|
|||||||
let id: Int
|
let id: Int
|
||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
|
||||||
|
var isExample: Bool {
|
||||||
|
switch kind {
|
||||||
|
case .examplePair, .spanishExample: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Kind {
|
enum Kind {
|
||||||
case paragraph(String)
|
case paragraph(String)
|
||||||
case heading(String)
|
case heading(String)
|
||||||
case spanishExample(String)
|
case spanishExample(String)
|
||||||
case examplePair(spanish: String, english: String)
|
case examplePair(spanish: String, english: String)
|
||||||
|
/// A suffix definition line like `*-ito / -ita* — description`
|
||||||
|
case suffixDef(suffix: String, description: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parse(_ body: String) -> [GrammarLine] {
|
static func parse(_ body: String) -> [GrammarLine] {
|
||||||
@@ -255,7 +372,13 @@ private struct GrammarLine: Identifiable {
|
|||||||
let englishPart = String(line[dashRange.upperBound...])
|
let englishPart = String(line[dashRange.upperBound...])
|
||||||
.replacingOccurrences(of: "*", with: "")
|
.replacingOccurrences(of: "*", with: "")
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Detect suffix definitions: spanish part starts with "-"
|
||||||
|
if spanishPart.hasPrefix("-") {
|
||||||
|
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
|
||||||
|
} else {
|
||||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Just a Spanish example without translation
|
// Just a Spanish example without translation
|
||||||
let spanish = line.replacingOccurrences(of: "*", with: "")
|
let spanish = line.replacingOccurrences(of: "*", with: "")
|
||||||
@@ -279,6 +402,126 @@ private struct GrammarLine: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Content Block Grouping
|
||||||
|
|
||||||
|
/// Groups paragraphs with their trailing examples into visual cards.
|
||||||
|
/// Also handles suffix definitions as a special card type.
|
||||||
|
private struct ContentBlock: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
|
||||||
|
enum Kind {
|
||||||
|
/// A standalone line (paragraph with no examples, or orphaned example)
|
||||||
|
case standalone(GrammarLine)
|
||||||
|
/// A paragraph header followed by example pairs — rendered as a card
|
||||||
|
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
|
||||||
|
/// A suffix definition with its examples — rendered as a pill card
|
||||||
|
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
|
||||||
|
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
|
||||||
|
var result: [ContentBlock] = []
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while i < lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
|
||||||
|
// Suffix definition: collect trailing examples
|
||||||
|
if case .suffixDef(let suffix, let desc) = line.kind {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
var j = i + 1
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph followed by examples: group into a card
|
||||||
|
if case .paragraph = line.kind {
|
||||||
|
var j = i + 1
|
||||||
|
// Check if examples follow
|
||||||
|
if j < lines.count, lines[j].isExample {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// Standalone paragraph
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned examples (no preceding paragraph) — group into a card
|
||||||
|
if line.isExample {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
var j = i
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip headings (handled at section level)
|
||||||
|
if case .heading = line.kind {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grammar Section Grouping
|
||||||
|
|
||||||
|
private struct GrammarSection: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let heading: String?
|
||||||
|
let lines: [GrammarLine]
|
||||||
|
|
||||||
|
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
|
||||||
|
var sections: [GrammarSection] = []
|
||||||
|
var currentHeading: String? = nil
|
||||||
|
var currentLines: [GrammarLine] = []
|
||||||
|
var sectionIndex = 0
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
if case .heading(let text) = line.kind {
|
||||||
|
// Flush the previous section
|
||||||
|
if !currentLines.isEmpty || currentHeading != nil {
|
||||||
|
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||||
|
sectionIndex += 1
|
||||||
|
currentLines = []
|
||||||
|
}
|
||||||
|
currentHeading = text
|
||||||
|
} else {
|
||||||
|
currentLines.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush final section
|
||||||
|
if !currentLines.isEmpty || currentHeading != nil {
|
||||||
|
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hashable/Equatable conformance for NavigationLink
|
// MARK: - Hashable/Equatable conformance for NavigationLink
|
||||||
|
|
||||||
extension GrammarNote: Hashable, Equatable {
|
extension GrammarNote: Hashable, Equatable {
|
||||||
|
|||||||
Reference in New Issue
Block a user