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.
- package/.agents/skills/laporan-generator/SKILL.md +109 -109
- package/.agents/skills/laporan-generator/resources/cover.md +18 -14
- package/.agents/skills/laporan-generator/resources/docx.lua +65 -0
- package/.agents/skills/laporan-generator/resources/laporan +37 -4
- package/.agents/skills/laporan-generator/resources/laporan.ps1 +74 -28
- package/.agents/skills/laporan-generator/resources/metadata.yml +13 -16
- package/.agents/skills/laporan-generator/resources/scripts/bundle.py +11 -2
- package/.agents/skills/laporan-generator/resources/scripts/report-doctor.py +121 -48
- package/.agents/skills/laporan-generator/resources/scripts/report-stats.py +53 -2
- package/.agents/skills/laporan-generator/resources/scripts/test_scripts.py +160 -2
- package/.agents/skills/laporan-generator/resources/template.typ +3 -0
- package/.agents/skills/laporan-generator/scripts/bundle.py +11 -2
- package/.agents/skills/laporan-generator/scripts/finalize-docx.py +140 -1
- package/.agents/skills/laporan-generator/scripts/make-reference-docx.py +42 -2
- package/.agents/skills/laporan-generator/scripts/report-doctor.py +121 -48
- package/.agents/skills/laporan-generator/scripts/report-stats.py +53 -2
- package/.agents/skills/laporan-generator/scripts/test_scripts.py +185 -2
- package/README.md +49 -0
- package/bin/laporan-generator.js +15 -3
- package/bin/mcp-server.js +4 -0
- package/cover.md +18 -14
- package/docs/metadata-schema.md +2 -0
- package/docx.lua +65 -0
- package/laporan +37 -4
- package/laporan.ps1 +50 -11
- package/lib/init.js +139 -45
- package/lib/mcp.js +579 -0
- package/lib/sync-hosts.js +196 -68
- package/lib/uninstall.js +21 -0
- package/metadata.yml +13 -16
- package/package.json +9 -3
- package/scripts/bundle.py +11 -2
- package/scripts/report-doctor.py +121 -48
- package/scripts/report-stats.py +46 -2
- package/scripts/test_scripts.py +147 -3
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
abstract_en: |
|
|
27
|
-
|
|
28
|
-
keywords_en:
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
187
|
+
if not json_mode:
|
|
188
|
+
print("")
|
|
154
189
|
|
|
155
190
|
# 3. Sitasi & Referensi
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
215
|
+
if not json_mode:
|
|
216
|
+
print("")
|
|
177
217
|
|
|
178
218
|
# 4. Gambar & Format Teks
|
|
179
|
-
|
|
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
|
-
|
|
224
|
+
if not json_mode:
|
|
225
|
+
print(f" {RED}[ERROR]{NC} Berkas gambar tidak ditemukan: '{p}' (di {f}:{l})")
|
|
184
226
|
errors += 1
|
|
185
|
-
|
|
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
|
-
|
|
233
|
+
if not json_mode:
|
|
234
|
+
print(f" {YELLOW}[WARN]{NC} {f}:{l} - {msg}")
|
|
192
235
|
warnings += 1
|
|
193
|
-
|
|
236
|
+
elif not json_mode:
|
|
194
237
|
print(f" {GREEN}[OK]{NC} Bebas dari manual heading numbering dan box-drawing characters")
|
|
195
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
19
|
-
|
|
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(" ========================================================")
|