prpm-dbp 0.2.2 → 0.4.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.
package/README.md CHANGED
@@ -128,6 +128,7 @@ Pengguna kemudian menarik versi baharu dengan `npx skills update`.
128
128
  | `jawi <fail>` | tukar teks Rumi ke Jawi ikut ejaan DBP |
129
129
  | `rumi <fail>` | tukar teks Jawi ke Rumi dari cache |
130
130
  | `import-tsv <fail>` | import dataset rumi/jawi tambahan |
131
+ | `nama <rumi> <jawi>` | sahkan satu nama khas |
131
132
  | `banding <kata...>` | silang semak PRPM lawan KamusDBP |
132
133
  | `cache` | status cache, `--buang <kata>` untuk padam satu |
133
134
 
package/SKILL.md CHANGED
@@ -73,6 +73,49 @@ Jawi Melayu menulis vokal dengan alif, wau dan ya, tidak seperti Arab. Kerana it
73
73
 
74
74
  `rumi` bekerja dari cache sahaja dan tidak menyentuh rangkaian. Perkataan yang belum pernah disemak dalam arah Rumi ke Jawi tidak akan dikenali. Jalankan `warm` atau `audit` dahulu pada kosa kata yang berkaitan.
75
75
 
76
+ ## Istilah Arab
77
+
78
+ Kebanyakan istilah agama ialah **kata Melayu**, bukan kata asing. Diuji terhadap PRPM: 29 daripada 30 istilah lazim ada entri, termasuk `yasin`, `fatihah`, `baqarah`, `rahman`, `rasulullah`. Jangan andaikan sesuatu itu Arab semata-mata kerana ia berbunyi Arab; tanya DBP dahulu.
79
+
80
+ Tiga keadaan berbeza, dilaporkan berasingan:
81
+
82
+ | Keadaan | Contoh | Maksud |
83
+ |---|---|---|
84
+ | Ada entri dan ejaan Jawi | `solat` صلاة, `zakat` زکاة | guna terus |
85
+ | `adaEntriTanpaJawi` | `yasin`, `fatihah`, `baqarah`, `wuduk` | DBP iktiraf sebagai kata Melayu, cuma tiada ejaan Jawi disimpan |
86
+ | `belumDisahkan` | `ahzab` | bukan kata Melayu; nama khas Arab |
87
+
88
+ Ejaan Jawi bagi pinjaman Arab **mengekalkan ejaan Arab asal**, bukan dieja secara fonetik: `صلاة` bukan `سولت`, `زکاة` bukan `زکات`. Ta marbutah dan alef maksura dikekalkan. Sebab itu ta marbutah tidak pernah dilipat semasa perbandingan.
89
+
90
+ Sesetengah entri memulangkan **dua perkataan Jawi untuk satu perkataan Rumi**: `rasulullah` → `رسول الله`. Pembacaan songsang memadan jujukan, jadi ia tidak pecah menjadi `rasul allah`.
91
+
92
+ **Teks Arab yang dipetik dibiarkan sepenuhnya.** Jawi Melayu tidak menggunakan baris tanda vokal, jadi kehadiran fathah, kasrah, dammah, sukun atau tanwin bermakna potongan itu Arab, bukan Jawi. Ayat al-Quran dan hadis dalam dokumen Melayu lulus tanpa disentuh dan dilaporkan dalam medan `arab`.
93
+
94
+ ## Nama khas
95
+
96
+ Diuji terhadap PRPM: **nama tokoh dan nama surah tiada sumber berwibawa.** `muhammad`, `ibrahim`, `musa`, `umar`, `aisyah`, `khadijah` semuanya tiada entri. Nama surah seperti `fatihah`, `baqarah`, `yasin` ada entri tetapi tanpa ejaan Jawi. Dataset komuniti meliputi nama tempat (`mekah` مکة, `mesir` مسير, `pahang` ڤاهڠ) tetapi bukan nama orang.
97
+
98
+ Alat ini **tidak mereka ejaan nama**. Sebaliknya:
99
+
100
+ - Perkataan berhuruf besar di **tengah** ayat dikira nama khas dan dilaporkan dalam medan `namaKhas`, berasingan daripada `belumDisahkan`. Nama bukan salah eja.
101
+ - Sahkan sekali, guna selamanya:
102
+
103
+ ```bash
104
+ npx prpm-dbp nama Muhammad <ejaan-jawi>
105
+ npx prpm-dbp nama # senaraikan yang sudah disahkan
106
+ ```
107
+
108
+ Ejaan yang disimpan begini ditandakan datang daripada pengguna, bukan daripada DBP. Sahkan dengan guru atau rujukan bertulis dahulu.
109
+
110
+ **Jangan keliru antara dua benda:**
111
+
112
+ | | Layanan |
113
+ |---|---|
114
+ | Petikan bahasa Arab (ayat, doa, hadis) | kekal Arab, tidak disentuh |
115
+ | Nama khas dalam ayat Melayu | **ditulis dalam Jawi**, bukan dikekalkan |
116
+
117
+ Bagi nama berasal Arab, ejaan Jawinya memang ejaan Arab asal, jadi ia nampak seperti dikekalkan. Bagi nama bukan Arab ia ditransliterasi penuh: `Kuala Lumpur` menjadi `کوالا لومڤور`.
118
+
76
119
  ## Rantai kepercayaan
