laporan-generator 2.7.0 → 2.8.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.
@@ -58,15 +58,25 @@ Identify the document type and target university:
58
58
  - `unpad-skripsi`: Universitas Padjadjaran.
59
59
  - `standard`: General University (2.5cm / 2cm).
60
60
 
61
- ### Stage 2: Scaffolding & Metadata Configuration
62
- 1. Initialize the workspace:
63
- - Call MCP tool `laporan_init` or run `npx laporan-generator init`.
64
- 2. Configure `metadata.yml`:
61
+ ### Stage 2: Document Structure Confirmation & Scaffolding
62
+ 1. **Confirm Document Structure**:
63
+ - The universal default structure is minimal: **Cover -> Kata Pengantar -> Daftar Isi -> Batang Tubuh (BAB I..n) -> Daftar Pustaka**.
64
+ - **MANDATORY INQUIRY**: Ask the user before scaffolding if they want to activate optional components:
65
+ - Abstrak / Abstract (`abstract_id` / `abstract_en`)
66
+ - Lembar Pengesahan (`approval`)
67
+ - Daftar Gambar (`daftar_gambar: true`)
68
+ - Daftar Tabel (`daftar_tabel: true`)
69
+ - Lampiran
70
+ 2. **Initialize the workspace**:
71
+ - Call MCP tool `laporan_init` (e.g. `laporan_init({ preset, title, daftar_gambar, daftar_tabel })`) or run `npx laporan-generator init`.
72
+ 3. **Configure `metadata.yml`**:
65
73
  - `title`: Clear, concise, capitalized (rendered as inverted pyramid).
66
74
  - `author`: Author array with `name` and `nim` (Student ID).
67
75
  - `preset`: Selected university preset (e.g. `skripsi-4433`, `itb-ta`).
76
+ - `daftar_gambar: false`, `daftar_tabel: false` (set to `true` only if opted-in).
77
+ - `abstract_id`: Kept empty or commented out unless user requested Abstrak.
68
78
  - `institution`, `faculty`, `department`, `year`.
69
- 3. Configure `cover.md`: Set formal title, institutional affiliations, and kata pengantar.
79
+ 4. **Configure `cover.md`**: Set formal title, institutional affiliations, and kata pengantar.
70
80
 
71
81
  ### Stage 3: Rigorous Academic Writing Standard
72
82
  When authoring content into `chapters/bab*.md`, strictly enforce **Indonesian Standard Academic Tone**:
@@ -22,21 +22,25 @@ Penulis menyadari bahwa laporan ini masih jauh dari sempurna. Oleh karena itu, k
22
22
  depth: 3,
23
23
  )
24
24
  #context {
25
- let imgs = query(figure.where(kind: image))
26
- if imgs.len() > 0 {
27
- pagebreak()
28
- outline(
29
- title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR GAMBAR]],
30
- target: figure.where(kind: image),
31
- )
25
+ if opt-daftar-gambar {
26
+ let imgs = query(figure.where(kind: image))
27
+ if imgs.len() > 0 {
28
+ pagebreak()
29
+ outline(
30
+ title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR GAMBAR]],
31
+ target: figure.where(kind: image),
32
+ )
33
+ }
32
34
  }
33
- let tbls = query(figure.where(kind: table))
34
- if tbls.len() > 0 {
35
- pagebreak()
36
- outline(
37
- title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR TABEL]],
38
- target: figure.where(kind: table),
39
- )
35
+ if opt-daftar-tabel {
36
+ let tbls = query(figure.where(kind: table))
37
+ if tbls.len() > 0 {
38
+ pagebreak()
39
+ outline(
40
+ title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR TABEL]],
41
+ target: figure.where(kind: table),
42
+ )
43
+ }
40
44
  }
41
45
  }
42
46
  #pagebreak()
@@ -138,9 +138,24 @@ local function meta_str(meta, key)
138
138
  if v == nil then
139
139
  return ""
140
140
  end
141
+ if type(v) == "boolean" then
142
+ return tostring(v)
143
+ end
141
144
  return pandoc.utils.stringify(v)
142
145
  end
143
146
 
147
+ local function meta_bool(meta, key)
148
+ local v = meta[key]
149
+ if v == nil then
150
+ return false
151
+ end
152
+ if type(v) == "boolean" then
153
+ return v
154
+ end
155
+ local s = meta_str(meta, key):lower():gsub("%s+", "")
156
+ return s == "true" or s == "1" or s == "yes"
157
+ end
158
+
144
159
  local function format_chapter_number(n)
145
160
  if chapter_num_fmt == "arabic" then
146
161
  return tostring(n)
@@ -280,6 +295,44 @@ local function daftar_isi()
280
295
  return out
281
296
  end
282
297
 
