diff --git a/build_docs.py b/build_docs.py index 6217ab1..1437bbc 100644 --- a/build_docs.py +++ b/build_docs.py @@ -1394,6 +1394,19 @@ def generate_html(docs, logo_data, lang='en'): font-size: 0.9rem; }} + .search-phonetic-badge {{ + display: inline-block; + margin-left: 0.5rem; + padding: 0.15rem 0.4rem; + background: var(--sage); + color: var(--pine); + border-radius: 4px; + font-size: 0.7rem; + font-style: italic; + text-transform: none; + letter-spacing: normal; + }} + .search-shortcut {{ display: none; margin-left: 0.5rem; @@ -1992,6 +2005,197 @@ def generate_html(docs, logo_data, lang='en'): let selectedIndex = -1; let currentResults = []; + // Metaphone phonetic encoding algorithm + // Converts words to phonetic codes for fuzzy matching + function metaphone(word) {{ + if (!word) return ''; + word = word.toUpperCase(); + let result = ''; + let i = 0; + + // Skip initial silent letters + if (word.length >= 2) {{ + const start = word.substring(0, 2); + if (['KN', 'GN', 'PN', 'AE', 'WR'].includes(start)) {{ + i = 1; + }} + }} + + while (i < word.length && result.length < 6) {{ + const c = word[i]; + const prev = i > 0 ? word[i - 1] : ''; + const next = i < word.length - 1 ? word[i + 1] : ''; + const next2 = i < word.length - 2 ? word[i + 2] : ''; + + // Skip duplicates except CC + if (c === prev && c !== 'C') {{ + i++; + continue; + }} + + switch (c) {{ + case 'A': case 'E': case 'I': case 'O': case 'U': + // Vowels only kept at start + if (i === 0) result += c; + break; + + case 'B': + // B silent after M at end + if (!(prev === 'M' && i === word.length - 1)) result += 'B'; + break; + + case 'C': + if (next === 'I' && next2 === 'A') {{ + result += 'X'; // CIA -> X + }} else if (['I', 'E', 'Y'].includes(next)) {{ + result += 'S'; // Soft C + }} else if (next === 'H') {{ + result += 'X'; // CH -> X + i++; + }} else {{ + result += 'K'; // Hard C + }} + break; + + case 'D': + if (next === 'G' && ['I', 'E', 'Y'].includes(next2)) {{ + result += 'J'; // DGE, DGI, DGY -> J + i++; + }} else {{ + result += 'T'; + }} + break; + + case 'F': + result += 'F'; + break; + + case 'G': + if (next === 'H') {{ + if (!['A', 'E', 'I', 'O', 'U'].includes(next2)) {{ + i++; // GH silent + }} else {{ + result += 'K'; + }} + }} else if (next === 'N' && (i === word.length - 2 || (next2 === 'E' && i === word.length - 3))) {{ + // GN at end or GNE at end is silent + }} else if (['I', 'E', 'Y'].includes(next)) {{ + result += 'J'; // Soft G + }} else {{ + result += 'K'; // Hard G + }} + break; + + case 'H': + // H only if followed by vowel and not after vowel + if (['A', 'E', 'I', 'O', 'U'].includes(next) && !['A', 'E', 'I', 'O', 'U'].includes(prev)) {{ + result += 'H'; + }} + break; + + case 'J': + result += 'J'; + break; + + case 'K': + if (prev !== 'C') result += 'K'; + break; + + case 'L': + result += 'L'; + break; + + case 'M': + result += 'M'; + break; + + case 'N': + result += 'N'; + break; + + case 'P': + if (next === 'H') {{ + result += 'F'; + i++; + }} else {{ + result += 'P'; + }} + break; + + case 'Q': + result += 'K'; + break; + + case 'R': + result += 'R'; + break; + + case 'S': + if (next === 'H') {{ + result += 'X'; + i++; + }} else if (next === 'I' && ['O', 'A'].includes(next2)) {{ + result += 'X'; // SIO, SIA -> X + }} else {{ + result += 'S'; + }} + break; + + case 'T': + if (next === 'I' && ['O', 'A'].includes(next2)) {{ + result += 'X'; // TIO, TIA -> X + }} else if (next === 'H') {{ + result += '0'; // TH -> 0 (theta) + i++; + }} else if (next !== 'C' || next2 !== 'H') {{ + result += 'T'; + }} + break; + + case 'V': + result += 'F'; + break; + + case 'W': + if (['A', 'E', 'I', 'O', 'U'].includes(next)) result += 'W'; + break; + + case 'X': + result += 'KS'; + break; + + case 'Y': + if (['A', 'E', 'I', 'O', 'U'].includes(next)) result += 'Y'; + break; + + case 'Z': + result += 'S'; + break; + }} + i++; + }} + return result; + }} + + // Get phonetic codes for all words in a string + function getPhoneticCodes(text) {{ + if (!text) return []; + const words = text.toLowerCase().replace(/[^a-z\\s]/g, '').split(/\\s+/); + return words.filter(w => w.length > 2).map(w => ({{ word: w, code: metaphone(w) }})); + }} + + // Check if query matches any word phonetically + function phoneticMatch(queryCodes, targetCodes) {{ + for (const qc of queryCodes) {{ + for (const tc of targetCodes) {{ + if (qc.code && tc.code && qc.code === tc.code && qc.word !== tc.word) {{ + return tc.word; // Return the matched word for highlighting + }} + }} + }} + return null; + }} + // Build search index from PAGES function buildSearchIndex() {{ const index = []; @@ -2023,13 +2227,19 @@ def generate_html(docs, logo_data, lang='en'): .trim() .substring(0, 200); + // Compute phonetic codes for title and headings + const titleCodes = getPhoneticCodes(title); + const headingCodes = headings.flatMap(h => getPhoneticCodes(h)); + index.push({{ title: title, pageId: page.id, section: section, headings: headings, content: page.content.toLowerCase(), - preview: preview + preview: preview, + titleCodes: titleCodes, + headingCodes: headingCodes }}); }} return index; @@ -2042,12 +2252,14 @@ def generate_html(docs, logo_data, lang='en'): if (!query || query.length < 2) return []; const q = query.toLowerCase().trim(); + const queryCodes = getPhoneticCodes(q); const results = []; for (const item of searchIndex) {{ let score = 0; let matchedHeading = null; let matchContext = ''; + let phoneticMatchWord = null; // Check title (highest priority) const titleLower = item.title.toLowerCase(); @@ -2084,6 +2296,32 @@ def generate_html(docs, logo_data, lang='en'): if (end < item.content.length) matchContext = matchContext + '...'; }} + // Phonetic matching (finds similar-sounding words even with typos) + if (score === 0 && queryCodes.length > 0) {{ + // Check title phonetically + const titleMatch = phoneticMatch(queryCodes, item.titleCodes); + if (titleMatch) {{ + score = 35; // Phonetic title match + phoneticMatchWord = titleMatch; + }} + + // Check headings phonetically + if (!titleMatch) {{ + const headingMatch = phoneticMatch(queryCodes, item.headingCodes); + if (headingMatch) {{ + score = Math.max(score, 25); // Phonetic heading match + phoneticMatchWord = headingMatch; + // Find the heading containing this match + for (const heading of item.headings) {{ + if (heading.toLowerCase().includes(headingMatch)) {{ + matchedHeading = heading; + break; + }} + }} + }} + }} + }} + if (score > 0) {{ results.push({{ title: item.title, @@ -2092,7 +2330,8 @@ def generate_html(docs, logo_data, lang='en'): score: score, matchedHeading: matchedHeading, preview: matchContext || item.preview, - query: q + query: q, + phoneticMatch: phoneticMatchWord }}); }} }} @@ -2118,13 +2357,20 @@ def generate_html(docs, logo_data, lang='en'): }} const html = results.map((result, index) => {{ - const titleHtml = highlightText(result.title, query); - const previewHtml = highlightText(result.preview, query); + // Highlight both query and phonetic match word + let titleHtml = highlightText(result.title, query); + let previewHtml = highlightText(result.preview, query); + if (result.phoneticMatch) {{ + titleHtml = highlightText(titleHtml, result.phoneticMatch); + previewHtml = highlightText(previewHtml, result.phoneticMatch); + }} const selectedClass = index === selectedIndex ? ' selected' : ''; + const phoneticIndicator = result.phoneticMatch ? + `sounds like "${{result.phoneticMatch}}"` : ''; return `
- ${{result.section ? `
${{result.section}}
` : ''}} + ${{result.section ? `
${{result.section}}${{phoneticIndicator}}
` : phoneticIndicator}}
${{titleHtml}}
${{result.matchedHeading ? `
${{highlightText(result.matchedHeading, query)}}
` : ''}}
${{previewHtml}}
diff --git a/docs.html b/docs.html index c5f1523..1044b2a 100644 --- a/docs.html +++ b/docs.html @@ -531,6 +531,19 @@ font-size: 0.9rem; } + .search-phonetic-badge { + display: inline-block; + margin-left: 0.5rem; + padding: 0.15rem 0.4rem; + background: var(--sage); + color: var(--pine); + border-radius: 4px; + font-size: 0.7rem; + font-style: italic; + text-transform: none; + letter-spacing: normal; + } + .search-shortcut { display: none; margin-left: 0.5rem; @@ -1217,6 +1230,197 @@

Hemlock Language Manual

let selectedIndex = -1; let currentResults = []; + // Metaphone phonetic encoding algorithm + // Converts words to phonetic codes for fuzzy matching + function metaphone(word) { + if (!word) return ''; + word = word.toUpperCase(); + let result = ''; + let i = 0; + + // Skip initial silent letters + if (word.length >= 2) { + const start = word.substring(0, 2); + if (['KN', 'GN', 'PN', 'AE', 'WR'].includes(start)) { + i = 1; + } + } + + while (i < word.length && result.length < 6) { + const c = word[i]; + const prev = i > 0 ? word[i - 1] : ''; + const next = i < word.length - 1 ? word[i + 1] : ''; + const next2 = i < word.length - 2 ? word[i + 2] : ''; + + // Skip duplicates except CC + if (c === prev && c !== 'C') { + i++; + continue; + } + + switch (c) { + case 'A': case 'E': case 'I': case 'O': case 'U': + // Vowels only kept at start + if (i === 0) result += c; + break; + + case 'B': + // B silent after M at end + if (!(prev === 'M' && i === word.length - 1)) result += 'B'; + break; + + case 'C': + if (next === 'I' && next2 === 'A') { + result += 'X'; // CIA -> X + } else if (['I', 'E', 'Y'].includes(next)) { + result += 'S'; // Soft C + } else if (next === 'H') { + result += 'X'; // CH -> X + i++; + } else { + result += 'K'; // Hard C + } + break; + + case 'D': + if (next === 'G' && ['I', 'E', 'Y'].includes(next2)) { + result += 'J'; // DGE, DGI, DGY -> J + i++; + } else { + result += 'T'; + } + break; + + case 'F': + result += 'F'; + break; + + case 'G': + if (next === 'H') { + if (!['A', 'E', 'I', 'O', 'U'].includes(next2)) { + i++; // GH silent + } else { + result += 'K'; + } + } else if (next === 'N' && (i === word.length - 2 || (next2 === 'E' && i === word.length - 3))) { + // GN at end or GNE at end is silent + } else if (['I', 'E', 'Y'].includes(next)) { + result += 'J'; // Soft G + } else { + result += 'K'; // Hard G + } + break; + + case 'H': + // H only if followed by vowel and not after vowel + if (['A', 'E', 'I', 'O', 'U'].includes(next) && !['A', 'E', 'I', 'O', 'U'].includes(prev)) { + result += 'H'; + } + break; + + case 'J': + result += 'J'; + break; + + case 'K': + if (prev !== 'C') result += 'K'; + break; + + case 'L': + result += 'L'; + break; + + case 'M': + result += 'M'; + break; + + case 'N': + result += 'N'; + break; + + case 'P': + if (next === 'H') { + result += 'F'; + i++; + } else { + result += 'P'; + } + break; + + case 'Q': + result += 'K'; + break; + + case 'R': + result += 'R'; + break; + + case 'S': + if (next === 'H') { + result += 'X'; + i++; + } else if (next === 'I' && ['O', 'A'].includes(next2)) { + result += 'X'; // SIO, SIA -> X + } else { + result += 'S'; + } + break; + + case 'T': + if (next === 'I' && ['O', 'A'].includes(next2)) { + result += 'X'; // TIO, TIA -> X + } else if (next === 'H') { + result += '0'; // TH -> 0 (theta) + i++; + } else if (next !== 'C' || next2 !== 'H') { + result += 'T'; + } + break; + + case 'V': + result += 'F'; + break; + + case 'W': + if (['A', 'E', 'I', 'O', 'U'].includes(next)) result += 'W'; + break; + + case 'X': + result += 'KS'; + break; + + case 'Y': + if (['A', 'E', 'I', 'O', 'U'].includes(next)) result += 'Y'; + break; + + case 'Z': + result += 'S'; + break; + } + i++; + } + return result; + } + + // Get phonetic codes for all words in a string + function getPhoneticCodes(text) { + if (!text) return []; + const words = text.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/); + return words.filter(w => w.length > 2).map(w => ({ word: w, code: metaphone(w) })); + } + + // Check if query matches any word phonetically + function phoneticMatch(queryCodes, targetCodes) { + for (const qc of queryCodes) { + for (const tc of targetCodes) { + if (qc.code && tc.code && qc.code === tc.code && qc.word !== tc.word) { + return tc.word; // Return the matched word for highlighting + } + } + } + return null; + } + // Build search index from PAGES function buildSearchIndex() { const index = []; @@ -1248,13 +1452,19 @@

Hemlock Language Manual

.trim() .substring(0, 200); + // Compute phonetic codes for title and headings + const titleCodes = getPhoneticCodes(title); + const headingCodes = headings.flatMap(h => getPhoneticCodes(h)); + index.push({ title: title, pageId: page.id, section: section, headings: headings, content: page.content.toLowerCase(), - preview: preview + preview: preview, + titleCodes: titleCodes, + headingCodes: headingCodes }); } return index; @@ -1267,12 +1477,14 @@

Hemlock Language Manual

if (!query || query.length < 2) return []; const q = query.toLowerCase().trim(); + const queryCodes = getPhoneticCodes(q); const results = []; for (const item of searchIndex) { let score = 0; let matchedHeading = null; let matchContext = ''; + let phoneticMatchWord = null; // Check title (highest priority) const titleLower = item.title.toLowerCase(); @@ -1309,6 +1521,32 @@

Hemlock Language Manual

if (end < item.content.length) matchContext = matchContext + '...'; } + // Phonetic matching (finds similar-sounding words even with typos) + if (score === 0 && queryCodes.length > 0) { + // Check title phonetically + const titleMatch = phoneticMatch(queryCodes, item.titleCodes); + if (titleMatch) { + score = 35; // Phonetic title match + phoneticMatchWord = titleMatch; + } + + // Check headings phonetically + if (!titleMatch) { + const headingMatch = phoneticMatch(queryCodes, item.headingCodes); + if (headingMatch) { + score = Math.max(score, 25); // Phonetic heading match + phoneticMatchWord = headingMatch; + // Find the heading containing this match + for (const heading of item.headings) { + if (heading.toLowerCase().includes(headingMatch)) { + matchedHeading = heading; + break; + } + } + } + } + } + if (score > 0) { results.push({ title: item.title, @@ -1317,7 +1555,8 @@

Hemlock Language Manual

score: score, matchedHeading: matchedHeading, preview: matchContext || item.preview, - query: q + query: q, + phoneticMatch: phoneticMatchWord }); } } @@ -1343,13 +1582,20 @@

Hemlock Language Manual

} const html = results.map((result, index) => { - const titleHtml = highlightText(result.title, query); - const previewHtml = highlightText(result.preview, query); + // Highlight both query and phonetic match word + let titleHtml = highlightText(result.title, query); + let previewHtml = highlightText(result.preview, query); + if (result.phoneticMatch) { + titleHtml = highlightText(titleHtml, result.phoneticMatch); + previewHtml = highlightText(previewHtml, result.phoneticMatch); + } const selectedClass = index === selectedIndex ? ' selected' : ''; + const phoneticIndicator = result.phoneticMatch ? + `sounds like "${result.phoneticMatch}"` : ''; return `
- ${result.section ? `
${result.section}
` : ''} + ${result.section ? `
${result.section}${phoneticIndicator}
` : phoneticIndicator}
${titleHtml}
${result.matchedHeading ? `
${highlightText(result.matchedHeading, query)}
` : ''}
${previewHtml}