77
120
 
78
121
  Setiap ejaan Jawi yang dikeluarkan membawa asal-usulnya. Jangan runtuhkan lapisan ini.
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import { pasangSkill } from '../src/install.js'
6
6
  import { banding, cariKamus } from '../src/kamus.js'
7
7
  import { keJawi, keRumi } from '../src/tukar.js'
8
8
  import { huraiTsv, muatDataset, simpanDataset } from '../src/dataset.js'
9
+ import { NAMA_PATH, muatNama, tambahNama } from '../src/nama.js'
9
10
  import {
10
11
  CACHE_PATH,
11
12
  auditJawi,
@@ -26,6 +27,8 @@ const BANTUAN = `prpm-dbp — semak Bahasa Melayu dan Jawi terhadap PRPM DBP
26
27
  npx prpm-dbp jawi <fail.txt> tukar teks Rumi ke Jawi ikut ejaan DBP
27
28
  npx prpm-dbp rumi <fail.txt> tukar teks Jawi ke Rumi dari cache
28
29
  npx prpm-dbp import-tsv <fail.tsv> import dataset rumi<TAB>jawi (dilabel bukan DBP)
30
+ npx prpm-dbp nama <rumi> <jawi> sahkan satu nama khas (tokoh, surah, tempat)
31
+ npx prpm-dbp nama senaraikan nama khas yang disahkan
29
32
  npx prpm-dbp banding <kata...> silang semak PRPM lawan KamusDBP
30
33
  npx prpm-dbp cache [--buang <kata>] status cache
31
34
  npx prpm-dbp install [--link] pasang sebagai Claude Code skill
@@ -154,6 +157,18 @@ async function main() {
154
157
  if (h.belumDisahkan.length) {
155
158
  console.error(`\nbelum disahkan (${h.belumDisahkan.length}): ${h.belumDisahkan.join(', ')}`)
156
159
  }
160
+ if (h.adaEntriTanpaJawi.length) {
161
+ console.error(
162
+ `DBP ada entri tetapi tiada ejaan Jawi (${h.adaEntriTanpaJawi.length}): ` +
163
+ h.adaEntriTanpaJawi.join(', '),
164
+ )
165
+ }
166
+ if (h.namaKhas.length) {
167
+ console.error(
168
+ `nama khas belum disahkan (${h.namaKhas.length}): ${h.namaKhas.join(', ')}\n` +
169
+ ` sahkan dengan: npx prpm-dbp nama <rumi> <jawi>`,
170
+ )
171
+ }
157
172
  if (h.terbitanSah.length) {
158
173
  console.error(
159
174
  `terbitan sah tanpa ejaan Jawi (${h.terbitanSah.length}): ` +
@@ -165,17 +180,29 @@ async function main() {
165
180
 
166
181
  case 'rumi': {
167
182
  const teks = await readFile(baki[0], 'utf8')
168
- const h = keRumi(teks, cache)
183
+ const h = keRumi(teks, cache, { dataset: await muatDataset() })
169
184
  if (pilihan.json) { keluar(h); break }
170
185
  console.log(h.teks)
171
186
  console.error(`\npeta songsang: ${h.saizPeta} ejaan`)
172
187
  if (h.tidakDikenali.length) {
173
188
  console.error(`tidak dikenali (${h.tidakDikenali.length}): ${h.tidakDikenali.join(', ')}`)
174
189
  }
190
+ if (h.arab.length) console.error(`teks Arab dibiarkan (${h.arab.length}): ${h.arab.join(' ')}`)
175
191
  for (const k of h.kabur) console.error(`kabur: ${k.jawi} -> ${k.calon.join(', ')}`)
176
192
  break
177
193
  }
178
194
 
195
+ case 'nama': {
196
+ if (baki.length >= 2) {
197
+ const rec = await tambahNama(baki[0], baki[1], { jenis: pilihan.jenis ?? null })
198
+ keluar({ disimpan: rec, fail: NAMA_PATH, nota: 'Ejaan ini datang dari anda, bukan dari DBP.' })
199
+ break
200
+ }
201
+ const peta = await muatNama()
202
+ keluar({ fail: NAMA_PATH, jumlah: Object.keys(peta).length, nama: peta })
203
+ break
204
+ }
205
+
179
206
  case 'import-tsv': {
180
207
  if (!baki[0]) throw new Error('perlu fail TSV: rumi<TAB>jawi setiap baris')
181
208
  const { peta, dilangkau, jumlah } = huraiTsv(await readFile(baki[0], 'utf8'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prpm-dbp",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Semak ejaan Bahasa Melayu dan Jawi terhadap PRPM Dewan Bahasa dan Pustaka. CLI dan skill untuk AI agent.",
5
5
  "keywords": [
6
6
  "bahasa-melayu",
package/src/nama.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Simpanan nama khas yang disahkan pengguna.
3
+ *
4
+ * Kenapa ini wujud: nama tokoh dan nama surah tiada sumber berwibawa yang
5
+ * boleh dicapai. Diuji terhadap PRPM, muhammad, ibrahim, musa, umar, aisyah
6
+ * dan khadijah semuanya TIADA entri. Dataset komuniti pula meliputi nama
7
+ * tempat (مکة, مسير, ڤاهڠ) tetapi bukan nama orang atau nama surah.
8
+ *
9
+ * Alat ini TIDAK mereka ejaan nama. Ia menyimpan apa yang pengguna sahkan
10
+ * sendiri, dengan sumber dicatat, supaya kerja pengesahan itu dibuat sekali
11
+ * sahaja dan boleh diaudit kemudian.
12
+ */
13
+
14
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
15
+ import { dirname, join } from 'node:path'
16
+
17
+ import { CACHE_PATH } from './prpm.js'
18
+
19
+ export const NAMA_PATH = process.env.PRPM_NAMA ?? join(dirname(CACHE_PATH), 'nama-khas.json')
20
+
21
+ let dimuat = null
22
+
23
+ export async function muatNama() {
24
+ if (dimuat !== null) return dimuat
25
+ try {
26
+ dimuat = JSON.parse(await readFile(NAMA_PATH, 'utf8'))
27
+ } catch {
28
+ dimuat = {}
29
+ }
30
+ return dimuat
31
+ }
32
+
33
+ export async function simpanNama(peta) {
34
+ await mkdir(dirname(NAMA_PATH), { recursive: true })
35
+ const tmp = `${NAMA_PATH}.tmp`
36
+ await writeFile(tmp, JSON.stringify(peta, null, 1), 'utf8')
37
+ await rename(tmp, NAMA_PATH)
38
+ dimuat = peta
39
+ return NAMA_PATH
40
+ }
41
+
42
+ export async function tambahNama(rumi, jawi, { sumber = 'pengguna', jenis = null } = {}) {
43
+ const peta = { ...(await muatNama()) }
44
+ const kunci = String(rumi).trim().toLowerCase()
45
+ if (!kunci || !jawi) throw new Error('perlu rumi dan jawi')
46
+ peta[kunci] = { rumi: kunci, jawi: String(jawi).trim(), sumber, jenis }
47
+ await simpanNama(peta)
48
+ return peta[kunci]
49
+ }
50
+
51
+ export async function cariNama(kata) {
52
+ const peta = await muatNama()
53
+ const rec = peta[String(kata).trim().toLowerCase()]
54
+ if (!rec) return null
55
+ return { ...rec, dijumpai: true, disahkanDbp: false }
56
+ }
57
+
58
+ /**
59
+ * Teka sama ada perkataan itu nama khas.
60
+ *
61
+ * Huruf besar di TENGAH ayat ialah isyarat yang munasabah dalam teks Melayu.
62
+ * Ia sengaja tidak digunakan pada perkataan pertama ayat, kerana di situ
63
+ * huruf besar tidak bermakna apa-apa.
64
+ */
65
+ export function mungkinNamaKhas(kata, sebelumnya) {
66
+ if (!/^[A-Z]/.test(kata)) return false
67
+ // Awal teks, atau selepas noktah, tanda soal, tanda seru, atau baris baru.
68
+ const awalAyat = !sebelumnya || /[.!?:\n]\s*$/.test(sebelumnya)
69
+ return !awalAyat
70
+ }
package/src/tukar.js CHANGED
@@ -10,7 +10,14 @@
10
10
 
11
11
  import { lookup } from './prpm.js'
12
12
  import { normalisasiJawi } from './kamus.js'
13
- import { cariDataset } from './dataset.js'
13
+ import { cariDataset, muatDataset } from './dataset.js'
14
+ import { cariNama, mungkinNamaKhas } from './nama.js'
15
+
16
+ // Baris tanda vokal Arab (fathah, kasrah, dammah, sukun, syaddah, tanwin,
17
+ // alef superscript). Jawi Melayu TIDAK menggunakannya; teks al-Quran dan
18
+ // hadis yang dipetik dalam dokumen Melayu menggunakannya. Kehadirannya
19
+ // bermakna potongan itu bahasa Arab, bukan Jawi, dan tidak boleh ditukar.
20
+ const HARAKAT = /[\u064B-\u0652\u0670\u0653-\u0655]/
14
21
 
15
22
  const JAWI_JULAT = '؀-ۿݐ-ݿﭐ-﷿ﹰ-'
16
23
  // Token dipecah supaya pemisah dikekalkan, jadi tanda baca, baris baru dan
@@ -80,15 +87,36 @@ export async function cariDasar(kata, cache, opts = {}) {
80
87
  return null
81
88
  }
82
89
 
83
- /** Bina peta Jawi -> Rumi daripada segala yang sudah disahkan dalam cache. */
84
- export function petaSongsang(cache) {
90
+ /**
91
+ * Bina peta Jawi -> Rumi. Menggabungkan cache DBP dengan dataset yang
92
+ * diimport, jika ada: tanpa dataset, apa-apa yang ditukar melalui lapisan
93
+ * ketiga tidak boleh dipusingkan balik langsung.
94
+ *
95
+ * Sesetengah nilai mengandungi RUANG (rasulullah -> رسول الله). Peta menjejaki
96
+ * bilangan perkataan terpanjang supaya pembacaan songsang boleh memadan
97
+ * jujukan, bukan token tunggal sahaja.
98
+ */
99
+ export function petaSongsang(cache, dataset = null) {
85
100
  const peta = new Map()
86
- for (const rec of Object.values(cache)) {
87
- if (!rec?.jawi) continue
88
- const kunci = normalisasiJawi(rec.jawi)
101
+ let maxKata = 1
102
+
103
+ const tambah = (jawi, rumi) => {
104
+ const kunci = normalisasiJawi(jawi)
105
+ if (!kunci) return
106
+ const bilangan = kunci.split(/\s+/).length
107
+ if (bilangan > maxKata) maxKata = bilangan
89
108
  if (!peta.has(kunci)) peta.set(kunci, [])
90
- if (!peta.get(kunci).includes(rec.kata)) peta.get(kunci).push(rec.kata)
109
+ if (!peta.get(kunci).includes(rumi)) peta.get(kunci).push(rumi)
91
110
  }
111
+
112
+ for (const rec of Object.values(cache)) {
113
+ if (rec?.jawi) tambah(rec.jawi, rec.kata)
114
+ }
115
+ for (const [rumi, ejaan] of Object.entries(dataset?.peta ?? {})) {
116
+ for (const j of ejaan) tambah(j, rumi)
117
+ }
118
+
119
+ peta.maxKata = maxKata
92
120
  return peta
93
121
  }
94
122
 
@@ -102,6 +130,9 @@ export async function keJawi(teks, cache, opts = {}) {
102
130
  const terbitanSah = []
103
131
  const dariDataset = []
104
132
  const dbp = []
133
+ const adaEntriTanpaJawi = []
134
+ const dariNama = []
135
+ const namaKhas = []
105
136
  const bahagian = teks.split(PECAH_RUMI)
106
137
  const keluar = []
107
138
 
@@ -111,6 +142,11 @@ export async function keJawi(teks, cache, opts = {}) {
111
142
  const rec = await lookup(cebis.toLowerCase(), cache, opts)
112
143
  if (rec?.jawi) { keluar.push(rec.jawi); dbp.push(cebis); continue }
113
144
 
145
+ // Keadaan berbeza daripada tiada entri: DBP mengiktiraf perkataan ini
146
+ // sebagai Melayu, cuma tidak menyimpan ejaan Jawinya. Lazim bagi nama
147
+ // surah dan istilah agama seperti yasin, fatihah, baqarah.
148
+ if (rec?.dijumpai) adaEntriTanpaJawi.push(cebis)
149
+
114
150
  // Lapisan ketiga: dataset yang diimport pengguna. Berlabel, tidak pernah
115
151
  // menyamar sebagai ejaan DBP.
116
152
  if (gunaDataset) {
@@ -122,6 +158,14 @@ export async function keJawi(teks, cache, opts = {}) {
122
158
  }
123
159
  }
124
160
 
161
+ // Nama khas yang pengguna sudah sahkan sendiri.
162
+ const nm = await cariNama(cebis)
163
+ if (nm?.jawi) {
164
+ dariNama.push({ kata: cebis, jawi: nm.jawi })
165
+ keluar.push(nm.jawi)
166
+ continue
167
+ }
168
+
125
169
  if (cubaDasar) {
126
170
  const dasar = await cariDasar(cebis.toLowerCase(), cache, opts)
127
171
  if (dasar) {
@@ -133,7 +177,11 @@ export async function keJawi(teks, cache, opts = {}) {
133
177
  }
134
178
  }
135
179
 
136
- belumDisahkan.push(cebis)
180
+ // Nama khas yang belum disahkan bukan salah eja. Ia dilaporkan
181
+ // berasingan supaya pengguna tahu apa yang perlu disahkan sekali, dan
182
+ // bukan mengulang menyemak ejaan yang memang betul.
183
+ if (mungkinNamaKhas(cebis, bahagian[i - 1])) namaKhas.push(cebis)
184
+ else belumDisahkan.push(cebis)
137
185
  keluar.push(penanda[0] + cebis + penanda[1])
138
186
  }
139
187
 
@@ -141,9 +189,12 @@ export async function keJawi(teks, cache, opts = {}) {
141
189
  teks: keluar.join(''),
142
190
  disahkanDbp: dbp.length,
143
191
  dariDataset,
192
+ dariNama,
193
+ namaKhas: [...new Set(namaKhas)],
194
+ adaEntriTanpaJawi: [...new Set(adaEntriTanpaJawi)],
144
195
  belumDisahkan: [...new Set(belumDisahkan)],
145
196
  terbitanSah,
146
- lengkap: belumDisahkan.length === 0 && terbitanSah.length === 0,
197
+ lengkap: belumDisahkan.length === 0 && terbitanSah.length === 0 && namaKhas.length === 0,
147
198
  // Sepenuhnya DBP bermakna setiap perkataan datang dari sumber rasmi.
148
199
  semuanyaDbp: dariDataset.length === 0 && belumDisahkan.length === 0 && terbitanSah.length === 0,
149
200
  }
@@ -156,32 +207,66 @@ export async function keJawi(teks, cache, opts = {}) {
156
207
  * menggugurkannya. Kerana itu perlanggaran jauh lebih jarang daripada yang
157
208
  * disangka. Bila ia berlaku, SEMUA calon dilaporkan, bukan dipilih satu.
158
209
  */
159
- export function keRumi(teksJawi, cache, { penanda = ['«', '»'] } = {}) {
160
- const peta = petaSongsang(cache)
210
+ export function keRumi(teksJawi, cache, { penanda = ['«', '»'], dataset = null } = {}) {
211
+ const peta = petaSongsang(cache, dataset)
161
212
  const tidakDikenali = []
162
213
  const kabur = []
214
+ const arab = []
163
215
  const bahagian = teksJawi.split(PECAH_JAWI)
164
216
  const keluar = []
165
217
 
166
- for (const [i, cebis] of bahagian.entries()) {
167
- if (i % 2 === 0) { keluar.push(cebis); continue }
218
+ for (let i = 0; i < bahagian.length; i++) {
219
+ if (i % 2 === 0) { keluar.push(bahagian[i]); continue }
220
+
221
+ // Teks Arab bertanda baris dibiarkan seadanya. Cuba menukarnya akan
222
+ // merosakkan petikan al-Quran dan hadis dalam dokumen Melayu.
223
+ if (HARAKAT.test(bahagian[i])) {
224
+ arab.push(bahagian[i])
225
+ keluar.push(bahagian[i])
226
+ continue
227
+ }
228
+
229
+ // Padanan jujukan terpanjang dahulu, supaya رسول الله ditemui sebagai
230
+ // satu perkataan dan bukan dipecah jadi rasul + allah.
231
+ let padanan = null
232
+ let hujung = i
233
+ // Had atas MESTI integer. Nilai pecahan menyebabkan n melompat 3.5, 2.5,
234
+ // 1.5 dan tidak pernah menyentuh bilangan perkataan sebenar, lalu hujung
235
+ // jatuh pada pemisah dan bukan token.
236
+ const maxN = Math.min(peta.maxKata, Math.floor((bahagian.length - i + 1) / 2))
237
+ for (let n = maxN; n >= 1; n--) {
238
+ const akhir = i + (n - 1) * 2
239
+ if (akhir >= bahagian.length || akhir % 2 === 0) continue
240
+ // Hanya gabung apabila pemisah antara token benar-benar ruang.
241
+ let bolehGabung = true
242
+ for (let k = i + 1; k < akhir; k += 2) {
243
+ if (!/^\s+$/.test(bahagian[k])) { bolehGabung = false; break }
244
+ }
245
+ if (!bolehGabung) continue
246
+ const frasa = bahagian.slice(i, akhir + 1).join(' ').replace(/\s+/g, ' ')
247
+ const jumpa = peta.get(normalisasiJawi(frasa))
248
+ if (jumpa) { padanan = jumpa; hujung = akhir; break }
249
+ }
168
250
 
169
- const padanan = peta.get(normalisasiJawi(cebis))
170
251
  if (!padanan) {
171
- tidakDikenali.push(cebis)
172
- keluar.push(penanda[0] + cebis + penanda[1])
173
- } else if (padanan.length === 1) {
174
- keluar.push(padanan[0])
175
- } else {
176
- kabur.push({ jawi: cebis, calon: padanan })
252
+ tidakDikenali.push(bahagian[i])
253
+ keluar.push(penanda[0] + bahagian[i] + penanda[1])
254
+ continue
255
+ }
256
+
257
+ if (padanan.length === 1) keluar.push(padanan[0])
258
+ else {
259
+ kabur.push({ jawi: bahagian.slice(i, hujung + 1).join(''), calon: padanan })
177
260
  keluar.push(padanan.join('|'))
178
261
  }
262
+ i = hujung
179
263
  }
180
264
 
181
265
  return {
182
266
  teks: keluar.join(''),
183
267
  tidakDikenali: [...new Set(tidakDikenali)],
184
268
  kabur,
269
+ arab: [...new Set(arab)],
185
270
  saizPeta: peta.size,
186
271
  lengkap: tidakDikenali.length === 0,
187
272
  }