laporan-generator 2.6.2 → 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.
Files changed (36) hide show
  1. package/.agents/skills/laporan-generator/SKILL.md +109 -109
  2. package/.agents/skills/laporan-generator/resources/cover.md +18 -14
  3. package/.agents/skills/laporan-generator/resources/docx.lua +65 -0
  4. package/.agents/skills/laporan-generator/resources/laporan +37 -4
  5. package/.agents/skills/laporan-generator/resources/laporan.ps1 +74 -28
  6. package/.agents/skills/laporan-generator/resources/metadata.yml +13 -16
  7. package/.agents/skills/laporan-generator/resources/scripts/bundle.py +11 -2
  8. package/.agents/skills/laporan-generator/resources/scripts/report-doctor.py +121 -48
  9. package/.agents/skills/laporan-generator/resources/scripts/report-stats.py +53 -2
  10. package/.agents/skills/laporan-generator/resources/scripts/test_scripts.py +160 -2
  11. package/.agents/skills/laporan-generator/resources/template.typ +3 -0
  12. package/.agents/skills/laporan-generator/scripts/bundle.py +11 -2
  13. package/.agents/skills/laporan-generator/scripts/finalize-docx.py +140 -1
  14. package/.agents/skills/laporan-generator/scripts/make-reference-docx.py +42 -2
  15. package/.agents/skills/laporan-generator/scripts/report-doctor.py +121 -48
  16. package/.agents/skills/laporan-generator/scripts/report-stats.py +53 -2
  17. package/.agents/skills/laporan-generator/scripts/test_scripts.py +185 -2
  18. package/README.md +49 -0
  19. package/bin/laporan-generator.js +15 -3
  20. package/bin/mcp-server.js +4 -0
  21. package/cover.md +18 -14
  22. package/docs/metadata-schema.md +2 -0
  23. package/docx.lua +65 -0
  24. package/laporan +37 -4
  25. package/laporan.ps1 +50 -11
  26. package/lib/init.js +139 -45
  27. package/lib/mcp.js +579 -0
  28. package/lib/sync-hosts.js +196 -68
  29. package/lib/uninstall.js +21 -0
  30. package/metadata.yml +13 -16
  31. package/package.json +9 -3
  32. package/scripts/bundle.py +11 -2
  33. package/scripts/report-doctor.py +121 -48
  34. package/scripts/report-stats.py +46 -2
  35. package/scripts/test_scripts.py +147 -3
  36. package/template.typ +3 -0
@@ -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
  ...
@@ -15,8 +15,17 @@ NC = "\033[0m"
15
15
 
16
16
 
17
17
  def main():
18
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
- os.chdir(root)
18
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
19
+
20
+ target_dir = os.getcwd()
21
+ if pos_args:
22
+ target_dir = os.path.abspath(pos_args[0])
23
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
24
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
26
+ target_dir = pkg_root
27
+
28
+ os.chdir(target_dir)
20
29
 
21
30
  print(f"{CYAN}{BOLD}")
22
31
  print(" ========================================================")
@@ -2,11 +2,19 @@
2
2
  """report-doctor.py: Audit komprehensif kesehatan dan integritas proyek laporan."""
3
3
 
4
4
  import glob
5
+ import json
5
6
  import os
6
7
  import re
7
8
  import shutil
8
9
  import sys
9
10
 
11
+ if sys.platform == "win32":
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+ except Exception:
16
+ pass
17
+
10
18
  GREEN = "\033[0;32m"
11
19
  BLUE = "\033[0;34m"
12
20
  CYAN = "\033[0;36m"
@@ -98,101 +106,166 @@ def check_style_anomalies(markdown_files):
98
106
 
99
107
 
100
108
  def main():
101
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
102
- os.chdir(root)
103
-
104
- print(f"{CYAN}{BOLD}")
105
- print(" ========================================================")
106
- print(" LAPORAN GENERATOR - DOCTOR & AUDIT SUITE ")
107
- print(" ========================================================")
108
- print(f"{NC}")
109
+ json_mode = "--json" in sys.argv
110
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
111
+
112
+ target_dir = os.getcwd()
113
+ if pos_args:
114
+ target_dir = os.path.abspath(pos_args[0])
115
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
116
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
117
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
118
+ target_dir = pkg_root
119
+
120
+ os.chdir(target_dir)
121
+
122
+ if not json_mode:
123
+ print(f"{CYAN}{BOLD}")
124
+ print(" ========================================================")
125
+ print(" LAPORAN GENERATOR - DOCTOR & AUDIT SUITE ")
126
+ print(" ========================================================")
127
+ print(f"{NC}")
109
128
 