298
+ local function daftar_gambar()
299
+ local out = pandoc.List{}
300
+ out[#out + 1] = pagebreak()
301
+ out[#out + 1] = pandoc.RawBlock(
302
+ "openxml",
303
+ '<w:p><w:pPr><w:pStyle w:val="TOCHeading"/><w:jc w:val="center"/><w:outlineLvl w:val="9"/></w:pPr>' ..
304
+ '<w:r><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>DAFTAR GAMBAR</w:t></w:r></w:p>'
305
+ )
306
+ out[#out + 1] = pandoc.RawBlock(
307
+ "openxml",
308
+ '<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>' ..
309
+ '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' ..
310
+ '<w:r><w:instrText xml:space="preserve"> TOC \\c "Gambar" \\h \\z </w:instrText></w:r>' ..
311
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' ..
312
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>'
313
+ )
314
+ return out
315
+ end
316
+
317
+ local function daftar_tabel()
318
+ local out = pandoc.List{}
319
+ out[#out + 1] = pagebreak()
320
+ out[#out + 1] = pandoc.RawBlock(
321
+ "openxml",
322
+ '<w:p><w:pPr><w:pStyle w:val="TOCHeading"/><w:jc w:val="center"/><w:outlineLvl w:val="9"/></w:pPr>' ..
323
+ '<w:r><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>DAFTAR TABEL</w:t></w:r></w:p>'
324
+ )
325
+ out[#out + 1] = pandoc.RawBlock(
326
+ "openxml",
327
+ '<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>' ..
328
+ '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' ..
329
+ '<w:r><w:instrText xml:space="preserve"> TOC \\c "Tabel" \\h \\z </w:instrText></w:r>' ..
330
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' ..
331
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>'
332
+ )
333
+ return out
334
+ end
335
+
283
336
  function Pandoc(doc)
284
337
  if FORMAT ~= "docx" then
285
338
  return doc
@@ -292,6 +345,8 @@ function Pandoc(doc)
292
345
  local faculty = meta_str(meta, "faculty")
293
346
  local institution = meta_str(meta, "institution")
294
347
  local year = meta_str(meta, "year")
348
+ local show_dg = (meta_bool(meta, "daftar_gambar") or meta_bool(meta, "list_of_figures"))
349
+ local show_dt = (meta_bool(meta, "daftar_tabel") or meta_bool(meta, "list_of_tables"))
295
350
 
296
351
  local blocks = pandoc.List{}
297
352
 
@@ -401,6 +456,16 @@ function Pandoc(doc)
401
456
  for _, b in ipairs(daftar_isi()) do
402
457
  body[#body + 1] = b
403
458
  end
459
+ if show_dg then
460
+ for _, b in ipairs(daftar_gambar()) do
461
+ body[#body + 1] = b
462
+ end
463
+ end
464
+ if show_dt then
465
+ for _, b in ipairs(daftar_tabel()) do
466
+ body[#body + 1] = b
467
+ end
468
+ end
404
469
  inserted = true
405
470
  end
406
471
  body[#body + 1] = blk
@@ -27,7 +27,7 @@ export PYTHONUTF8=1
27
27
  show_banner() {
28
28
  echo -e "${CYAN}${BOLD}"
29
29
  echo " ========================================================"
30
- echo " LAPORAN GENERATOR CLI v2.7.0 "
30
+ echo " LAPORAN GENERATOR CLI v2.8.0 "
31
31
  echo " Otomatisasi Dokumen Akademik (Typst + DOCX Engine) "
32
32
  echo " ========================================================"
33
33
  echo -e "${NC}"
@@ -40,7 +40,7 @@ function Get-PythonCommand {
40
40
 
41
41
  function Show-Banner {
42
42
  Write-Host " ========================================================" -ForegroundColor Cyan
43
- Write-Host " LAPORAN GENERATOR CLI v2.7.0 " -ForegroundColor Cyan
43
+ Write-Host " LAPORAN GENERATOR CLI v2.8.0 " -ForegroundColor Cyan
44
44
  Write-Host " Otomatisasi Dokumen Akademik (Typst + DOCX Engine) " -ForegroundColor Cyan
45
45
  Write-Host " ========================================================" -ForegroundColor Cyan
46
46
  Write-Host ""
@@ -15,21 +15,18 @@ institution: "Universitas Example"
15
15
  faculty: "Program Studi Informatika"
16
16
  year: "2026/2027"
17
17
  date: "Agustus 2026"
18
- abstract_id: |
19
- Laporan ini membahas perancangan dan implementasi pipeline otomatisasi pembuatan dokumen akademik modern menggunakan Markdown, Pandoc, Typst, Lua Filter, dan multi-pass DOCX engine. Pipeline ini memungkinkan penulisan konten laporan dalam format Markdown yang secara otomatis dikonversi menjadi dokumen PDF berkualitas percetakan berbasis Typst serta dokumen Microsoft Word (.docx) berstruktur formal lengkap dengan penomoran bab, tabel, gambar, dan halaman otomatis yang mematuhi standar karya ilmiah perguruan tinggi di Indonesia.
20
- keywords_id:
21
- - Otomatisasi Dokumen
22
- - Pandoc
23
- - Typst
24
- - OpenXML
25
- - Rekayasa Perangkat Lunak
26
- abstract_en: |
27
- This report presents the design and implementation of a modern academic document automation pipeline utilizing Markdown, Pandoc, Typst, Lua Filters, and a multi-pass DOCX engine. This pipeline facilitates authoring scholarly reports in plain Markdown, seamlessly generating both print-quality Typst-based PDF documents and formally structured Microsoft Word (.docx) files. The generated outputs feature automated chapter, table, figure, and dual-pagination numbering compliant with standardized higher education guidelines.
28
- keywords_en:
29
- - Document Automation
30
- - Pandoc
31
- - Typst
32
- - OpenXML
33
- - Software Engineering
18
+ daftar_gambar: false
19
+ daftar_tabel: false
20
+ # Komponen halaman depan opsional (aktifkan jika diperlukan):
21
+ # abstract_id: |
22
+ # Ringkasan atau abstrak laporan dalam Bahasa Indonesia...
23
+ # keywords_id:
24
+ # - Kata Kunci 1
25
+ # - Kata Kunci 2
26
+ # abstract_en: |
27
+ # Summary or abstract of the report in English...
28
+ # keywords_en:
29
+ # - Keyword 1
30
+ # - Keyword 2
34
31
  preset: "standard"
35
32
  ...
@@ -184,6 +184,103 @@ class TestReportDoctor(unittest.TestCase):
184
184
  self.assertIn("citations", data)
185
185
 
186
186
 
187
+ class TestFrontMatterOptIn(unittest.TestCase):
188
+ def test_default_metadata_structure(self):
189
+ meta_path = os.path.join(get_project_root(), "metadata.yml")
190
+ with open(meta_path, "r", encoding="utf-8") as f:
191
+ content = f.read()
192
+ # Verify abstract is commented or empty by default
193
+ self.assertNotIn("\nabstract_id: |", content, "abstract_id should not be active by default in metadata.yml")
194
+ self.assertNotIn("\nabstract_en: |", content, "abstract_en should not be active by default in metadata.yml")
195
+ # Verify opt-in flags for daftar_gambar & daftar_tabel are explicitly false or commented
196
+ has_dg = "daftar_gambar: false" in content
197
+ has_dt = "daftar_tabel: false" in content
198
+ self.assertTrue(has_dg, "metadata.yml must explicitly specify daftar_gambar: false by default")
199
+ self.assertTrue(has_dt, "metadata.yml must explicitly specify daftar_tabel: false by default")
200
+
201
+ def test_cover_md_gated_outlines(self):
202
+ cover_path = os.path.join(get_project_root(), "cover.md")
203
+ with open(cover_path, "r", encoding="utf-8") as f:
204
+ content = f.read()
205
+ # Unconditional outline queries must not exist
206
+ self.assertNotIn("let imgs = query(figure.where(kind: image))\n if imgs.len() > 0", content,
207
+ "cover.md must not unconditionally emit DAFTAR GAMBAR outline")
208
+ self.assertNotIn("let tbls = query(figure.where(kind: table))\n if tbls.len() > 0", content,
209
+ "cover.md must not unconditionally emit DAFTAR TABEL outline")
210
+ # Must be gated by opt-in check
211
+ self.assertIn("opt-daftar-gambar", content, "cover.md must gate DAFTAR GAMBAR with opt-daftar-gambar")
212
+ self.assertIn("opt-daftar-tabel", content, "cover.md must gate DAFTAR TABEL with opt-daftar-tabel")
213
+
214
+ def test_lua_meta_bool_coercion(self):
215
+ """Verify docx.lua handles YAML boolean scalars and string equivalents safely."""
216
+ lua_code = """
217
+ local pandoc = {
218
+ utils = {
219
+ stringify = function(x)
220
+ if type(x) ~= 'string' and type(x) ~= 'table' then
221
+ error('pandoc.utils.stringify expects table or string, got ' .. type(x))
222
+ end
223
+ return tostring(x)
224
+ end
225
+ }
226
+ }
227
+ local function meta_str(meta, key)
228
+ local v = meta[key]
229
+ if v == nil then return "" end
230
+ if type(v) == "boolean" then return tostring(v) end
231
+ return pandoc.utils.stringify(v)
232
+ end
233
+ local function meta_bool(meta, key)
234
+ local v = meta[key]
235
+ if v == nil then return false end
236
+ if type(v) == "boolean" then return v end
237
+ local s = meta_str(meta, key):lower():gsub("%s+", "")
238
+ return s == "true" or s == "1" or s == "yes"
239
+ end
240
+
241
+ local meta = {
242
+ dg_bool_false = false,
243
+ dg_bool_true = true,
244
+ dg_str_false = "false",
245
+ dg_str_true = "true",
246
+ dg_nil = nil
247
+ }
248
+
249
+ assert(meta_str(meta, "dg_bool_false") == "false")
250
+ assert(meta_bool(meta, "dg_bool_false") == false)
251
+ assert(meta_str(meta, "dg_bool_true") == "true")
252
+ assert(meta_bool(meta, "dg_bool_true") == true)
253
+ assert(meta_bool(meta, "dg_str_false") == false)
254
+ assert(meta_bool(meta, "dg_str_true") == true)
255
+ assert(meta_bool(meta, "dg_nil") == false)
256
+ print("LUA_OK")
257
+ """
258
+ proc = subprocess.run(["lua", "-e", lua_code], capture_output=True, text=True)
259
+ self.assertEqual(proc.returncode, 0, f"Lua helper test failed: {proc.stderr}")
260
+ self.assertIn("LUA_OK", proc.stdout)
261
+
262
+ def test_init_project_boolean_coercion(self):
263
+ """Verify lib/init.js does not treat string 'false' as truthy."""
264
+ import tempfile
265
+ with tempfile.TemporaryDirectory() as tmpdir:
266
+ node_code = f"""
267
+ const path = require('path');
268
+ const {{ initProject }} = require('{os.path.join(ROOT_DIR, "lib/init.js")}');
269
+ initProject({{ targetDir: '{tmpdir}/test_f', daftar_gambar: 'false', daftar_tabel: false, silent: true }});
270
+ initProject({{ targetDir: '{tmpdir}/test_t', daftar_gambar: 'true', daftar_tabel: true, silent: true }});
271
+ """
272
+ proc = subprocess.run(["node", "-e", node_code], capture_output=True, text=True)
273
+ self.assertEqual(proc.returncode, 0, f"Node initProject failed: {proc.stderr}")
274
+ with open(os.path.join(tmpdir, "test_f", "metadata.yml"), "r", encoding="utf-8") as f:
275
+ meta_f = f.read()
276
+ with open(os.path.join(tmpdir, "test_t", "metadata.yml"), "r", encoding="utf-8") as f:
277
+ meta_t = f.read()
278
+ self.assertIn("daftar_gambar: false", meta_f)
279
+ self.assertIn("daftar_tabel: false", meta_f)
280
+ self.assertIn("daftar_gambar: true", meta_t)
281
+ self.assertIn("daftar_tabel: true", meta_t)
282
+
283
+
187
284
  if __name__ == "__main__":
188
285
  unittest.main(verbosity=2)
189
286
 
@@ -391,4 +391,7 @@ $endif$
391
391
  #pagebreak()
392
392
  $endif$
393
393
 
394
+ #let opt-daftar-gambar = $if(daftar_gambar)$$daftar_gambar$$else$$if(list_of_figures)$$list_of_figures$$else$false$endif$$endif$
395
+ #let opt-daftar-tabel = $if(daftar_tabel)$$daftar_tabel$$else$$if(list_of_tables)$$list_of_tables$$else$false$endif$$endif$
396
+
394
397
  $body$
@@ -184,6 +184,103 @@ class TestReportDoctor(unittest.TestCase):
184
184
  self.assertIn("citations", data)
185
185
 
186
186
 
187
+ class TestFrontMatterOptIn(unittest.TestCase):
188
+ def test_default_metadata_structure(self):
189
+ meta_path = os.path.join(get_project_root(), "metadata.yml")
190
+ with open(meta_path, "r", encoding="utf-8") as f:
191
+ content = f.read()
192
+ # Verify abstract is commented or empty by default
193
+ self.assertNotIn("\nabstract_id: |", content, "abstract_id should not be active by default in metadata.yml")
194
+ self.assertNotIn("\nabstract_en: |", content, "abstract_en should not be active by default in metadata.yml")
195
+ # Verify opt-in flags for daftar_gambar & daftar_tabel are explicitly false or commented
196
+ has_dg = "daftar_gambar: false" in content
197
+ has_dt = "daftar_tabel: false" in content
198
+ self.assertTrue(has_dg, "metadata.yml must explicitly specify daftar_gambar: false by default")
199
+ self.assertTrue(has_dt, "metadata.yml must explicitly specify daftar_tabel: false by default")
200
+
201
+ def test_cover_md_gated_outlines(self):
202
+ cover_path = os.path.join(get_project_root(), "cover.md")
203
+ with open(cover_path, "r", encoding="utf-8") as f:
204
+ content = f.read()
205
+ # Unconditional outline queries must not exist
206
+ self.assertNotIn("let imgs = query(figure.where(kind: image))\n if imgs.len() > 0", content,
207
+ "cover.md must not unconditionally emit DAFTAR GAMBAR outline")
208
+ self.assertNotIn("let tbls = query(figure.where(kind: table))\n if tbls.len() > 0", content,
209
+ "cover.md must not unconditionally emit DAFTAR TABEL outline")
210
+ # Must be gated by opt-in check
211
+ self.assertIn("opt-daftar-gambar", content, "cover.md must gate DAFTAR GAMBAR with opt-daftar-gambar")
212
+ self.assertIn("opt-daftar-tabel", content, "cover.md must gate DAFTAR TABEL with opt-daftar-tabel")
213
+
214
+ def test_lua_meta_bool_coercion(self):
215
+ """Verify docx.lua handles YAML boolean scalars and string equivalents safely."""
216
+ lua_code = """
217
+ local pandoc = {
218
+ utils = {
219
+ stringify = function(x)
220
+ if type(x) ~= 'string' and type(x) ~= 'table' then
221
+ error('pandoc.utils.stringify expects table or string, got ' .. type(x))
222
+ end
223
+ return tostring(x)
224
+ end
225
+ }
226
+ }
227
+ local function meta_str(meta, key)
228
+ local v = meta[key]
229
+ if v == nil then return "" end
230
+ if type(v) == "boolean" then return tostring(v) end
231
+ return pandoc.utils.stringify(v)
232
+ end
233
+ local function meta_bool(meta, key)
234
+ local v = meta[key]
235
+ if v == nil then return false end
236
+ if type(v) == "boolean" then return v end
237
+ local s = meta_str(meta, key):lower():gsub("%s+", "")
238
+ return s == "true" or s == "1" or s == "yes"
239
+ end
240
+
241
+ local meta = {
242
+ dg_bool_false = false,
243
+ dg_bool_true = true,
244
+ dg_str_false = "false",
245
+ dg_str_true = "true",
246
+ dg_nil = nil
247
+ }
248
+
249
+ assert(meta_str(meta, "dg_bool_false") == "false")
250
+ assert(meta_bool(meta, "dg_bool_false") == false)
251
+ assert(meta_str(meta, "dg_bool_true") == "true")
252
+ assert(meta_bool(meta, "dg_bool_true") == true)
253
+ assert(meta_bool(meta, "dg_str_false") == false)
254
+ assert(meta_bool(meta, "dg_str_true") == true)
255
+ assert(meta_bool(meta, "dg_nil") == false)
256
+ print("LUA_OK")
257
+ """
258
+ proc = subprocess.run(["lua", "-e", lua_code], capture_output=True, text=True)
259
+ self.assertEqual(proc.returncode, 0, f"Lua helper test failed: {proc.stderr}")
260
+ self.assertIn("LUA_OK", proc.stdout)
261
+
262
+ def test_init_project_boolean_coercion(self):
263
+ """Verify lib/init.js does not treat string 'false' as truthy."""
264
+ import tempfile
265
+ with tempfile.TemporaryDirectory() as tmpdir:
266
+ node_code = f"""
267
+ const path = require('path');
268
+ const {{ initProject }} = require('{os.path.join(ROOT_DIR, "lib/init.js")}');
269
+ initProject({{ targetDir: '{tmpdir}/test_f', daftar_gambar: 'false', daftar_tabel: false, silent: true }});
270
+ initProject({{ targetDir: '{tmpdir}/test_t', daftar_gambar: 'true', daftar_tabel: true, silent: true }});
271
+ """
272
+ proc = subprocess.run(["node", "-e", node_code], capture_output=True, text=True)
273
+ self.assertEqual(proc.returncode, 0, f"Node initProject failed: {proc.stderr}")
274
+ with open(os.path.join(tmpdir, "test_f", "metadata.yml"), "r", encoding="utf-8") as f:
275
+ meta_f = f.read()
276
+ with open(os.path.join(tmpdir, "test_t", "metadata.yml"), "r", encoding="utf-8") as f:
277
+ meta_t = f.read()
278
+ self.assertIn("daftar_gambar: false", meta_f)
279
+ self.assertIn("daftar_tabel: false", meta_f)
280
+ self.assertIn("daftar_gambar: true", meta_t)
281
+ self.assertIn("daftar_tabel: true", meta_t)
282
+
283
+
187
284
  if __name__ == "__main__":
188
285
  unittest.main(verbosity=2)
189
286
 
package/README.md CHANGED
@@ -113,7 +113,7 @@ npx laporan-generator sync-hosts
113
113
 
114
114
  ## Dukungan Model Context Protocol (MCP)
115
115
 
116
- Laporan Generator v2.7.0 dilengkapi dengan **Native MCP Server** resmi yang berjalan melalui `stdio` berbasis protokol JSON-RPC 2.0 (spesifikasi 2024-11-05). Server ini memungkinkan AI Agent otonom (seperti Google Antigravity, Claude Desktop, Claude Code, dan OpenCode) untuk berinteraksi langsung dengan dokumen akademik melalui tools deterministik.
116
+ Laporan Generator v2.8.0 dilengkapi dengan **Native MCP Server** resmi yang berjalan melalui `stdio` berbasis protokol JSON-RPC 2.0 (spesifikasi 2024-11-05). Server ini memungkinkan AI Agent otonom (seperti Google Antigravity, Claude Desktop, Claude Code, dan OpenCode) untuk berinteraksi langsung dengan dokumen akademik melalui tools deterministik.
117
117
 
118
118
  ### Konfigurasi Otomatis (Direkomendasikan)
119
119
  Cukup jalankan satu perintah berikut untuk mendaftarkan skill sekaligus konfigurasi MCP Server ke seluruh agent di mesin Anda:
@@ -293,6 +293,11 @@ title: "Analisis dan Implementasi Sistem Deteksi Intrusi Jaringan Berbasis eBPF"
293
293
  document_type: "Skripsi"
294
294
  margin_preset: "itb-ta"
295
295
 
296
+ # Kontrol Struktur Halaman Depan (Opt-In)
297
+ # Default struktur bersih: Cover -> Kata Pengantar -> Daftar Isi -> Batang Tubuh -> Daftar Pustaka
298
+ daftar_gambar: false # Ubah true jika dokumen memuat gambar dan membutuhkan DAFTAR GAMBAR
299
+ daftar_tabel: false # Ubah true jika dokumen memuat tabel dan membutuhkan DAFTAR TABEL
300
+
296
301
  author:
297
302
  - name: "Mu'adz Hudzaifah"
298
303
  nim: "13522001"
@@ -25,7 +25,7 @@ function showHelp() {
25
25
  ========================================================
26
26
  LAPORAN GENERATOR CLI (Multi-Host AI Engine)
27
27
  ========================================================
28
- Versi: 2.7.0
28
+ Versi: 2.8.0
29
29
 
30
30
  Penggunaan:
31
31
  npx laporan-generator <perintah> [opsi]
package/cover.md CHANGED
@@ -22,21 +22,25 @@ Penulis menyadari bahwa laporan ini masih jauh dari sempurna. Oleh karena itu, k
22
22
  depth: 3,
23
23
  )
24
24
  #context {
25
- let imgs = query(figure.where(kind: image))
26
- if imgs.len() > 0 {
27
- pagebreak()
28
- outline(
29
- title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR GAMBAR]],
30
- target: figure.where(kind: image),
31
- )
25
+ if opt-daftar-gambar {
26
+ let imgs = query(figure.where(kind: image))
27
+ if imgs.len() > 0 {
28
+ pagebreak()
29
+ outline(
30
+ title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR GAMBAR]],
31
+ target: figure.where(kind: image),
32
+ )
33
+ }
32
34
  }
33
- let tbls = query(figure.where(kind: table))
34
- if tbls.len() > 0 {
35
- pagebreak()
36
- outline(
37
- title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR TABEL]],
38
- target: figure.where(kind: table),
39
- )
35
+ if opt-daftar-tabel {
36
+ let tbls = query(figure.where(kind: table))
37
+ if tbls.len() > 0 {
38
+ pagebreak()
39
+ outline(
40
+ title: align(center)[#text(size: 14pt, weight: "bold")[DAFTAR TABEL]],
41
+ target: figure.where(kind: table),
42
+ )
43
+ }
40
44
  }
41
45
  }
42
46
  #pagebreak()
@@ -49,6 +49,8 @@ abstract: |
49
49
  | `faculty` | String | **Ya** | Nama Fakultas, Departemen, atau Program Studi. |
50
50
  | `year` | String | **Ya** | Tahun Ajaran (contoh: `2025/2026`). |
51
51
  | `date` | String | Opsional | Bulan dan Tahun pembuatan (contoh: `Juli 2026`). |
52
+ | `daftar_gambar` | Boolean | Opsional | Aktifkan halaman DAFTAR GAMBAR (`true`/`false`, default: `false`). |
53
+ | `daftar_tabel` | Boolean | Opsional | Aktifkan halaman DAFTAR TABEL (`true`/`false`, default: `false`). |
52
54
  | `abstract` | Multiline String | Opsional | Ringkasan/Abstrak laporan dalam Bahasa Indonesia. |
53
55
 
54
56
  ---
package/docx.lua CHANGED
@@ -138,9 +138,24 @@ local function meta_str(meta, key)
138
138
  if v == nil then
139
139
  return ""
140
140
  end
141
+ if type(v) == "boolean" then
142
+ return tostring(v)
143
+ end
141
144
  return pandoc.utils.stringify(v)
142
145
  end
143
146
 
147
+ local function meta_bool(meta, key)
148
+ local v = meta[key]
149
+ if v == nil then
150
+ return false
151
+ end
152
+ if type(v) == "boolean" then
153
+ return v
154
+ end
155
+ local s = meta_str(meta, key):lower():gsub("%s+", "")
156
+ return s == "true" or s == "1" or s == "yes"
157
+ end
158
+
144
159
  local function format_chapter_number(n)
145
160
  if chapter_num_fmt == "arabic" then
146
161
  return tostring(n)
@@ -280,6 +295,44 @@ local function daftar_isi()
280
295
  return out
281
296
  end
282
297
 
298
+ local function daftar_gambar()
299
+ local out = pandoc.List{}
300
+ out[#out + 1] = pagebreak()
301
+ out[#out + 1] = pandoc.RawBlock(
302
+ "openxml",
303
+ '<w:p><w:pPr><w:pStyle w:val="TOCHeading"/><w:jc w:val="center"/><w:outlineLvl w:val="9"/></w:pPr>' ..
304
+ '<w:r><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>DAFTAR GAMBAR</w:t></w:r></w:p>'
305
+ )
306
+ out[#out + 1] = pandoc.RawBlock(
307
+ "openxml",
308
+ '<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>' ..
309
+ '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' ..
310
+ '<w:r><w:instrText xml:space="preserve"> TOC \\c "Gambar" \\h \\z </w:instrText></w:r>' ..
311
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' ..
312
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>'
313
+ )
314
+ return out
315
+ end
316
+
317
+ local function daftar_tabel()
318
+ local out = pandoc.List{}
319
+ out[#out + 1] = pagebreak()
320
+ out[#out + 1] = pandoc.RawBlock(
321
+ "openxml",
322
+ '<w:p><w:pPr><w:pStyle w:val="TOCHeading"/><w:jc w:val="center"/><w:outlineLvl w:val="9"/></w:pPr>' ..
323
+ '<w:r><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>DAFTAR TABEL</w:t></w:r></w:p>'
324
+ )
325
+ out[#out + 1] = pandoc.RawBlock(
326
+ "openxml",
327
+ '<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>' ..
328
+ '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' ..
329
+ '<w:r><w:instrText xml:space="preserve"> TOC \\c "Tabel" \\h \\z </w:instrText></w:r>' ..
330
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' ..
331
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>'
332
+ )
333
+ return out
334
+ end
335
+
283
336
  function Pandoc(doc)
284
337
  if FORMAT ~= "docx" then
285
338
  return doc
@@ -292,6 +345,8 @@ function Pandoc(doc)
292
345
  local faculty = meta_str(meta, "faculty")
293
346
  local institution = meta_str(meta, "institution")
294
347
  local year = meta_str(meta, "year")
348
+ local show_dg = (meta_bool(meta, "daftar_gambar") or meta_bool(meta, "list_of_figures"))
349
+ local show_dt = (meta_bool(meta, "daftar_tabel") or meta_bool(meta, "list_of_tables"))
295
350
 
296
351
  local blocks = pandoc.List{}
297
352
 
@@ -401,6 +456,16 @@ function Pandoc(doc)
401
456
  for _, b in ipairs(daftar_isi()) do
402
457
  body[#body + 1] = b
403
458
  end
459
+ if show_dg then
460
+ for _, b in ipairs(daftar_gambar()) do
461
+ body[#body + 1] = b
462
+ end
463
+ end
464
+ if show_dt then
465
+ for _, b in ipairs(daftar_tabel()) do
466
+ body[#body + 1] = b
467
+ end
468
+ end
404
469
  inserted = true
405
470
  end
406
471
  body[#body + 1] = blk
package/laporan CHANGED
@@ -27,7 +27,7 @@ export PYTHONUTF8=1
27
27
  show_banner() {
28
28
  echo -e "${CYAN}${BOLD}"
29
29
  echo " ========================================================"
30
- echo " LAPORAN GENERATOR CLI v2.7.0 "
30
+ echo " LAPORAN GENERATOR CLI v2.8.0 "
31
31
  echo " Otomatisasi Dokumen Akademik (Typst + DOCX Engine) "
32
32
  echo " ========================================================"
33
33
  echo -e "${NC}"
package/laporan.ps1 CHANGED
@@ -40,7 +40,7 @@ function Get-PythonCommand {
40
40
 
41
41
  function Show-Banner {
42
42
  Write-Host " ========================================================" -ForegroundColor Cyan
43
- Write-Host " LAPORAN GENERATOR CLI v2.7.0 " -ForegroundColor Cyan
43
+ Write-Host " LAPORAN GENERATOR CLI v2.8.0 " -ForegroundColor Cyan
44
44
  Write-Host " Otomatisasi Dokumen Akademik (Typst + DOCX Engine) " -ForegroundColor Cyan
45
45
  Write-Host " ========================================================" -ForegroundColor Cyan
46
46
  Write-Host ""
package/lib/init.js CHANGED
@@ -23,6 +23,7 @@ function copyRecursiveSync(src, dest) {
23
23
  function initProject(options = {}) {
24
24
  const targetDir = options.targetDir ? path.resolve(options.targetDir) : process.cwd();
25
25
  const silent = !!options.silent;
26
+ const isTruthy = (val) => val === true || val === 'true';
26
27
 
27
28
  if (!fs.existsSync(targetDir)) {
28
29
  fs.mkdirSync(targetDir, { recursive: true });
@@ -103,6 +104,8 @@ faculty: "${options.faculty || 'Fakultas Teknik'}"
103
104
  department: "${options.department || 'Program Studi Informatika'}"
104
105
  year: "${options.year || '2026'}"
105
106
  preset: "${options.preset || 'skripsi-4433'}"
107
+ daftar_gambar: ${isTruthy(options.daftar_gambar) ? 'true' : 'false'}
108
+ daftar_tabel: ${isTruthy(options.daftar_tabel) ? 'true' : 'false'}
106
109
  ...
107
110
  `;
108
111
  fs.writeFileSync(metaPath, defaultMeta, 'utf8');
@@ -124,7 +127,7 @@ preset: "${options.preset || 'skripsi-4433'}"
124
127
  }
125
128
 
126
129
  // Update metadata.yml if custom options were provided
127
- if (fs.existsSync(metaPath) && (options.title || options.preset || options.authorName || options.institution)) {
130
+ if (fs.existsSync(metaPath) && (options.title || options.preset || options.authorName || options.institution || options.daftar_gambar !== undefined || options.daftar_tabel !== undefined)) {
128
131
  try {
129
132
  let metaContent = fs.readFileSync(metaPath, 'utf8');
130
133
  if (options.title) {
@@ -151,8 +154,26 @@ preset: "${options.preset || 'skripsi-4433'}"
151
154
  metaContent = metaContent.replace(/nim:\s*".*?"/, `nim: "${options.authorId}"`);
152
155
  }
153
156
  }
157
+ if (options.daftar_gambar !== undefined) {
158
+ const val = isTruthy(options.daftar_gambar) ? 'true' : 'false';
159
+ if (/^daftar_gambar:\s*(true|false)/m.test(metaContent)) {
160
+ metaContent = metaContent.replace(/^daftar_gambar:\s*(true|false)/m, `daftar_gambar: ${val}`);
161
+ } else {
162
+ metaContent = metaContent.replace(/preset:\s*".*?"/m, `daftar_gambar: ${val}\n$&`);
163
+ }
164
+ }
165
+ if (options.daftar_tabel !== undefined) {
166
+ const val = isTruthy(options.daftar_tabel) ? 'true' : 'false';
167
+ if (/^daftar_tabel:\s*(true|false)/m.test(metaContent)) {
168
+ metaContent = metaContent.replace(/^daftar_tabel:\s*(true|false)/m, `daftar_tabel: ${val}`);
169
+ } else {
170
+ metaContent = metaContent.replace(/preset:\s*".*?"/m, `daftar_tabel: ${val}\n$&`);
171
+ }
172
+ }
154
173
  fs.writeFileSync(metaPath, metaContent, 'utf8');
155
- } catch (_) {}
174
+ } catch (err) {
175
+ console.warn(`[init] Failed to customize metadata.yml: ${err.message}`);
176
+ }
156
177
  }
157
178
 
158
179
  // Ensure scripts are executable on Unix
@@ -163,7 +184,9 @@ preset: "${options.preset || 'skripsi-4433'}"
163
184
  if (fs.existsSync(laporanBin)) fs.chmodSync(laporanBin, '755');
164
185
  if (fs.existsSync(buildSh)) fs.chmodSync(buildSh, '755');
165
186
  }
166
- } catch (_) {}
187
+ } catch (err) {
188
+ console.warn(`[init] Failed to set executable permissions: ${err.message}`);
189
+ }
167
190
 
168
191
  return {
169
192
  success: true,
package/lib/mcp.js CHANGED
@@ -7,7 +7,7 @@ const https = require("https");
7
7
  const { spawnSync } = require("child_process");
8
8
  const { initProject } = require("./init");
9
9
 
10
- const VERSION = "2.7.0";
10
+ const VERSION = "2.8.0";
11
11
  const PACKAGE_ROOT = path.join(__dirname, "..");
12
12
 
13
13
  function getPythonCommand() {
@@ -92,6 +92,14 @@ const TOOLS = [
92
92
  type: "string",
93
93
  description: "Academic year (e.g. 2026)."
94
94
  },
95
+ daftar_gambar: {
96
+ type: "boolean",
97
+ description: "Whether to include Daftar Gambar (List of Figures). Default is false."
98
+ },
99
+ daftar_tabel: {
100
+ type: "boolean",
101
+ description: "Whether to include Daftar Tabel (List of Tables). Default is false."
102
+ },
95
103
  force: {
96
104
  type: "boolean",
97
105
  description: "Whether to overwrite existing files if already present."
@@ -211,6 +219,8 @@ async function handleToolCall(name, args = {}) {
211
219
  faculty: args.faculty,
212
220
  department: args.department,
213
221
  year: args.year,
222
+ daftar_gambar: args.daftar_gambar,
223
+ daftar_tabel: args.daftar_tabel,
214
224
  force: args.force,
215
225
  silent: true
216
226
  });
package/metadata.yml CHANGED
@@ -15,21 +15,18 @@ institution: "Universitas Example"
15
15
  faculty: "Program Studi Informatika"
16
16
  year: "2026/2027"
17
17
  date: "Agustus 2026"
18
- abstract_id: |
19
- Laporan ini membahas perancangan dan implementasi pipeline otomatisasi pembuatan dokumen akademik modern menggunakan Markdown, Pandoc, Typst, Lua Filter, dan multi-pass DOCX engine. Pipeline ini memungkinkan penulisan konten laporan dalam format Markdown yang secara otomatis dikonversi menjadi dokumen PDF berkualitas percetakan berbasis Typst serta dokumen Microsoft Word (.docx) berstruktur formal lengkap dengan penomoran bab, tabel, gambar, dan halaman otomatis yang mematuhi standar karya ilmiah perguruan tinggi di Indonesia.
20
- keywords_id:
21
- - Otomatisasi Dokumen
22
- - Pandoc
23
- - Typst
24
- - OpenXML
25
- - Rekayasa Perangkat Lunak
26
- abstract_en: |
27
- This report presents the design and implementation of a modern academic document automation pipeline utilizing Markdown, Pandoc, Typst, Lua Filters, and a multi-pass DOCX engine. This pipeline facilitates authoring scholarly reports in plain Markdown, seamlessly generating both print-quality Typst-based PDF documents and formally structured Microsoft Word (.docx) files. The generated outputs feature automated chapter, table, figure, and dual-pagination numbering compliant with standardized higher education guidelines.
28
- keywords_en:
29
- - Document Automation
30
- - Pandoc
31
- - Typst
32
- - OpenXML
33
- - Software Engineering
18
+ daftar_gambar: false
19
+ daftar_tabel: false
20
+ # Komponen halaman depan opsional (aktifkan jika diperlukan):
21
+ # abstract_id: |
22
+ # Ringkasan atau abstrak laporan dalam Bahasa Indonesia...
23
+ # keywords_id:
24
+ # - Kata Kunci 1
25
+ # - Kata Kunci 2
26
+ # abstract_en: |
27
+ # Summary or abstract of the report in English...
28
+ # keywords_en:
29
+ # - Keyword 1
30
+ # - Keyword 2
34
31
  preset: "standard"
35
32
  ...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laporan-generator",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Universal Academic Report & Thesis Generator with Multi-Host AI Agent Skills & Native MCP Server",
5
5
  "main": "bin/laporan-generator.js",
6
6
  "bin": {
@@ -184,6 +184,103 @@ class TestReportDoctor(unittest.TestCase):
184
184
  self.assertIn("citations", data)
185
185
 
186
186
 
187
+ class TestFrontMatterOptIn(unittest.TestCase):
188
+ def test_default_metadata_structure(self):
189
+ meta_path = os.path.join(get_project_root(), "metadata.yml")
190
+ with open(meta_path, "r", encoding="utf-8") as f:
191
+ content = f.read()
192
+ # Verify abstract is commented or empty by default
193
+ self.assertNotIn("\nabstract_id: |", content, "abstract_id should not be active by default in metadata.yml")
194
+ self.assertNotIn("\nabstract_en: |", content, "abstract_en should not be active by default in metadata.yml")
195
+ # Verify opt-in flags for daftar_gambar & daftar_tabel are explicitly false or commented
196
+ has_dg = "daftar_gambar: false" in content
197
+ has_dt = "daftar_tabel: false" in content
198
+ self.assertTrue(has_dg, "metadata.yml must explicitly specify daftar_gambar: false by default")
199
+ self.assertTrue(has_dt, "metadata.yml must explicitly specify daftar_tabel: false by default")
200
+
201
+ def test_cover_md_gated_outlines(self):
202
+ cover_path = os.path.join(get_project_root(), "cover.md")
203
+ with open(cover_path, "r", encoding="utf-8") as f:
204
+ content = f.read()
205
+ # Unconditional outline queries must not exist
206
+ self.assertNotIn("let imgs = query(figure.where(kind: image))\n if imgs.len() > 0", content,
207
+ "cover.md must not unconditionally emit DAFTAR GAMBAR outline")
208
+ self.assertNotIn("let tbls = query(figure.where(kind: table))\n if tbls.len() > 0", content,
209
+ "cover.md must not unconditionally emit DAFTAR TABEL outline")
210
+ # Must be gated by opt-in check
211
+ self.assertIn("opt-daftar-gambar", content, "cover.md must gate DAFTAR GAMBAR with opt-daftar-gambar")
212
+ self.assertIn("opt-daftar-tabel", content, "cover.md must gate DAFTAR TABEL with opt-daftar-tabel")
213
+
214
+ def test_lua_meta_bool_coercion(self):
215
+ """Verify docx.lua handles YAML boolean scalars and string equivalents safely."""
216
+ lua_code = """
217
+ local pandoc = {
218
+ utils = {
219
+ stringify = function(x)
220
+ if type(x) ~= 'string' and type(x) ~= 'table' then
221
+ error('pandoc.utils.stringify expects table or string, got ' .. type(x))
222
+ end
223
+ return tostring(x)
224
+ end
225
+ }
226
+ }
227
+ local function meta_str(meta, key)
228
+ local v = meta[key]
229
+ if v == nil then return "" end
230
+ if type(v) == "boolean" then return tostring(v) end
231
+ return pandoc.utils.stringify(v)
232
+ end
233
+ local function meta_bool(meta, key)
234
+ local v = meta[key]
235
+ if v == nil then return false end
236
+ if type(v) == "boolean" then return v end
237
+ local s = meta_str(meta, key):lower():gsub("%s+", "")
238
+ return s == "true" or s == "1" or s == "yes"
239
+ end
240
+
241
+ local meta = {
242
+ dg_bool_false = false,
243
+ dg_bool_true = true,
244
+ dg_str_false = "false",
245
+ dg_str_true = "true",
246
+ dg_nil = nil
247
+ }
248
+
249
+ assert(meta_str(meta, "dg_bool_false") == "false")
250
+ assert(meta_bool(meta, "dg_bool_false") == false)
251
+ assert(meta_str(meta, "dg_bool_true") == "true")
252
+ assert(meta_bool(meta, "dg_bool_true") == true)
253
+ assert(meta_bool(meta, "dg_str_false") == false)
254
+ assert(meta_bool(meta, "dg_str_true") == true)
255
+ assert(meta_bool(meta, "dg_nil") == false)
256
+ print("LUA_OK")
257
+ """
258
+ proc = subprocess.run(["lua", "-e", lua_code], capture_output=True, text=True)
259
+ self.assertEqual(proc.returncode, 0, f"Lua helper test failed: {proc.stderr}")
260
+ self.assertIn("LUA_OK", proc.stdout)
261
+
262
+ def test_init_project_boolean_coercion(self):
263
+ """Verify lib/init.js does not treat string 'false' as truthy."""
264
+ import tempfile
265
+ with tempfile.TemporaryDirectory() as tmpdir:
266
+ node_code = f"""
267
+ const path = require('path');
268
+ const {{ initProject }} = require('{os.path.join(ROOT_DIR, "lib/init.js")}');
269
+ initProject({{ targetDir: '{tmpdir}/test_f', daftar_gambar: 'false', daftar_tabel: false, silent: true }});
270
+ initProject({{ targetDir: '{tmpdir}/test_t', daftar_gambar: 'true', daftar_tabel: true, silent: true }});
271
+ """
272
+ proc = subprocess.run(["node", "-e", node_code], capture_output=True, text=True)
273
+ self.assertEqual(proc.returncode, 0, f"Node initProject failed: {proc.stderr}")
274
+ with open(os.path.join(tmpdir, "test_f", "metadata.yml"), "r", encoding="utf-8") as f:
275
+ meta_f = f.read()
276
+ with open(os.path.join(tmpdir, "test_t", "metadata.yml"), "r", encoding="utf-8") as f:
277
+ meta_t = f.read()
278
+ self.assertIn("daftar_gambar: false", meta_f)
279
+ self.assertIn("daftar_tabel: false", meta_f)
280
+ self.assertIn("daftar_gambar: true", meta_t)
281
+ self.assertIn("daftar_tabel: true", meta_t)
282
+
283
+
187
284
  if __name__ == "__main__":
188
285
  unittest.main(verbosity=2)
189
286
 
package/template.typ CHANGED
@@ -391,4 +391,7 @@ $endif$
391
391
  #pagebreak()
392
392
  $endif$
393
393
 
394
+ #let opt-daftar-gambar = $if(daftar_gambar)$$daftar_gambar$$else$$if(list_of_figures)$$list_of_figures$$else$false$endif$$endif$
395
+ #let opt-daftar-tabel = $if(daftar_tabel)$$daftar_tabel$$else$$if(list_of_tables)$$list_of_tables$$else$false$endif$$endif$
396
+
394
397
  $body$