#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
usage() {
cat <<USAGE >&2
Usage: $0 --template <name> --data <json_path|-> --out <output_path>
Arguments:
--template <name> テンプレート basename (拡張子 .html.template を除く)
templates/html/<name>.html.template を読みに行く
--data <json_path|-> JSON データファイル (- で stdin から読む)
--out <output_path> 出力 HTML の destination
USAGE
exit 2
}
TEMPLATE_NAME=""
DATA_PATH=""
OUT_PATH=""
WITH_REDACTION="false"
CLIENT_DICT_PATH=""
AUDIT_GROUP=""
AUDIT_MEMBERS=""
AUDIT_QUERY_HASH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--template) TEMPLATE_NAME="${2:-}"; shift 2 ;;
--data) DATA_PATH="${2:-}"; shift 2 ;;
--out) OUT_PATH="${2:-}"; shift 2 ;;
--with-redaction) WITH_REDACTION="true"; shift 1 ;;
--client-dict) CLIENT_DICT_PATH="${2:-}"; shift 2 ;;
--audit-group) AUDIT_GROUP="${2:-}"; shift 2 ;;
--audit-members) AUDIT_MEMBERS="${2:-}"; shift 2 ;;
--audit-query-hash) AUDIT_QUERY_HASH="${2:-}"; shift 2 ;;
-h|--help) usage ;;
*) echo "ERROR: unknown argument: $1" >&2; usage ;;
esac
done
if [[ -z "$TEMPLATE_NAME" || -z "$DATA_PATH" || -z "$OUT_PATH" ]]; then
echo "ERROR: --template / --data / --out のいずれかが未指定です" >&2
usage
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq が見つかりません。jq をインストールしてください。" >&2
exit 5
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEMPLATE_PATH="$PLUGIN_ROOT/templates/html/${TEMPLATE_NAME}.html.template"
if [[ ! -f "$TEMPLATE_PATH" ]]; then
echo "ERROR: template not found: $TEMPLATE_PATH" >&2
exit 3
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/render-html.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
DATA_FILE="$TMP_DIR/data.json"
if [[ "$DATA_PATH" == "-" ]]; then
cat > "$DATA_FILE"
else
if [[ ! -f "$DATA_PATH" ]]; then
echo "ERROR: data file not found: $DATA_PATH" >&2
exit 3
fi
cp "$DATA_PATH" "$DATA_FILE"
fi
if ! jq -e '.' "$DATA_FILE" >/dev/null 2>&1; then
echo "ERROR: invalid JSON in data file (jq failed to parse)" >&2
exit 4
fi
find_first_section() {
local content="$1"
printf '%s' "$content" | awk '
# BSD awk は RS="\0" を空文字 (= paragraph mode) に解釈し leading newline を消すので、
# 入力に絶対現れないセンチネル文字列を RS に置く (実質 EOF まで 1 record)。
BEGIN { RS = "__RENDER_HTML_AWK_RS_SENTINEL_NEVER_OCCURS__"; }
{
if (match($0, /\{\{#[a-zA-Z_][a-zA-Z_0-9]*\}\}/)) {
open_start = RSTART
open_len = RLENGTH
tag = substr($0, RSTART + 3, RLENGTH - 5)
rest = substr($0, RSTART + RLENGTH)
close_marker = "{{/" tag "}}"
cp = index(rest, close_marker)
if (cp > 0) {
printf "%d %d %d %s", open_start - 1, open_len, cp - 1, tag
}
}
}
'
}
find_first_var() {
local content="$1"
printf '%s' "$content" | awk '
# BSD awk は RS="\0" を空文字 (= paragraph mode) に解釈し leading newline を消すので、
# 入力に絶対現れないセンチネル文字列を RS に置く (実質 EOF まで 1 record)。
BEGIN { RS = "__RENDER_HTML_AWK_RS_SENTINEL_NEVER_OCCURS__"; }
{
if (match($0, /\{\{[a-zA-Z_][a-zA-Z_0-9]*\}\}/)) {
printf "%d %d %s", RSTART - 1, RLENGTH, substr($0, RSTART + 2, RLENGTH - 4)
}
}
'
}
lookup_top_var() {
local var="$1"
jq -r --arg k "$var" '.[$k] // "" | tostring' "$DATA_FILE"
}
lookup_item_var() {
local item_json="$1"
local var="$2"
printf '%s' "$item_json" | jq -r --arg k "$var" '.[$k] // "" | tostring'
}
SENTINEL_OPEN_BRACE=$'\x01\x02\x03'
escape_val_for_embed() {
local v="$1"
printf '%s' "${v//\{/$SENTINEL_OPEN_BRACE}"
}
render_block_with_item() {
local block="$1"
local item_json="$2"
local rendered="$block"
while :; do
local info
info="$(find_first_var "$rendered")"
[[ -z "$info" ]] && break
local off len var
off="$(echo "$info" | awk '{print $1}')"
len="$(echo "$info" | awk '{print $2}')"
var="$(echo "$info" | awk '{print $3}')"
local val val_safe
val="$(lookup_item_var "$item_json" "$var")"
val_safe="$(escape_val_for_embed "$val")"
rendered="${rendered:0:off}${val_safe}${rendered:$((off + len))}"
done
printf '%s' "$rendered"
}
TEMPLATE_CONTENT="$(cat "$TEMPLATE_PATH")"
while :; do
info="$(find_first_section "$TEMPLATE_CONTENT")"
[[ -z "$info" ]] && break
open_off="$(echo "$info" | awk '{print $1}')"
open_len="$(echo "$info" | awk '{print $2}')"
block_len="$(echo "$info" | awk '{print $3}')"
tag_name="$(echo "$info" | awk '{print $4}')"
prefix="${TEMPLATE_CONTENT:0:open_off}"
block="${TEMPLATE_CONTENT:$((open_off + open_len)):block_len}"
suffix_off=$((open_off + open_len + block_len + ${#tag_name} + 5))
suffix="${TEMPLATE_CONTENT:$suffix_off}"
items_count="$(jq -r --arg t "$tag_name" '
if (.[$t] | type) == "array" then (.[$t] | length) else 0 end
' "$DATA_FILE")"
rendered_section=""
if [[ "$items_count" -gt 0 ]]; then
for ((i = 0; i < items_count; i++)); do
item_json="$(jq -c --arg t "$tag_name" --argjson i "$i" '.[$t][$i]' "$DATA_FILE")"
rendered_block="$(render_block_with_item "$block" "$item_json")"
rendered_section="${rendered_section}${rendered_block}"
done
fi
TEMPLATE_CONTENT="${prefix}${rendered_section}${suffix}"
done
while :; do
info="$(find_first_var "$TEMPLATE_CONTENT")"
[[ -z "$info" ]] && break
off="$(echo "$info" | awk '{print $1}')"
len="$(echo "$info" | awk '{print $2}')"
var="$(echo "$info" | awk '{print $3}')"
val="$(lookup_top_var "$var")"
val_safe="$(escape_val_for_embed "$val")"
TEMPLATE_CONTENT="${TEMPLATE_CONTENT:0:off}${val_safe}${TEMPLATE_CONTENT:$((off + len))}"
done
LITERAL_OPEN_BRACE="{"
TEMPLATE_CONTENT="${TEMPLATE_CONTENT//$SENTINEL_OPEN_BRACE/$LITERAL_OPEN_BRACE}"
DICT_COUNT=0
NER_COUNT=0
PASSED_FINAL_SCAN="true"
if [[ "$WITH_REDACTION" == "true" ]]; then
REDACTION_LOG="$TMP_DIR/redaction.log"
: > "$REDACTION_LOG"
DICT_LOG="$TMP_DIR/dict.log"
NER_LOG="$TMP_DIR/ner.log"
if [[ -n "$CLIENT_DICT_PATH" ]]; then
TEMPLATE_CONTENT="$(printf '%s' "$TEMPLATE_CONTENT" | bash "$SCRIPT_DIR/redact-by-dictionary.sh" --stdin --dict "$CLIENT_DICT_PATH" 2>"$DICT_LOG" || true)"
else
TEMPLATE_CONTENT="$(printf '%s' "$TEMPLATE_CONTENT" | bash "$SCRIPT_DIR/redact-by-dictionary.sh" --stdin 2>"$DICT_LOG" || true)"
fi
cat "$DICT_LOG" >> "$REDACTION_LOG"
if grep -q "redacted:" "$DICT_LOG" 2>/dev/null; then
DICT_COUNT="$(awk '/redacted:/ {print $2}' "$DICT_LOG" | head -1)"
DICT_COUNT="${DICT_COUNT:-0}"
fi
TEMPLATE_CONTENT="$(printf '%s' "$TEMPLATE_CONTENT" | bash "$SCRIPT_DIR/redact-by-ner.sh" --stdin 2>"$NER_LOG" || true)"
cat "$NER_LOG" >> "$REDACTION_LOG"
if grep -q "redacted:" "$NER_LOG" 2>/dev/null; then
NER_COUNT="$(awk '/redacted:/ {print $2}' "$NER_LOG" | head -1)"
NER_COUNT="${NER_COUNT:-0}"
fi
set +e
printf '%s' "$TEMPLATE_CONTENT" | python3 "$SCRIPT_DIR/final-scan-redaction.py"
FINAL_SCAN_EXIT=$?
set -e
if [[ $FINAL_SCAN_EXIT -ne 0 ]]; then
PASSED_FINAL_SCAN="false"
if [[ -n "$AUDIT_GROUP" && -n "$AUDIT_QUERY_HASH" ]]; then
bash "$SCRIPT_DIR/cross-project-audit-log.sh" \
--group "$AUDIT_GROUP" \
--members "${AUDIT_MEMBERS:-}" \
--query-hash "$AUDIT_QUERY_HASH" \
--dict-count "$DICT_COUNT" \
--ner-count "$NER_COUNT" \
--passed-final-scan "false" 2>>"$REDACTION_LOG" || true
fi
echo "ERROR: Layer 3 final scan detected residue (HTML generation aborted)" >&2
exit 1
fi
AUDIT_FOOTER="<div class=\"audit-summary\" style=\"margin-top:2em;padding:0.6em 0.8em;border-top:1px solid #ccc;font-size:0.85em;color:#666;\">redacted: dict ${DICT_COUNT} 件 + NER ${NER_COUNT} 件</div>"
if grep -q "</body>" <<<"$TEMPLATE_CONTENT"; then
BODY_CLOSE_TAG="</body>"
TEMPLATE_CONTENT="${TEMPLATE_CONTENT/${BODY_CLOSE_TAG}/${AUDIT_FOOTER}${BODY_CLOSE_TAG}}"
else
TEMPLATE_CONTENT="${TEMPLATE_CONTENT}${AUDIT_FOOTER}"
fi
if [[ -n "$AUDIT_GROUP" && -n "$AUDIT_QUERY_HASH" ]]; then
bash "$SCRIPT_DIR/cross-project-audit-log.sh" \
--group "$AUDIT_GROUP" \
--members "${AUDIT_MEMBERS:-}" \
--query-hash "$AUDIT_QUERY_HASH" \
--dict-count "$DICT_COUNT" \
--ner-count "$NER_COUNT" \
--passed-final-scan "$PASSED_FINAL_SCAN" 2>>"$REDACTION_LOG" || true
fi
fi
OUT_DIR="$(dirname "$OUT_PATH")"
mkdir -p "$OUT_DIR"
printf '%s' "$TEMPLATE_CONTENT" > "$OUT_PATH"
if [[ "${TEMPLATE_CONTENT: -1}" != $'\n' ]]; then
printf '\n' >> "$OUT_PATH"
fi
exit 0