110
129
  errors = 0
111
130
  warnings = 0
112
131
 
113
132
  # 1. Dependensi
114
- print(f" {BLUE}{BOLD}[1/4] Audit Toolchain & Dependensi Sistem{NC}")
133
+ if not json_mode:
134
+ print(f" {BLUE}{BOLD}[1/4] Audit Toolchain & Dependensi Sistem{NC}")
115
135
  deps, has_im = check_dependencies()
136
+ dep_list = []
116
137
  for cmd, name, req, found in deps:
117
138
  if "ImageMagick" in name:
118
139
  continue
119
- if found:
120
- print(f" {GREEN}[OK]{NC} {name:<26}: Terpasang ({cmd})")
121
- else:
122
- if req:
123
- print(f" {RED}[FAIL]{NC} {name:<24}: TIDAK DITEMUKAN (Wajib)")
124
- errors += 1
140
+ dep_list.append({"command": cmd, "name": name, "required": req, "found": found})
141
+ if not json_mode:
142
+ if found:
143
+ print(f" {GREEN}[OK]{NC} {name:<26}: Terpasang ({cmd})")
125
144
  else:
126
- print(f" {YELLOW}[INFO]{NC} {name:<24}: Tidak ditemukan (Opsional)")
145
+ if req:
146
+ print(f" {RED}[FAIL]{NC} {name:<24}: TIDAK DITEMUKAN (Wajib)")
147
+ else:
148
+ print(f" {YELLOW}[INFO]{NC} {name:<24}: Tidak ditemukan (Opsional)")
149
+ if not found and req:
150
+ errors += 1
127
151
 
128
- if has_im:
129
- print(f" {GREEN}[OK]{NC} {'ImageMagick':<26}: Terpasang")
130
- else:
131
- print(f" {YELLOW}[INFO]{NC} {'ImageMagick':<24}: Tidak ditemukan (Opsional untuk kompresi gambar)")
132
- print("")
152
+ dep_list.append({"command": "magick/convert", "name": "ImageMagick", "required": False, "found": has_im})
153
+ if not json_mode:
154
+ if has_im:
155
+ print(f" {GREEN}[OK]{NC} {'ImageMagick':<26}: Terpasang")
156
+ else:
157
+ print(f" {YELLOW}[INFO]{NC} {'ImageMagick':<24}: Tidak ditemukan (Opsional untuk kompresi gambar)")
158
+ print("")
133
159
 
134
160
  # 2. Berkas & Struktur
135
- print(f" {BLUE}{BOLD}[2/4] Audit Struktur Direktori & Berkas Wajib{NC}")
161
+ if not json_mode:
162
+ print(f" {BLUE}{BOLD}[2/4] Audit Struktur Direktori & Berkas Wajib{NC}")
136
163
  req_files = [
137
164
  "metadata.yml", "template.typ", "reference.docx",
138
165
  "references.bib", "docx.lua", "cover.md"
139
166
  ]
167
+ file_status = []
140
168
  for rf in req_files:
141
- if os.path.isfile(rf):
142
- print(f" {GREEN}[OK]{NC} Berkas {rf}")
143
- else:
144
- print(f" {RED}[FAIL]{NC} Berkas {rf} TIDAK DITEMUKAN")
169
+ exists = os.path.isfile(rf)
170
+ file_status.append({"file": rf, "found": exists})
171
+ if not json_mode:
172
+ if exists:
173
+ print(f" {GREEN}[OK]{NC} Berkas {rf}")
174
+ else:
175
+ print(f" {RED}[FAIL]{NC} Berkas {rf} TIDAK DITEMUKAN")
176
+ if not exists:
145
177
  errors += 1
146
178
 
147
179
  chapter_files = sorted(glob.glob("chapters/*.md"))
148
- if chapter_files:
149
- print(f" {GREEN}[OK]{NC} Direktori chapters/ ({len(chapter_files)} bab ditemukan)")
150
- else:
151
- print(f" {RED}[FAIL]{NC} Tidak ada berkas markdown di chapters/")
180
+ if not json_mode:
181
+ if chapter_files:
182
+ print(f" {GREEN}[OK]{NC} Direktori chapters/ ({len(chapter_files)} bab ditemukan)")
183
+ else:
184
+ print(f" {RED}[FAIL]{NC} Tidak ada berkas markdown di chapters/")
185
+ if not chapter_files:
152
186
  errors += 1
