@ihabdevteam/core 0.322.0 → 0.324.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +557 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +55 -17
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/Icon.js +2 -2
  24. package/dist/packages/core/src/components/ItemActions.js +16 -16
  25. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  26. package/dist/packages/core/src/components/LineChart.js +24 -24
  27. package/dist/packages/core/src/components/Loader.js +5 -5
  28. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  29. package/dist/packages/core/src/components/PageTitle.js +2 -2
  30. package/dist/packages/core/src/components/Pagination.js +4 -4
  31. package/dist/packages/core/src/components/Panel.js +2 -2
  32. package/dist/packages/core/src/components/PanelRow.js +6 -13
  33. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  34. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  35. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  36. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  37. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  38. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
  39. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  40. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
  41. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  42. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  43. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  44. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  45. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  46. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  47. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  48. package/dist/packages/core/src/components/Rating.js +20 -20
  49. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  50. package/dist/packages/core/src/components/RingChart.js +5 -5
  51. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  52. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  53. package/dist/packages/core/src/components/SideNav.js +37 -37
  54. package/dist/packages/core/src/components/Slider.js +2 -2
  55. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  56. package/dist/packages/core/src/components/Table.js +257 -98
  57. package/dist/packages/core/src/components/Tabs.js +12 -12
  58. package/dist/packages/core/src/components/TextField.js +34 -34
  59. package/dist/packages/core/src/components/Toast.js +6 -6
  60. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  61. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  63. package/dist/packages/core/src/components/TopBanner.js +28 -28
  64. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  65. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  66. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  67. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  68. package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
  69. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  70. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  71. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  72. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  73. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  74. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
  75. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  76. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
  77. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  78. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  79. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  80. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
  81. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  82. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  83. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  84. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  85. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  86. package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
  87. package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
  88. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  89. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  90. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  91. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  92. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  93. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  94. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  95. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  96. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  97. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  98. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  99. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  100. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  101. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
  102. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  103. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  104. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  105. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  106. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  107. package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
  108. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  109. package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
  110. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  111. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  112. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  113. package/dist/packages/core/src/components/WordTable.js +130 -130
  114. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  115. package/dist/packages/core/src/components/barChartData.js +49 -29
  116. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  117. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  118. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  119. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  120. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  121. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  122. package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
  123. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  124. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  125. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  126. package/dist/packages/core/src/hooks/inputModality.js +13 -13
  127. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  128. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  129. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  130. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  131. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  132. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  133. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  134. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  135. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  136. package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
  137. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  138. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  139. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  140. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  141. package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
  142. package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
  143. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  144. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  145. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  146. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  147. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
  148. package/dist/packages/core/src/i18n/locales/en.json +14 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +13 -1
  150. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  151. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  152. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  153. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  154. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  155. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  156. package/dist/packages/core/src/utils/clipboard.js +3 -3
  157. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  158. package/dist/packages/core/src/utils/date.js +12 -12
  159. package/dist/packages/core/src/utils/devWarn.js +4 -4
  160. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  161. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  162. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  163. package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
  164. package/dist/packages/core/src/utils/format.js +4 -4
  165. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  166. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  167. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  168. package/dist/packages/core/src/utils/navScroll.js +2 -2
  169. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  170. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  171. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  172. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  173. package/dist/packages/core/src/utils/phonemePairSearch.js +129 -105
  174. package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  176. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  177. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  178. package/dist/packages/core/src/utils/safeHref.js +4 -4
  179. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  180. package/dist/packages/core/src/utils/speak.js +12 -12
  181. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  182. package/dist/packages/core/src/utils/textSort.js +38 -48
  183. package/dist/packages/core/src/utils/theme.js +3 -3
  184. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  185. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  186. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +1 -1
  187. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -45
  188. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  189. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  190. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  191. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  192. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  193. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  194. package/dist/packages/core/src/utils/wornDate.js +14 -14
  195. package/dist/packages/core/src/version.d.ts +1 -1
  196. package/dist/packages/core/src/version.js +1 -1
  197. package/dist/utils.cjs.js +4 -4
  198. package/package.json +1 -1
@@ -8,15 +8,20 @@ function decompose(char) {
8
8
  if (code < 0 || code > 11171)
9
9
  return null;
10
10
  return {
11
- 초성: CHO[Math.floor(code / 588)],
12
- 모음: JUNG[Math.floor((code % 588) / 28)],
13
- 종성: JONG[code % 28] || '',
11
+ onset: CHO[Math.floor(code / 588)],
12
+ vowel: JUNG[Math.floor((code % 588) / 28)],
13
+ coda: JONG[code % 28] || '',
14
14
  };
15
15
  }
