@cosla/sensemaking-report-builder 0.1.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 ADDED
@@ -0,0 +1,117 @@
1
+ # @cosla/sensemaking-report-builder
2
+
3
+ > **Cosla fork.** Maintained by [Cosla](https://github.com/CoslaDigital/sensemaking-tools) from the interactive report UI in [Jigsaw sensemaking-tools](https://github.com/Jigsaw-Code/sensemaking-tools) (`src/report_ui`). Not an official Jigsaw or Google release. Cosla adds a path-configurable CLI so Consul and other jobs can pass pipeline artefacts without copying into a package `input/` folder.
4
+
5
+ Builds an interactive HTML report from **Python pipeline** outputs: bridging-scores / opinions CSV plus `report_data.json` (summary).
6
+
7
+ This is **not** [`@cosla/sensemaking-report-ui`](https://www.npmjs.com/package/@cosla/sensemaking-report-ui), which consumes Node advanced JSON (`topic-stats.json`, `comments.json`, etc.).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @cosla/sensemaking-report-builder
13
+ # or
14
+ npx @cosla/sensemaking-report-builder inline --help
15
+ ```
16
+
17
+ CLI binary: `sensemaking-report-builder`.
18
+
19
+ ## Quick start (CLI)
20
+
21
+ ### Inline (single self-contained HTML) — use `--output`
22
+
23
+ ```bash
24
+ npx @cosla/sensemaking-report-builder inline \
25
+ --bridging_scores /path/to/bridging_scores.csv \
26
+ --summary /path/to/report_data.json \
27
+ --output /path/to/report.html
28
+ ```
29
+
30
+ `--opinions` is an alias for `--bridging_scores`.
31
+
32
+ ### Static (HTML + CSS/JS siblings) — use `--outputDir`
33
+
34
+ ```bash
35
+ npx @cosla/sensemaking-report-builder static \
36
+ --bridging_scores /path/to/bridging_scores.csv \
37
+ --summary /path/to/report_data.json \
38
+ --outputDir /path/to/out
39
+ # → /path/to/out/report.html plus assets
40
+ ```
41
+
42
+ Using the wrong output flag for the mode fails with a clear error (`inline` forbids `--outputDir`; `static` forbids `--output`).
43
+
44
+ ### Directory-oriented (prepared `input/`)
45
+
46
+ ```bash
47
+ npx @cosla/sensemaking-report-builder inline \
48
+ --inputDir ./input \
49
+ --output ./output/report.html
50
+ ```
51
+
52
+ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`, optional `<inputDir>/predicted.json` and `<inputDir>/config.json` if present.
53
+
54
+ ## CLI options
55
+
56
+ | Arg / flag | Purpose | Default |
57
+ |------------|---------|---------|
58
+ | `inline` \| `static` | Build mode (**required**) | — |
59
+ | `--inputDir` | Base dir for default input paths | `./input` |
60
+ | `--opinions` / `--bridging_scores` | Opinions / bridging scores CSV | `<inputDir>/opinions.csv` |
61
+ | `--summary` | Summary JSON (`report_data.json`) | `<inputDir>/summary.json` |
62
+ | `--predicted` | Predicted-agreement JSON | `<inputDir>/predicted.json` if present |
63
+ | `--config` | Config JSON (logo, exclusions, …) | `<inputDir>/config.json` if present; else `{}` |
64
+ | `--output` | **inline only:** path to HTML file | `./output/report.html` |
65
+ | `--outputDir` | **static only:** output directory | `./output` |
66
+
67
+ Logo and translations are configured in `config.json` (not CLI flags). Place `logo.svg` / `logo.png` next to that config (under `--inputDir` when using dir mode).
68
+
69
+ ## Input contracts
70
+
71
+ ### Opinions / bridging CSV
72
+
73
+ Required columns: `topic`, `opinion`, `quote`, `participant_id`.
74
+
75
+ Optional: `AVERAGE_OF_2_BRIDGING` or `AVERAGE_OF_3_BRIDGING` (used to sort quotes). Demographic columns: prefix with `demo:` (e.g. `demo:Age`).
76
+
77
+ ### Summary JSON
78
+
79
+ Same shape as Python `sensemaking-report-text` / `report_data.json`: `text`, `sub_contents[]` with `title` and `text`. Optional top-level `title`.
80
+
81
+ ### Config JSON (optional)
82
+
83
+ | Key | Description |
84
+ | :--- | :--- |
85
+ | `logo` | Header image filename (e.g. `"logo.svg"`) |
86
+ | `translations` | Optional i18n filename under the input dir |
87
+ | `overview_chart` | `"toggle"`, `"topics"`, or `"opinions"` |
88
+ | `number_of_top_opinions` | Opinions overview chart size |
89
+ | `number_of_sample_quotes` | Quote previews per opinion |
90
+ | `low_sample_warning_threshold` | Low-sample warning threshold |
91
+ | `topic_colors` / `chart_colors` | Overview chart colours |
92
+ | `demographic_colors` | Participant chart colours |
93
+ | `excludedTopics` / `excludedOpinions` | Hide named topics/opinions |
94
+
95
+ ### Predicted agreement (optional)
96
+
97
+ `predicted.json` with `text` and `sub_contents[]` containing `statements[]` with `text` and `predicted_agreement`.
98
+
99
+ ### Translations (optional)
100
+
101
+ Copy `src/default-translations.json`, translate values, set `locale` / `direction` (`ltr` or `rtl`). See that file for keys.
102
+
103
+ ## Local development (git checkout)
104
+
105
+ From this directory:
106
+
107
+ ```bash
108
+ npm install
109
+ npm run inline # → ./output/report.html
110
+ npm run static # → ./output/report.html + assets
111
+ npm run preview # browser-sync on ./output (after static)
112
+ ```
113
+
114
+ ## Related
115
+
116
+ - Python CLIs: PyPI [`cosla-sensemaking-tools`](https://pypi.org/project/cosla-sensemaking-tools/) — see repo [README.md](../../README.md) and [PACKAGING.md](../../PACKAGING.md).
117
+ - Node report UI (different inputs): [`@cosla/sensemaking-report-ui`](https://www.npmjs.com/package/@cosla/sensemaking-report-ui).
package/bin/cli.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { runBuild } from "../build.js";
3
+
4
+ runBuild(process.argv, process.cwd())
5
+ .then((result) => {
6
+ const out = result.output || result.outputDir;
7
+ console.log(`Standalone report generated: ${out}`);
8
+ })
9
+ .catch((error) => {
10
+ console.error(error.message);
11
+ process.exit(1);
12
+ });
package/build.js ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * @fileoverview Build automation for static and inlined HTML reports.
3
+ * CLI-first entry via {@link runBuild}, modeled on @cosla/sensemaking-report-ui.
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { execSync } from "node:child_process";
10
+ import csv from "csvtojson";
11
+ import mustache from "mustache";
12
+ import { inlineSource } from "inline-source";
13
+ import { processReportData, resolveBuildOptions } from "./data.js";
14
+
15
+ const WORK_DIR_NAME = ".py-report-ui-work";
16
+
17
+ /**
18
+ * Recursively removes a directory or file if it exists.
19
+ * @param {string} dirPath
20
+ */
21
+ function rm(dirPath) {
22
+ if (fs.existsSync(dirPath)) {
23
+ fs.rmSync(dirPath, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Creates a directory recursively if it does not already exist.
29
+ * @param {string} dirPath
30
+ */
31
+ function mkdir(dirPath) {
32
+ if (!fs.existsSync(dirPath)) {
33
+ fs.mkdirSync(dirPath, { recursive: true });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Copies a source file or directory to a destination.
39
+ * @param {string} src
40
+ * @param {string} dest
41
+ */
42
+ function cp(src, dest) {
43
+ if (!fs.existsSync(src)) return;
44
+ let destination = dest;
45
+ if (fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
46
+ destination = path.join(dest, path.basename(src));
47
+ }
48
+ fs.cpSync(src, destination, { recursive: true });
49
+ }
50
+
51
+ /**
52
+ * Stages caller inputs into a fixed layout under workDir.
53
+ * @param {import("./data.js").BuildOptions} options
54
+ * @param {string} workDir
55
+ * @returns {{
56
+ * opinionsCsv: string,
57
+ * summaryPath: string,
58
+ * configPath: string|null,
59
+ * predictedPath: string|null,
60
+ * stagedInputDir: string,
61
+ * }}
62
+ */
63
+ function stageInputs(options, workDir) {
64
+ const stagedInputDir = path.join(workDir, "input");
65
+ mkdir(stagedInputDir);
66
+
67
+ const opinionsCsv = path.join(stagedInputDir, "opinions.csv");
68
+ fs.copyFileSync(options.opinionsPath, opinionsCsv);
69
+
70
+ const summaryPath = path.join(stagedInputDir, "summary.json");
71
+ fs.copyFileSync(options.summaryPath, summaryPath);
72
+
73
+ let configPath = null;
74
+ if (options.configPath) {
75
+ configPath = path.join(stagedInputDir, "config.json");
76
+ fs.copyFileSync(options.configPath, configPath);
77
+ }
78
+
79
+ let predictedPath = null;
80
+ if (options.predictedPath) {
81
+ predictedPath = path.join(stagedInputDir, "predicted.json");
82
+ fs.copyFileSync(options.predictedPath, predictedPath);
83
+ }
84
+
85
+ if (fs.existsSync(options.inputDir)) {
86
+ for (const name of fs.readdirSync(options.inputDir)) {
87
+ if (name.startsWith("logo.")) {
88
+ cp(path.join(options.inputDir, name), stagedInputDir);
89
+ }
90
+ }
91
+ const translations = path.join(options.inputDir, "translations.json");
92
+ if (fs.existsSync(translations)) {
93
+ cp(translations, stagedInputDir);
94
+ }
95
+ if (configPath) {
96
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
97
+ if (config.translations) {
98
+ const t1 = path.join(options.inputDir, config.translations);
99
+ const t2 = path.join(options.inputDir, `${config.translations}.json`);
100
+ if (fs.existsSync(t1)) cp(t1, stagedInputDir);
101
+ else if (fs.existsSync(t2)) cp(t2, stagedInputDir);
102
+ }
103
+ }
104
+ }
105
+
106
+ return {
107
+ opinionsCsv,
108
+ summaryPath,
109
+ configPath,
110
+ predictedPath,
111
+ stagedInputDir,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Copies package src assets (except mustache) into dest.
117
+ * @param {string} packageRoot
118
+ * @param {string} dest
119
+ */
120
+ function copySrcAssets(packageRoot, dest) {
121
+ const srcDir = path.join(packageRoot, "src");
122
+ if (!fs.existsSync(srcDir)) return;
123
+ for (const file of fs.readdirSync(srcDir)) {
124
+ if (file === "index.mustache") continue;
125
+ cp(path.join(srcDir, file), dest);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Copies logo.* from staged input into dest.
131
+ * @param {string} stagedInputDir
132
+ * @param {string} dest
133
+ */
134
+ function copyLogos(stagedInputDir, dest) {
135
+ if (!fs.existsSync(stagedInputDir)) return;
136
+ for (const name of fs.readdirSync(stagedInputDir)) {
137
+ if (name.startsWith("logo.")) {
138
+ cp(path.join(stagedInputDir, name), dest);
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Replaces font file URLs with base64 data URIs from sibling .txt files.
145
+ * @param {string} html
146
+ * @param {string} fontsDir
147
+ * @returns {string}
148
+ */
149
+ function inlineFontDataUris(html, fontsDir) {
150
+ if (!fs.existsSync(fontsDir)) return html;
151
+ let result = html;
152
+ const fontFiles = fs
153
+ .readdirSync(fontsDir)
154
+ .filter((f) => f.endsWith(".woff2"));
155
+ for (const fontFile of fontFiles) {
156
+ const txtFile = path.join(
157
+ fontsDir,
158
+ fontFile.replace(/\.woff2$/, ".txt"),
159
+ );
160
+ if (!fs.existsSync(txtFile)) continue;
161
+ const dataUri = fs.readFileSync(txtFile, "utf-8").trim();
162
+ result = result.split(`fonts/${fontFile}`).join(dataUri);
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Runs the full report build.
169
+ * @param {string[]} [argv=process.argv]
170
+ * @param {string} [cwd=process.cwd()]
171
+ * @returns {Promise<{output?: string, outputDir?: string}>}
172
+ */
173
+ export async function runBuild(argv = process.argv, cwd = process.cwd()) {
174
+ const options = resolveBuildOptions(argv, cwd);
175
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
176
+ const artefactRoot =
177
+ options.command === "inline"
178
+ ? path.dirname(options.output)
179
+ : options.outputDir;
180
+ const workDir = path.join(artefactRoot, WORK_DIR_NAME);
181
+
182
+ mkdir(artefactRoot);
183
+ rm(workDir);
184
+ mkdir(workDir);
185
+
186
+ try {
187
+ console.log(
188
+ options.command === "inline"
189
+ ? "\n** BUILDING REPORT (INLINE) **\n"
190
+ : "\n** BUILDING REPORT (STATIC) **\n",
191
+ );
192
+
193
+ console.log("...staging inputs");
194
+ const staged = stageInputs(options, workDir);
195
+
196
+ console.log("...converting opinions csv to json");
197
+ const opinionsRaw = await csv().fromFile(staged.opinionsCsv);
198
+
199
+ console.log("...processing data");
200
+ const { dataStatic, dataInline } = processReportData({
201
+ opinionsRaw,
202
+ summaryPath: staged.summaryPath,
203
+ configPath: staged.configPath,
204
+ predictedPath: staged.predictedPath,
205
+ inputDir: staged.stagedInputDir,
206
+ packageRoot,
207
+ workDir,
208
+ });
209
+
210
+ const template = fs.readFileSync(
211
+ path.join(packageRoot, "src", "index.mustache"),
212
+ "utf-8",
213
+ );
214
+ const templateData =
215
+ options.command === "inline" ? dataInline : dataStatic;
216
+ const rawHtml = mustache.render(template, templateData);
217
+ const rawHtmlPath = path.join(workDir, "raw.html");
218
+ fs.writeFileSync(rawHtmlPath, rawHtml);
219
+
220
+ if (options.command === "inline") {
221
+ console.log("...inlining data and assets");
222
+ copySrcAssets(packageRoot, workDir);
223
+ copyLogos(staged.stagedInputDir, workDir);
224
+
225
+ let inlined = await inlineSource(rawHtmlPath, {
226
+ rootpath: workDir,
227
+ compress: false,
228
+ attribute: "inline",
229
+ });
230
+ inlined = inlineFontDataUris(inlined, path.join(workDir, "fonts"));
231
+ fs.writeFileSync(options.output, inlined);
232
+ console.log("\n** BUILD COMPLETE! **\n");
233
+ return { output: options.output };
234
+ }
235
+
236
+ console.log("...copying static assets");
237
+ copySrcAssets(packageRoot, artefactRoot);
238
+ copyLogos(staged.stagedInputDir, artefactRoot);
239
+ cp(path.join(workDir, "quotes.json"), artefactRoot);
240
+ const reportHtml = path.join(artefactRoot, "report.html");
241
+ fs.writeFileSync(reportHtml, rawHtml);
242
+ const mustacheOut = path.join(artefactRoot, "index.mustache");
243
+ if (fs.existsSync(mustacheOut)) fs.rmSync(mustacheOut);
244
+
245
+ console.log("\n** BUILD COMPLETE! **\n");
246
+ return { outputDir: options.outputDir };
247
+ } finally {
248
+ rm(workDir);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Legacy preview helper: browser-sync on ./output.
254
+ */
255
+ function preview() {
256
+ execSync(
257
+ 'npx -y -q browser-sync start --server ./output --files "./output/**"',
258
+ { stdio: "inherit" },
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Legacy github pages helper (static into docs/).
264
+ */
265
+ async function github() {
266
+ await runBuild(
267
+ ["node", "build.js", "static", "--outputDir", "./output"],
268
+ process.cwd(),
269
+ );
270
+ console.log("...deploying to github docs");
271
+ rm("docs");
272
+ mkdir("docs");
273
+ for (const file of fs.readdirSync("output")) {
274
+ if (file === WORK_DIR_NAME) continue;
275
+ cp(path.join("output", file), "docs");
276
+ }
277
+ fs.closeSync(fs.openSync("docs/.nojekyll", "w"));
278
+ execSync("git add -A", { stdio: "inherit" });
279
+ execSync('git commit -m "update github pages"', { stdio: "inherit" });
280
+ execSync("git push", { stdio: "inherit" });
281
+ }
282
+
283
+ const isMain =
284
+ process.argv[1] &&
285
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
286
+
287
+ if (isMain) {
288
+ const command = process.argv[2];
289
+ if (command === "preview") {
290
+ preview();
291
+ } else if (command === "github") {
292
+ github().catch((error) => {
293
+ console.error(error.message);
294
+ process.exit(1);
295
+ });
296
+ } else {
297
+ runBuild(process.argv, process.cwd())
298
+ .then((result) => {
299
+ console.log(
300
+ `Standalone report generated: ${result.output || result.outputDir}`,
301
+ );
302
+ })
303
+ .catch((error) => {
304
+ console.error(error.message);
305
+ process.exit(1);
306
+ });
307
+ }
308
+ }