153
- print("")
187
+ if not json_mode:
188
+ print("")
154
189
 
155
190
  # 3. Sitasi & Referensi
156
- print(f" {BLUE}{BOLD}[3/4] Audit Sitasi & Integritas Bibliografi{NC}")
191
+ if not json_mode:
192
+ print(f" {BLUE}{BOLD}[3/4] Audit Sitasi & Integritas Bibliografi{NC}")
157
193
  bib_keys, cited_keys, missing_keys, orphan_keys = check_citations("references.bib", chapter_files)
158
- print(f" * Total entri .bib : {len(bib_keys)}")
159
- print(f" * Total sitasi di teks : {len(cited_keys)}")
194
+ if not json_mode:
195
+ print(f" * Total entri .bib : {len(bib_keys)}")
196
+ print(f" * Total sitasi di teks : {len(cited_keys)}")
160
197
 
161
198
  if missing_keys:
162
199
  for k, locs in missing_keys.items():
163
200
  loc_str = ", ".join([f"{f}:{l}" for f, l in locs])
164
- print(f" {RED}[ERROR]{NC} Sitasi '@{k}' TIDAK DITEMUKAN di references.bib ({loc_str})")
201
+ if not json_mode:
202
+ print(f" {RED}[ERROR]{NC} Sitasi '@{k}' TIDAK DITEMUKAN di references.bib ({loc_str})")
165
203
  errors += 1
166
- else:
204
+ elif not json_mode:
167
205
  print(f" {GREEN}[OK]{NC} Semua sitasi dalam teks valid terdaftar di references.bib")
168
206
 
169
207
  if orphan_keys:
170
- print(f" {YELLOW}[WARN]{NC} {len(orphan_keys)} entri di references.bib belum disitir dalam teks:")
171
- for ok in list(orphan_keys)[:5]:
172
- print(f" - @{ok}")
173
- if len(orphan_keys) > 5:
174
- print(f" - ... dan {len(orphan_keys) - 5} lainnya")
208
+ if not json_mode:
209
+ print(f" {YELLOW}[WARN]{NC} {len(orphan_keys)} entri di references.bib belum disitir dalam teks:")
210
+ for ok in list(orphan_keys)[:5]:
211
+ print(f" - @{ok}")
212
+ if len(orphan_keys) > 5:
213
+ print(f" - ... dan {len(orphan_keys) - 5} lainnya")
175
214
  warnings += 1
176
- print("")
215
+ if not json_mode:
216
+ print("")
177
217
 
178
218
  # 4. Gambar & Format Teks
179
- print(f" {BLUE}{BOLD}[4/4] Audit Media Citra & Anomali Tipografi{NC}")
219
+ if not json_mode:
220
+ print(f" {BLUE}{BOLD}[4/4] Audit Media Citra & Anomali Tipografi{NC}")
180
221
  total_imgs, broken_imgs = check_images(chapter_files)
181
222
  if broken_imgs:
182
223
  for p, f, l in broken_imgs:
183
- print(f" {RED}[ERROR]{NC} Berkas gambar tidak ditemukan: '{p}' (di {f}:{l})")
224
+ if not json_mode:
225
+ print(f" {RED}[ERROR]{NC} Berkas gambar tidak ditemukan: '{p}' (di {f}:{l})")
184
226
  errors += 1
185
- else:
227
+ elif not json_mode:
186
228
  print(f" {GREEN}[OK]{NC} Semua tautan gambar ({total_imgs} gambar) valid dan ada di disk")
187
229
 
188
230
  anomalies = check_style_anomalies(chapter_files)
189
231
  if anomalies:
190
232
  for f, l, msg in anomalies:
191
- print(f" {YELLOW}[WARN]{NC} {f}:{l} - {msg}")
233
+ if not json_mode:
234
+ print(f" {YELLOW}[WARN]{NC} {f}:{l} - {msg}")
192
235
  warnings += 1
193
- else:
236
+ elif not json_mode:
194
237
  print(f" {GREEN}[OK]{NC} Bebas dari manual heading numbering dan box-drawing characters")