16
- const SECTION_KEYS = ['초성', '모음', '종성', '활음'];
17
- function isSectionKey(value) {
18
- return SECTION_KEYS.includes(value);
19
- }
16
+ /* 음절 자리 코드는 **아래 모듈**에 있다 — `phonemeConvert` 도 함께 쓰는데, 여기 두면
17
+ `phonemePairSearch ↔ phonemeConvert` 돌림 import 가 된다(한 번 그렇게 해 시험 338 꾸러미가
18
+ 무너졌다, 2026-10-13). */
19
+ import { toPhonemeCodes, toLegacyPhonemeKey, isPhonemeSectionCode, PHONEME_SECTION_CODES, PHONEME_NONE } from './phonemeSectionCodes';
20
+ export { toPhonemeCodes, PHONEME_NONE } from './phonemeSectionCodes';
21
+ const SECTION_KEYS = PHONEME_SECTION_CODES;
22
+ const NONE = PHONEME_NONE;
23
+ const isSectionKey = isPhonemeSectionCode;
24
+ const toCodes = toPhonemeCodes;
20
25
  // 발음형 분해: 모음을 활음 + 핵모음으로 분리
21
26
  const VOWEL_GLIDE = {
22
27
  ㅑ: 'j', ㅒ: 'j', ㅕ: 'j', ㅖ: 'j', ㅛ: 'j', ㅠ: 'j',
@@ -38,10 +43,10 @@ function decomposePhonemicSyllable(char) {
38
43
  const rawJung = JUNG[jungIdx];
39
44
  const rawJong = JONG[jongIdx];
40
45
  return {
41
- 초성: rawCho === 'ㅇ' ? '없음' : rawCho,
42
- 활음: VOWEL_GLIDE[rawJung] ?? '없음',
43
- 모음: VOWEL_NUCLEUS[rawJung] ?? rawJung,
44
- 종성: rawJong || '없음',
46
+ onset: rawCho === 'ㅇ' ? NONE : rawCho,
47
+ glide: VOWEL_GLIDE[rawJung] ?? NONE,
48
+ vowel: VOWEL_NUCLEUS[rawJung] ?? rawJung,
49
+ coda: rawJong || NONE,
45
50
  };
46
51
  }
47
52
  function analyzeMinimalPair(word1, word2, phonemic = false) {
@@ -94,8 +99,8 @@ function diffHasEmptyPair(section, vals) {
94
99
  // '활음' 섹션의 '없음'은 비움으로 보지 않는다 — 발음 모드에서 사용자가 활음 자리를 **직접 골라** 찾는 대립(ㅐ↔ㅙ = 없음↔w)이라
95
100
  // 그 검색 결과를 이 필터가 통째로 지우면 안 된다.
96
101
  const isEmpty = (v) => v === '' ||
97
- (section === '종성' && v === '없음') ||
98
- (section === '초성' && (v === 'ㅇ' || v === '없음'));
102
+ (section === 'coda' && v === NONE) ||
103
+ (section === 'onset' && (v === 'ㅇ' || v === NONE));
99
104
  if (isEmpty(vals[0]) || isEmpty(vals[1]))
100
105
  return true;
101
106
  // 모음(글자 모드) 활음 구조 차이: 두 모음의 반모음(활음)이 다르면 음절 '구조' 차이로 본다.
@@ -104,7 +109,7 @@ function diffHasEmptyPair(section, vals) {
104
109
  // 발음 모드는 모음=핵모음이라 둘 다 활음 없음으로 읽혀 영향 없음. 활음 대립은
105
110
  // 별도 '활음' 섹션에서 처리되므로 여기(모음)에서 걸러도 활음 지정 대립과 충돌하지 않는다.
106
111
  // 단어훈련 런타임(wordContrast.hasStructureContrast)도 **같은 함수**로 판정한다.
107
- if (section === '모음' && vowelGlidesDiffer(vals[0], vals[1]))
112
+ if (section === 'vowel' && vowelGlidesDiffer(vals[0], vals[1]))
108
113
  return true;
109
114
  return false;
110
115
  }
@@ -122,29 +127,31 @@ const DEFAULT_PAIR_FILTER_KEYS = {
122
127
  syllable5plus: '5음절 이상',
123
128
  partOfSpeech: '품사',
124
129
  wordType: '유형',
130
+ field: '의미범주',
125
131
  posMap: POS_GROUP_MAP_KO,
126
132
  typeMap: WORD_TYPE_MAP_KO,
127
133
  };
128
- const 모은것 = new WeakMap();
129
- function 모으기(filterSelected, keys) {
130
- const 있음 = 모은것.get(filterSelected);
131
- if (있음 && 있음.keys === keys)
132
- return 있음.값;
133
- const 값 = { grades: [], syls: [], poses: [], types: [] };
134
- const 칸 = { grade: 값.grades, syllables: 값.syls, pos: 값.poses, wordType: 값.types };
134
+ const collected = new WeakMap();
135
+ function collect(filterSelected, keys) {
136
+ const hit = collected.get(filterSelected);
137
+ if (hit && hit.keys === keys)
138
+ return hit.value;
139
+ const value = { grades: [], syls: [], poses: [], types: [], fields: [] };
140
+ const bySection = { grade: value.grades, syllables: value.syls, pos: value.poses, wordType: value.types, field: value.fields };
135
141
  for (const [label, raw] of Object.entries(filterSelected ?? {})) {
136
142
  /* 섹션은 넘긴 keys 의 라벨이면 그것, 아니면 어느 언어·옛 라벨·코드든 읽는다(wordAttributeSectionOf) */
137
143
  const s = label === keys.difficulty ? 'grade' : label === keys.syllables ? 'syllables'
138
144
  : label === keys.partOfSpeech ? 'pos' : label === keys.wordType ? 'wordType'
139
- : wordAttributeSectionOf(label);
145
+ : label === keys.field ? 'field'
146
+ : wordAttributeSectionOf(label);
140
147
  if (!s)
141
148
  continue;
142
149
  for (const v of Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [])
143
- if (!칸[s].includes(v))
144
- 칸[s].push(v);
150
+ if (!bySection[s].includes(v))
151
+ bySection[s].push(v);
145
152
  }
146
- 모은것.set(filterSelected, { keys, 값 });
147
- return 값;
153
+ collected.set(filterSelected, { keys, value });
154
+ return value;
148
155
  }
149
156
  /**
150
157
  * 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리).
@@ -154,7 +161,7 @@ function 모으기(filterSelected, keys) {
154
161
  * **풀리고** 품사·어종 값은 posMap 에 없어 **모든 단어가 떨어졌다**.
155
162
  */
156
163
  export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
157
- const { grades, syls, poses, types } = 모으기(filterSelected, keys);
164
+ const { grades, syls, poses, types } = collect(filterSelected, keys);
158
165
  if (grades.length) {
159
166
  /* **`없음` 은 값이 아니라 빔이다**((205), 2026-10-12) — 거르는 결정은 `matchesGradeCodes` **하나**다
160
167
  (단어훈련 풀도 같은 것을 부른다). 여기서는 라벨을 코드로만 바꾼다.
@@ -188,8 +195,14 @@ export function applyFilters(filterSelected, words, keys = DEFAULT_PAIR_FILTER_K
188
195
  return words.filter(entry => {
189
196
  if (!matchesFilters({ grade: entry.grade, syllables: [...entry.word].length, pos: entry.pos, wordType: entry.wordType }, filterSelected, keys))
190
197
  return false;
191
- const fields = filterSelected['의미범주'];
192
- if (fields?.length) {
198
+ /* **의미범주도 다른 넷과 같은 길로 읽는다**(사장님 지시 2026-10-14, 말귀 실측으로 다리는
199
+ 필요 없음이 확인됐다 — 앱 소스 0건·저장 구성 416줄 0건).
200
+
201
+ 전에는 `filterSelected['의미범주']` 로 **그 다섯 글자만** 찾았다. 다른 절은 어느 언어
202
+ 라벨이든 읽는데 이 하나만 안 그랬고, 영어 라벨로 부르면 **이 필터만 조용히 안 걸렸다**
203
+ (에러도 없다). 같은 꼴을 등급·음절에서 이미 겪어 고쳤던 자리다. */
204
+ const { fields } = collect(filterSelected, keys);
205
+ if (fields.length) {
193
206
  const normalized = (entry.field ?? '').replace(/전문\(|\)/g, '');
194
207
  if (!fields.some(f => normalized.includes(f)))
195
208
  return false;
@@ -278,9 +291,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
278
291
  const uniqueWordsCount = new Set(words.map(w => w.word)).size;
279
292
  const bySection = {};
280
293
  const bySectionValueOrder = {};
281
- for (let i = 0; i < selectedList.length; i++) {
282
- for (const [section, value] of Object.entries(selectedList[i])) {
283
- if (!isSectionKey(section))
294
+ /* **들어오는 자리에서 한 번 옮긴다** — 저장된 단어표의 `phonemeSelected` 는 옛 한글 열쇠일
295
+ 수 있다(`{ 초성: 'ㄴ' }`). 안쪽은 코드만 보므로 여기서 가른다. */
296
+ const codedList = selectedList.map(toCodes);
297
+ for (let i = 0; i < codedList.length; i++) {
298
+ for (const [section, value] of Object.entries(codedList[i])) {
299
+ /* **`!value` 로 거르지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 뜻이고,
300
+ 옮기며 그 줄을 더했다가 `받침 있음↔없음` 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
301
+ *옮기기에 거르는 줄을 끼워 넣으면 뜻이 바뀐다.* */
302
+ if (!isSectionKey(section) || value === undefined)
284
303
  continue;
285
304
  if (!bySection[section])
286
305
  bySection[section] = new Set();
@@ -315,16 +334,16 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
315
334
  const targetKeys = new Set(sectionEntries.map(([s]) => s));
316
335
  const contrastSections = new Set(sectionEntries.filter(([, vals]) => vals.size >= 2).map(([s]) => s));
317
336
  const encodeAll = phonemic
318
- ? (s) => s.초성 + (s.활음 ?? '') + s.모음 + s.종성
319
- : (s) => s.초성 + s.모음 + s.종성;
337
+ ? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
338
+ : (s) => s.onset + s.vowel + s.coda;
320
339
  const encodeWild = phonemic
321
- ? (s) => (targetKeys.has('초성') ? '*' : s.초성) +
322
- (targetKeys.has('활음') ? '*' : (s.활음 ?? '')) +
323
- (targetKeys.has('모음') ? '*' : s.모음) +
324
- (targetKeys.has('종성') ? '*' : s.종성)
325
- : (s) => (targetKeys.has('초성') ? '*' : s.초성) +
326
- (targetKeys.has('모음') ? '*' : s.모음) +
327
- (targetKeys.has('종성') ? '*' : s.종성);
340
+ ? (s) => (targetKeys.has('onset') ? '*' : s.onset) +
341
+ (targetKeys.has('glide') ? '*' : (s.glide ?? '')) +
342
+ (targetKeys.has('vowel') ? '*' : s.vowel) +
343
+ (targetKeys.has('coda') ? '*' : s.coda)
344
+ : (s) => (targetKeys.has('onset') ? '*' : s.onset) +
345
+ (targetKeys.has('vowel') ? '*' : s.vowel) +
346
+ (targetKeys.has('coda') ? '*' : s.coda);
328
347
  const makeKeyForPosition = (syllables, diffPos) => syllables.map((s, i) => (i === diffPos ? encodeWild(s) : encodeAll(s))).join('|');
329
348
  const gradeOrder = (g) => {
330
349
  const parsed = Number.parseInt(g ?? '', 10);
@@ -335,9 +354,9 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
335
354
  한 묶음의 낱말은 모두 같은 자리를 비운 것이다. 분해한 음절도 함께 든다(견줄 때마다 다시 분해하지 않게). */
336
355
  const groups = new Map();
337
356
  /* 표기가 같아도 뜻마다 칸이 하나다 — 판정은 대표로 한 번만 하고 결과에서 뜻을 펼친다 */
338
- const { 대표들, 뜻들 } = 뜻묶기(words);
357
+ const { heads, senses } = groupBySense(words);
339
358
  const seenWords = new Set();
340
- for (const entry of 대표들) {
359
+ for (const entry of heads) {
341
360
  seenWords.add(entry.word);
342
361
  const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
343
362
  const syllables = [...cleanPron(entry.pronunciation)].map(decomposeFn);
@@ -346,25 +365,25 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
346
365
  const syls = syllables;
347
366
  for (let p = 0; p < syls.length; p++) {
348
367
  const key = makeKeyForPosition(syls, p);
349
- let 묶음 = groups.get(key);
350
- if (!묶음) {
351
- 묶음 = { 비운자리: p, 낱말들: [] };
352
- groups.set(key, 묶음);
368
+ let group = groups.get(key);
369
+ if (!group) {
370
+ group = { emptyAt: p, members: [] };
371
+ groups.set(key, group);
353
372
  }
354
- 묶음.낱말들.push({ entry, syls });
373
+ group.members.push({ entry, syls });
355
374
  }
356
375
  }
357
376
  const results = [];
358
377
  const rejectCounts = createRejectCounter();
359
378
  const rejectSamples = [];
360
379
  let comparedPairs = 0;
361
- for (const { 비운자리, 낱말들 } of groups.values()) {
362
- if (낱말들.length < 2)
380
+ for (const { emptyAt, members } of groups.values()) {
381
+ if (members.length < 2)
363
382
  continue;
364
- for (let i = 0; i < 낱말들.length; i++) {
365
- for (let j = i + 1; j < 낱말들.length; j++) {
366
- const w1 = 낱말들[i].entry;
367
- const w2 = 낱말들[j].entry;
383
+ for (let i = 0; i < members.length; i++) {
384
+ for (let j = i + 1; j < members.length; j++) {
385
+ const w1 = members[i].entry;
386
+ const w2 = members[j].entry;
368
387
  /* 같은 표기가 발음만 다르게 두 번 들어온 자료(김밥[김밥]·김밥[김빱])는 짝으로 세지 않는다 —
369
388
  칸 둘의 이름이 같은 '김밥/김밥' 쌍이 된다. 견준 쌍을 다 기억하던 전에는 이것이
370
389
  덤으로 막히고 있었다(같은 표기면 열쇠가 하나라 한 번만 지나갔다). */
@@ -383,15 +402,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
383
402
  남은 것은 '한 군데만 다른가' 를 쌍 문자열·발음 분해 전에 여기서 재는 일이다 —
384
403
  analyzeMinimalPair 와 같은 잣대(SECTION_KEYS)를 그 한 자리에만 쓴다. 포함 검색
385
404
  (searchWordsByPhonemes)이 전부터 하던 것과 같은 걸름이다. */
386
- const s1 = 낱말들[i].syls[비운자리];
387
- const s2 = 낱말들[j].syls[비운자리];
388
- let 다른칸수 = 0;
405
+ const s1 = members[i].syls[emptyAt];
406
+ const s2 = members[j].syls[emptyAt];
407
+ let diffCount = 0;
389
408
  for (const sec of SECTION_KEYS)
390
409
  if (s1[sec] !== s2[sec])
391
- 다른칸수 += 1;
410
+ diffCount += 1;
392
411
  comparedPairs += 1;
393
- if (다른칸수 !== 1) {
394
- const reason = 다른칸수 === 0 ? 'no_difference' : 'multiple_differences';
412
+ if (diffCount !== 1) {
413
+ const reason = diffCount === 0 ? 'no_difference' : 'multiple_differences';
395
414
  rejectCounts[reason] += 1;
396
415
  if (debug && rejectSamples.length < 20)
397
416
  rejectSamples.push({ id: `${w1.word}|${w2.word}`, reason });
@@ -421,13 +440,15 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
421
440
  const sDiffVals = analysis.diff.diffs[sDiffSection];
422
441
  const sDiffHasEmpty = diffHasEmptyPair(sDiffSection, sDiffVals);
423
442
  // 뜻마다 한 칸 — '눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍을 이룬다
424
- for (const f of 뜻들.get(first) ?? [first]) {
425
- for (const sec of 뜻들.get(second) ?? [second]) {
443
+ for (const f of senses.get(first) ?? [first]) {
444
+ for (const sec of senses.get(second) ?? [second]) {
426
445
  results.push({
427
- id: `${낱말열쇠(f)}|${낱말열쇠(sec)}`,
428
- diffSection: sDiffSection,
446
+ id: `${wordKey(f)}|${wordKey(sec)}`,
447
+ /* 옛 이름을 내보내고 코드를 나란히 — 소비자가 옛 값으로 묶음을 가른다 */
448
+ diffSection: toLegacyPhonemeKey(sDiffSection),
449
+ diffSectionCode: sDiffSection,
429
450
  diffHasEmpty: sDiffHasEmpty,
430
- words: [낱말칸(f), 낱말칸(sec)],
451
+ words: [wordCells(f), wordCells(sec)],
431
452
  });
432
453
  }
433
454
  }
@@ -469,38 +490,38 @@ export function searchPairs(selectedList, words, enforcePosition, phonemic = fal
469
490
  // 슬롯 i → 단어의 i번째 음절에 조건 매칭 (순서 보장)
470
491
  /** 표기가 같아도 뜻이 다르면 다른 칸 — 칸에 붙는 그림이 뜻마다 다르다(사용자 결정 2026-09-16).
471
492
  * 동음이의 번호가 없는 자료는 예전처럼 표기뿐이다. */
472
- function 낱말열쇠(entry) {
493
+ function wordKey(entry) {
473
494
  return entry.homonum != null ? `${entry.word}#${entry.homonum}` : entry.word;
474
495
  }
475
496
  /** 표기·발음이 같은 뜻들을 한 대표로 묶는다 — 최소대립쌍 판정은 표기와 발음만 보므로 뜻마다 다시 셀 까닭이 없다.
476
497
  * (뜻을 모두 후보로 넣으면 묶음이 커져 23,000 낱말에서 검색이 2–4배 느려졌다.) 결과를 만들 때 뜻마다 칸을 펼친다. */
477
498
  /** 결과 칸 하나 */
478
- function 낱말칸(e) {
499
+ function wordCells(e) {
479
500
  return {
480
501
  label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
481
502
  grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType, isCustom: e.isCustom,
482
503
  };
483
504
  }
484
- function 뜻묶기(words) {
485
- const 자리 = new Map();
486
- const 뜻들 = new Map();
487
- const 대표들 = [];
505
+ function groupBySense(words) {
506
+ const byKey = new Map();
507
+ const senses = new Map();
508
+ const heads = [];
488
509
  for (const entry of words) {
489
- const 열쇠 = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
490
- const 대표 = 자리.get(열쇠);
491
- if (대표 === undefined) {
492
- 자리.set(열쇠, entry);
493
- 대표들.push(entry);
494
- 뜻들.set(entry, [entry]);
510
+ const key = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
511
+ const head = byKey.get(key);
512
+ if (head === undefined) {
513
+ byKey.set(key, entry);
514
+ heads.push(entry);
515
+ senses.set(entry, [entry]);
495
516
  continue;
496
517
  }
497
- const 목록 = 뜻들.get(대표);
518
+ const list = senses.get(head);
498
519
  // 같은 번호가 두 번 오면(자료 중복) 한 번만
499
- if (목록.some(e => 낱말열쇠(e) === 낱말열쇠(entry)))
520
+ if (list.some(e => wordKey(e) === wordKey(entry)))
500
521
  continue;
501
- 목록.push(entry);
522
+ list.push(entry);
502
523
  }
503
- return { 대표들, 뜻들 };
524
+ return { heads, senses };
504
525
  }
505
526
  function wordContainsAllPhonemes(syllables, requirements) {
506
527
  for (let i = 0; i < requirements.length; i++) {
@@ -534,18 +555,20 @@ export function searchWordsByPhonemes(requiredPhonemes, words, ordered = true, p
534
555
  * 이 조건을 만족하는 쪽을 우선한다(예: "검색 단어만" 필터 통과 여부). 지정하지
535
556
  * 않으면 기존 동작(먼저 발견된 방향을 그대로 채택)과 동일하다. */
536
557
  preferAsTarget) {
537
- const reqs = requiredPhonemes.filter(r => Object.keys(r).some(k => isSectionKey(k)));
558
+ /* 들어오는 자리에서 코드로 — 옛 한글 열쇠로 저장된 표도 그대로 읽힌다 */
559
+ const coded = requiredPhonemes.map(toCodes);
560
+ const reqs = coded.filter(r => Object.keys(r).some(k => isSectionKey(k)));
538
561
  if (reqs.length === 0)
539
562
  return [];
540
563
  const encodeAll = phonemic
541
- ? (s) => s.초성 + (s.활음 ?? '') + s.모음 + s.종성
542
- : (s) => s.초성 + s.모음 + s.종성;
564
+ ? (s) => s.onset + (s.glide ?? '') + s.vowel + s.coda
565
+ : (s) => s.onset + s.vowel + s.coda;
543
566
  const makeKey = (syls, pos) => syls.map((s, i) => (i === pos ? '*' : encodeAll(s))).join('|');
544
567
  const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
545
568
  const allWords = [];
546
- const { 대표들, 뜻들 } = 뜻묶기(words);
569
+ const { heads, senses } = groupBySense(words);
547
570
  const seenWords = new Set();
548
- for (const entry of 대표들) {
571
+ for (const entry of heads) {
549
572
  seenWords.add(entry.word);
550
573
  const syls = [...cleanPron(entry.pronunciation)].map(decomposeFn);
551
574
  if (syls.some(s => s === null))
@@ -563,7 +586,7 @@ preferAsTarget) {
563
586
  /* 일치(ordered) 는 조건의 자리가 곧 음절 자리다 — 빈 슬롯을 걸러 낸 reqs 를 쓰면 [{}, {초성:ㄴ}] 의 ㄴ 이 첫 음절에 걸렸다.
564
587
  wordContainsAllPhonemes 는 빈 슬롯을 스스로 건너뛴다. */
565
588
  return ordered
566
- ? wordContainsAllPhonemes(filterSyls, requiredPhonemes)
589
+ ? wordContainsAllPhonemes(filterSyls, coded)
567
590
  : wordContainsAllPhonemesUnordered(filterSyls, reqs);
568
591
  });
569
592
  const groups = new Map();
@@ -575,7 +598,7 @@ preferAsTarget) {
575
598
  groups.get(key).push(w);
576
599
  }
577
600
  }
578
- const 쌍들 = [];
601
+ const pairs = [];
579
602
  // dedupKey → 쌍들 배열 내 인덱스. 같은 두 단어가 서로 minimal pair이면서
580
603
  // 둘 다 독립적으로 targetWords에 속할 수 있어(예: 다른 음절에 요구 음소가 하나 더 있는 경우),
581
604
  // 어느 쪽을 target(1번/words[0])으로 채택할지가 순회 순서에 좌우될 수 있다.
@@ -594,13 +617,13 @@ preferAsTarget) {
594
617
  {
595
618
  const cs = candidate.syls[p];
596
619
  let d = 0;
597
- if (ts.초성 !== cs.초성)
620
+ if (ts.onset !== cs.onset)
598
621
  d++;
599
- if (ts.모음 !== cs.모음)
622
+ if (ts.vowel !== cs.vowel)
600
623
  d++;
601
- if (ts.종성 !== cs.종성)
624
+ if (ts.coda !== cs.coda)
602
625
  d++;
603
- if ((ts.활음 ?? '') !== (cs.활음 ?? ''))
626
+ if ((ts.glide ?? '') !== (cs.glide ?? ''))
604
627
  d++;
605
628
  if (d !== 1)
606
629
  continue;
@@ -612,7 +635,7 @@ preferAsTarget) {
612
635
  if (existingIdx !== undefined) {
613
636
  if (!preferAsTarget)
614
637
  continue;
615
- if (preferAsTarget(낱말칸(쌍들[existingIdx].target)))
638
+ if (preferAsTarget(wordCells(pairs[existingIdx].target)))
616
639
  continue;
617
640
  if (!preferAsTarget({ grade: target.entry.grade, syllables: [...target.entry.word].length, pos: target.entry.pos, wordType: target.entry.wordType }))
618
641
  continue;
@@ -635,12 +658,12 @@ preferAsTarget) {
635
658
  if (tv !== cv)
636
659
  diffValues = [tv, cv];
637
660
  }
638
- const 맞나 = (req) => {
661
+ const matches = (req) => {
639
662
  const reqVal = req?.[diffSection];
640
663
  return reqVal !== undefined && (diffValues[0] === reqVal || diffValues[1] === reqVal);
641
664
  };
642
665
  // 일치(ordered): "첫번째 음절에서 초성 ㄱ 이 대립" — 대립한 자리의 조건이어야 한다(다른 음절에서 대립한 쌍이 섞였다)
643
- const isRelevant = ordered ? 맞나(requiredPhonemes[analysis.diff.idx]) : reqs.some(맞나);
666
+ const isRelevant = ordered ? matches(coded[analysis.diff.idx]) : reqs.some(matches);
644
667
  if (!isRelevant)
645
668
  continue;
646
669
  }
@@ -649,26 +672,27 @@ preferAsTarget) {
649
672
  const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
650
673
  const item = { target: target.entry, candidate: candidate.entry, diffSection, diffHasEmpty };
651
674
  if (existingIdx !== undefined) {
652
- 쌍들[existingIdx] = item;
675
+ pairs[existingIdx] = item;
653
676
  }
654
677
  else {
655
- seenPairs.set(dedupKey, 쌍들.length);
656
- 쌍들.push(item);
678
+ seenPairs.set(dedupKey, pairs.length);
679
+ pairs.push(item);
657
680
  }
658
681
  }
659
682
  }
660
683
  }
661
684
  /* 뜻마다 한 칸 — 기준·짝의 뜻을 모두 펼친다('눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍). id 는 정렬 꼴에 번호까지. */
662
685
  const results = [];
663
- for (const 것 of 쌍들) {
664
- for (const t of 뜻들.get(것.target) ?? [것.target]) {
665
- for (const c of 뜻들.get(것.candidate) ?? [것.candidate]) {
666
- const kt = 낱말열쇠(t), kc = 낱말열쇠(c);
686
+ for (const item of pairs) {
687
+ for (const t of senses.get(item.target) ?? [item.target]) {
688
+ for (const c of senses.get(item.candidate) ?? [item.candidate]) {
689
+ const kt = wordKey(t), kc = wordKey(c);
667
690
  results.push({
668
691
  id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
669
- diffSection: 것.diffSection,
670
- diffHasEmpty: 것.diffHasEmpty,
671
- words: [낱말칸(t), 낱말칸(c)],
692
+ diffSection: toLegacyPhonemeKey(item.diffSection),
693
+ diffSectionCode: item.diffSection,
694
+ diffHasEmpty: item.diffHasEmpty,
695
+ words: [wordCells(t), wordCells(c)],
672
696
  });
673
697
  }
674
698
  }
@@ -25,6 +25,8 @@ export interface PairFilterKeys {
25
25
  syllable5plus: string;
26
26
  partOfSpeech: string;
27
27
  wordType: string;
28
+ /** 의미범주 절의 이름표. **안 줘도 된다** — 안 주면 어느 언어 라벨이든 사전으로 읽는다(0.324.0). */
29
+ field?: string;
28
30
  posMap: Record<string, string[]>;
29
31
  typeMap: Record<string, string>;
30
32
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
3
+ *
4
+ * ## 왜 제 모듈인가
5
+ *
6
+ * 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
7
+ * `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
8
+ * 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
9
+ * 한쪽이 아니라 그 아래**에 둔다.
10
+ *
11
+ * ## 왜 코드인가
12
+ *
13
+ * 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
14
+ * `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
15
+ * 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
16
+ *
17
+ * **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
18
+ * 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
19
+ *
20
+ * 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
21
+ * `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
22
+ *
23
+ * **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
24
+ */
25
+ /** 음절 자리 코드 @public */
26
+ export type PhonemeSectionCode = 'onset' | 'vowel' | 'coda' | 'glide';
27
+ /** @public */
28
+ export declare const PHONEME_SECTION_CODES: PhonemeSectionCode[];
29
+ /** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
30
+ export declare const PHONEME_NONE = "none";
31
+ /** @public */
32
+ export declare function isPhonemeSectionCode(value: string): value is PhonemeSectionCode;
33
+ /**
34
+ * **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
35
+ *
36
+ * 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
37
+ * `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
38
+ * 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
39
+ *
40
+ * @public
41
+ */
42
+ export declare function toPhonemeCodes(sel: Record<string, string>): Partial<Record<PhonemeSectionCode, string>>;
43
+ /**
44
+ * 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
45
+ *
46
+ * 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
47
+ * 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
48
+ * `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
49
+ *
50
+ * @public
51
+ */
52
+ export declare function toLegacyPhonemeKey(code: PhonemeSectionCode): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * **음절 자리의 코드** — `onset`·`vowel`·`coda`·`glide`, 그리고 빔(`none`).
3
+ *
4
+ * ## 왜 제 모듈인가
5
+ *
6
+ * 처음에는 이 가름을 `phonemePairSearch` 에 두고 `phonemeConvert` 가 들여왔다 — 그런데
7
+ * `phonemePairSearch` 도 `phonemeConvert`(`VOWEL_TO_PHONEME`)를 들여온다. **돌림 import** 가
8
+ * 되어 시험 **338 꾸러미**가 한꺼번에 무너졌다(2026-10-13). 두 쪽이 함께 쓰는 것은 **어느
9
+ * 한쪽이 아니라 그 아래**에 둔다.
10
+ *
11
+ * ## 왜 코드인가
12
+ *
13
+ * 전에는 열쇠가 한글(`'초성'`)이었고 그것이 **저장된다** — 단어표의
14
+ * `searchConfig.phonemeSelected` 가 그 꼴이다. 저장되는 값이 표시 언어에 묶이면 말을 고치려
15
+ * 해도 자료를 밀어야 한다(사장님 지시 2026-10-13 "동작에 한글이 끼지 말라").
16
+ *
17
+ * **값을 바꾸지 않고 읽는 쪽을 너그럽게 했다** — 표준의 *lenient reader · strict writer*.
18
+ * 저장된 표를 밀지 않아도 되고 소비자 코드도 안 바뀐다. 안쪽 로직에서만 한글이 사라진다.
19
+ *
20
+ * 어휘는 **훈련 필터가 이미 쓰던 것**과 같게 뒀다(`useWordTrainingFilter` 의 `onset`·`vowel`·
21
+ * `coda`) — 같은 뜻에 어휘가 둘이던 것을 모았다.
22
+ *
23
+ * **값(자모 `'ㄴ'`)은 그대로다** — 그것은 한국어 글자 그 자체이고 바꿀 것은 열쇠와 빔 값이다.
24
+ */
25
+ /** @public */
26
+ export const PHONEME_SECTION_CODES = ['onset', 'vowel', 'coda', 'glide'];
27
+ /** 자리가 비었음 — 초성 ㅇ · 받침 없음 · 활음 없음을 한 값으로 적는다 @public */
28
+ export const PHONEME_NONE = 'none';
29
+ /** 저장된 옛 열쇠 → 코드. 옛 표를 읽을 때만 쓰는 다리다(새로 쓰는 것은 코드뿐) */
30
+ const LEGACY_SECTION_KEY = {
31
+ 초성: 'onset', 모음: 'vowel', 종성: 'coda', 활음: 'glide',
32
+ };
33
+ /** 저장된 옛 '빔' 값 */
34
+ const LEGACY_NONE = '없음';
35
+ /** @public */
36
+ export function isPhonemeSectionCode(value) {
37
+ return PHONEME_SECTION_CODES.includes(value);
38
+ }
39
+ /**
40
+ * **들어오는 고른값 하나를 코드 꼴로.** 옛 한글 열쇠·옛 빔 값도 받는다.
41
+ *
42
+ * 공개 함수(`searchPairs`·`searchWordsByPhonemes`·`convertToPhoneme`)가 받는
43
+ * `Record<string, string>` 은 **저장된 검색 구성에서 온다** — 그 꼴을 바꾸면 이미 저장된
44
+ * 단어표가 깨진다. 그래서 들어오는 자리에서 한 번 옮기고, 안쪽은 코드만 본다.
45
+ *
46
+ * @public
47
+ */
48
+ export function toPhonemeCodes(sel) {
49
+ const out = {};
50
+ for (const [k, v] of Object.entries(sel)) {
51
+ const key = isPhonemeSectionCode(k) ? k : LEGACY_SECTION_KEY[k];
52
+ /* **빈 값은 버리지 않는다.** `{ 종성: '' }` 은 "받침 없음을 골랐다" 는 **뜻 있는 고름**이다 —
53
+ 한 번 버렸다가 `받침 있음↔없음` 대립 검색이 빈손이 됐다(2026-10-13, 시험이 잡았다).
54
+ 거르는 것은 **열쇠를 못 알아볼 때**뿐이다. */
55
+ if (!key)
56
+ continue;
57
+ out[key] = v === LEGACY_NONE ? PHONEME_NONE : v;
58
+ }
59
+ return out;
60
+ }
61
+ /** 코드 → 저장된 옛 열쇠. 밖으로 내보내는 값이 옛 꼴이어야 할 때만 쓴다 */
62
+ const CODE_TO_LEGACY = {
63
+ onset: '초성', vowel: '모음', coda: '종성', glide: '활음',
64
+ };
65
+ /**
66
+ * 코드를 **옛 열쇠 글자로** — 밖으로 나가는 `diffSection` 이 그 꼴이다.
67
+ *
68
+ * 소비자가 그 값으로 묶음을 가른다(말귀 `WordPairsPage` 의 `GROUP_ORDER`·`GROUP_LABEL`).
69
+ * 혼자 코드로 바꾸면 **그쪽 묶음이 조용히 빈다** — 그래서 옛 이름을 내보내고, 코드는
70
+ * `diffSectionCode` 로 **나란히** 둔다(2026-10-13). 소비자가 옮긴 뒤 옛 쪽을 걷는다.
71
+ *
72
+ * @public
73
+ */
74
+ export function toLegacyPhonemeKey(code) {
75
+ return CODE_TO_LEGACY[code];
76
+ }
@@ -26,19 +26,19 @@
26
26
  * @public
27
27
  */
28
28
  export function prefetchChunk(...loads) {
29
- let 취소됨 = false;
30
- const 받기 = () => { if (!취소됨)
29
+ let cancelled = false;
30
+ const load = () => { if (!cancelled)
31
31
  for (const load of loads)
32
32
  void load().catch(() => { }); };
33
33
  const idle = globalThis.requestIdleCallback;
34
34
  if (idle) {
35
- const id = idle(받기, { timeout: 2000 });
35
+ const id = idle(load, { timeout: 2000 });
36
36
  return () => {
37
- 취소됨 = true;
37
+ cancelled = true;
38
38
  const cancel = globalThis.cancelIdleCallback;
39
39
  cancel?.(id);
40
40
  };
41
41
  }
42
- const id = setTimeout(받기, 300);
43
- return () => { 취소됨 = true; clearTimeout(id); };
42
+ const id = setTimeout(load, 300);
43
+ return () => { cancelled = true; clearTimeout(id); };
44
44
  }
@@ -8,16 +8,16 @@
8
8
  export function isSafeOpenHref(href) {
9
9
  if (typeof href !== 'string')
10
10
  return false;
11
- let 걷은 = '';
11
+ let stripped = '';
12
12
  for (const ch of href) {
13
13
  const c = ch.charCodeAt(0);
14
14
  if (c <= 0x20 || (c >= 0x7f && c <= 0x9f))
15
15
  continue;
16
- 걷은 += ch;
16
+ stripped += ch;
17
17
  }
18
- if (!걷은)
18
+ if (!stripped)
19
19
  return false;
20
- const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(걷은);
20
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(stripped);
21
21
  if (!m)
22
22
  return true; // 스킴이 없다 — 상대 경로(`/help`·`./a`·`?q`·`#x`)·`//host` 는 지금 문서의 스킴을 따른다
23
23
  return ['http', 'https', 'mailto', 'tel'].includes(m[1].toLowerCase());