195
- print("")
238
+ if not json_mode:
239
+ print("")
240
+
241
+ score = 100 if (errors == 0 and warnings == 0) else (95 if errors == 0 else max(0, 90 - errors * 15 - warnings * 5))
242
+ status_label = "HEALTHY" if errors == 0 and warnings == 0 else ("WARNING" if errors == 0 else "ERROR")
243
+
244
+ if json_mode:
245
+ result = {
246
+ "status": status_label,
247
+ "score": score,
248
+ "errors_count": errors,
249
+ "warnings_count": warnings,
250
+ "target_dir": os.getcwd(),
251
+ "dependencies": dep_list,
252
+ "required_files": file_status,
253
+ "chapters_count": len(chapter_files),
254
+ "chapters": [os.path.basename(c) for c in chapter_files],
255
+ "citations": {
256
+ "bib_count": len(bib_keys),
257
+ "cited_count": len(cited_keys),
258
+ "missing_keys": {k: [f"{f}:{l}" for f, l in locs] for k, locs in missing_keys.items()},
259
+ "orphan_keys": list(orphan_keys),
260
+ },
261
+ "images": {
262
+ "total": total_imgs,
263
+ "broken": [{"path": p, "file": f, "line": l} for p, f, l in broken_imgs],
264
+ },
265
+ "anomalies": [{"file": f, "line": l, "message": msg} for f, l, msg in anomalies],
266
+ }
267
+ print(json.dumps(result, indent=2))
268
+ return 1 if errors > 0 else 0
196
269
 
197
270
  # Skor Kesehatan
198
271
  print(" ========================================================")
@@ -2,10 +2,18 @@
2
2
  """report-stats.py: Menghitung statistik komprehensif dokumen laporan akademik."""
3
3
 
4
4
  import glob
5
+ import json
5
6
  import os
6
7
  import re
7
8
  import sys
8
9
 
10
+ if sys.platform == "win32":
11
+ try:
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
14
+ except Exception:
15
+ pass
16
+
9
17
  GREEN = "\033[0;32m"
10
18
  BLUE = "\033[0;34m"
11
19
  CYAN = "\033[0;36m"
@@ -48,8 +56,18 @@ def count_file_stats(filepath):
48
56
 
49
57
 
50
58
  def main():
51
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
- os.chdir(root)
59
+ json_mode = "--json" in sys.argv
60
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
61
+
62
+ target_dir = os.getcwd()
63
+ if pos_args:
64
+ target_dir = os.path.abspath(pos_args[0])
65
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
66
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
67
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
68
+ target_dir = pkg_root
69
+
70
+ os.chdir(target_dir)
53
71
 
54
72
  title = "Laporan Akademik"
55
73
  preset = "standard"
@@ -109,6 +127,39 @@ def main():
109
127
  sz = os.path.getsize("Laporan.docx")
110
128
  docx_size = f"{sz / 1024:.1f} KB"
111
129
 
130
+ if json_mode:
131
+ result = {
132
+ "title": title,
133
+ "preset": preset,
134
+ "target_dir": os.getcwd(),
135
+ "chapters_count": len(chapter_files),
136
+ "total_words": total_words,
137
+ "total_chars": total_chars,
138
+ "est_pages": est_pages,
139
+ "read_mins": read_mins,
140
+ "headings": {
141
+ "h1": total_h1,
142
+ "h2": total_h2,
143
+ "h3": total_h3,
144
+ },
145
+ "elements": {
146
+ "images": total_images,
147
+ "tables": total_tables,
148
+ "equations": total_equations,
149
+ },
150
+ "citations": {
151
+ "unique_cited": len(all_citations),
152
+ "bib_entries": bib_count,
153
+ },
154
+ "compiled_files": {
155
+ "pdf": pdf_size,
156
+ "docx": docx_size,
157
+ },
158
+ "file_breakdown": [{"filename": f, "words": c} for f, c in file_breakdown],
159
+ }
160
+ print(json.dumps(result, indent=2))
161
+ return
162
+
112
163
  print(f"{CYAN}{BOLD}")
113
164
  print(" ========================================================")
114
165
  print(" LAPORAN GENERATOR - STATISTIK DOKUMEN ")
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """test_scripts.py: Automated unit tests for Laporan Generator Python helper scripts."""
3
3
 
4
+ import json
4
5
  import os
6
+ import subprocess
5
7
  import sys
6
8
  import unittest
7
9
 
@@ -36,16 +38,39 @@ class TestScanPreset(unittest.TestCase):
36
38
  self.assertIn("UNIVERSITAS INDONESIA", inst)
37
39
 
38
40
 
41
+ def get_presets_dir():
42
+ for c in [
43
+ os.path.join(ROOT_DIR, "presets"),
44
+ os.path.join(ROOT_DIR, "resources", "presets"),
45
+ os.path.join(ROOT_DIR, "..", "..", "..", "presets"),
46
+ ]:
47
+ if os.path.isdir(c):
48
+ return os.path.abspath(c)
49
+ return os.path.join(ROOT_DIR, "presets")
50
+
51
+
52
+ def get_project_root():
53
+ for c in [
54
+ ROOT_DIR,
55
+ os.path.join(ROOT_DIR, "resources"),
56
+ os.path.join(ROOT_DIR, "..", "..", ".."),
57
+ ]:
58
+ if os.path.isfile(os.path.join(c, "metadata.yml")):
59
+ return os.path.abspath(c)
60
+ return ROOT_DIR
61
+
62
+
39
63
  class TestValidatePreset(unittest.TestCase):
40
64
  def test_parse_simple_yaml(self):
41
- test_yaml = os.path.join(ROOT_DIR, "presets", "standard.yml")
65
+ presets_dir = get_presets_dir()
66
+ test_yaml = os.path.join(presets_dir, "standard.yml")
42
67
  self.assertTrue(os.path.exists(test_yaml))
43
68
  data = validate_preset.parse_simple_yaml(test_yaml)
44
69
  self.assertEqual(data.get("preset_id"), "standard")
45
70
  self.assertEqual(data.get("margin_top"), "2cm")
46
71
 
47
72
  def test_validate_all_presets(self):
48
- presets_dir = os.path.join(ROOT_DIR, "presets")
73
+ presets_dir = get_presets_dir()
49
74
  for f in os.listdir(presets_dir):
50
75
  if f.endswith(".yml"):
51
76
  path = os.path.join(presets_dir, f)
@@ -123,6 +148,139 @@ class TestFinalizeDocx(unittest.TestCase):
123
148
  self.assertIn('w:left="720"', res)
124
149
 
125
150
 
151
+ report_stats = __import__("report-stats")
152
+
153
+
154
+ class TestReportStats(unittest.TestCase):
155
+ def test_count_file_stats_structure(self):
156
+ sample_md = os.path.join(get_project_root(), "cover.md")
157
+ if os.path.exists(sample_md):
158
+ stats = report_stats.count_file_stats(sample_md)
159
+ self.assertIsInstance(stats, dict)
160
+ self.assertIsInstance(stats["words"], int)
161
+ self.assertIsInstance(stats["chars"], int)
162
+ self.assertGreaterEqual(stats["words"], 0)
163
+
164
+ def test_json_stats_execution(self):
165
+ proj_root = get_project_root()
166
+ cmd = [sys.executable, os.path.join(SCRIPTS_DIR, "report-stats.py"), proj_root, "--json"]
167
+ proc = subprocess.run(cmd, capture_output=True, text=True, cwd=proj_root)
168
+ self.assertEqual(proc.returncode, 0)
169
+ data = json.loads(proc.stdout)
170
+ self.assertIn("total_words", data)
171
+ self.assertIn("preset", data)
172
+ self.assertIn("target_dir", data)
173
+
174
+
175
+ class TestReportDoctor(unittest.TestCase):
176
+ def test_json_doctor_execution(self):
177
+ proj_root = get_project_root()
178
+ cmd = [sys.executable, os.path.join(SCRIPTS_DIR, "report-doctor.py"), proj_root, "--json"]
179
+ proc = subprocess.run(cmd, capture_output=True, text=True, cwd=proj_root)
180
+ data = json.loads(proc.stdout)
181
+ self.assertIn("status", data)
182
+ self.assertIn("dependencies", data)
183
+ self.assertIn("required_files", data)
184
+ self.assertIn("citations", data)
185
+
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
+
126
284
  if __name__ == "__main__":
127
285
  unittest.main(verbosity=2)
128
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$
@@ -15,8 +15,17 @@ NC = "\033[0m"
15
15
 
16
16
 
17
17
  def main():
18
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
- os.chdir(root)
18
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
19
+
20
+ target_dir = os.getcwd()
21
+ if pos_args:
22
+ target_dir = os.path.abspath(pos_args[0])
23
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
24
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
26
+ target_dir = pkg_root
27
+
28
+ os.chdir(target_dir)
20
29
 
21
30
  print(f"{CYAN}{BOLD}")
22
31
  print(" ========================================================")