view mrjunejune/template_renderer.c @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 04fee26ecce0
children
line wrap: on
line source

/*
 * template_renderer.c — site-owned HTML template renderer for mrjunejune.
 *
 * Expands one level of {{/path}} include tokens found in HTML templates.
 * File I/O is done with standard C fopen/fread so that tests can point
 * the renderer at any root without depending on Seobeo server state.
 */

#include "mrjunejune/template_renderer.h"

#include "dowa/dowa.h"
#include "seobeo/seobeo.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Default matches the path Seobeo_Web_Server_Start_On receives in main.c. */
static char g_src_root[512] = "mrjunejune/src";

void Mjj_Template_Renderer_Init(const char *src_root)
{
  if (!src_root || src_root[0] == '\0')
    return;
  strncpy(g_src_root, src_root, sizeof(g_src_root) - 1);
  g_src_root[sizeof(g_src_root) - 1] = '\0';
}

/* Load a file using the renderer's own root; caller must free() result. */
static char *renderer_load_file(const char *rel_path, size_t *p_size)
{
  if (!rel_path)
    return NULL;

  char full_path[1024];
  /* rel_path already starts with '/' when produced by canonical markers
     (e.g. "/parts/base_head.html"), so concatenate directly. */
  if (rel_path[0] == '/')
    snprintf(full_path, sizeof(full_path), "%s%s", g_src_root, rel_path);
  else
    snprintf(full_path, sizeof(full_path), "%s/%s", g_src_root, rel_path);

  FILE *f = fopen(full_path, "rb");
  if (!f)
    return NULL;

  fseek(f, 0, SEEK_END);
  long sz = ftell(f);
  fseek(f, 0, SEEK_SET);
  if (sz < 0)
  {
    fclose(f);
    return NULL;
  }

  char *buf = (char *)malloc((size_t)sz + 1);
  if (!buf)
  {
    fclose(f);
    return NULL;
  }

  size_t n = fread(buf, 1, (size_t)sz, f);
  fclose(f);
  buf[n] = '\0';

  if (p_size)
    *p_size = n;
  return buf;
}

/* Write `len` bytes of `src` to out starting at *p_offset, respecting cap.
   Returns FALSE and does a best-effort NUL-terminate on overflow. */
static boolean safe_append(
    char *out, size_t cap, size_t *p_offset,
    const char *src, size_t len)
{
  if (len == 0)
    return TRUE;
  if (*p_offset + len >= cap)
  {
    /* Partial write then NUL-terminate. */
    size_t avail = (cap > *p_offset + 1) ? (cap - *p_offset - 1) : 0;
    if (avail > 0)
      memcpy(out + *p_offset, src, avail);
    out[cap - 1] = '\0';
    return FALSE;
  }
  memcpy(out + *p_offset, src, len);
  *p_offset += len;
  return TRUE;
}

boolean Mjj_Template_Render(
    char *out, size_t cap, const char *tmpl, Dowa_Arena *arena)
{
  if (!out || cap == 0)
    return FALSE;
  out[0] = '\0';
  if (!tmpl)
    return FALSE;

  size_t offset = 0;
  const char *cursor = tmpl;

  while (1)
  {
    const char *open = strstr(cursor, "{{");
    if (!open)
      break;

    const char *close = strstr(open + 2, "}}");
    if (!close)
      break;

    /* Copy text before the token. */
    size_t lead = (size_t)(open - cursor);
    if (!safe_append(out, cap, &offset, cursor, lead))
      return FALSE;

    /* Extract include path between {{ and }}. */
    size_t name_len = (size_t)(close - (open + 2));
    char *include_name = (char *)Dowa_Arena_Allocate(arena, name_len + 1);
    if (!include_name)
      return FALSE;
    memcpy(include_name, open + 2, name_len);
    include_name[name_len] = '\0';

    /* Load and inline the included file; skip silently if missing. */
    size_t inc_size = 0;
    char *inc = renderer_load_file(include_name, &inc_size);
    Seobeo_Log(SEOBEO_DEBUG,
        "[TEMPLATE] include '%s' -> %s (size=%zu)\n",
        include_name, inc ? "OK" : "MISSING", inc_size);
    if (inc)
    {
      boolean ok = safe_append(out, cap, &offset, inc, inc_size);
      free(inc);
      if (!ok)
        return FALSE;
    }

    cursor = close + 2;
  }

  /* Copy the tail (after the last token, or the whole string if no tokens). */
  size_t tail = strlen(cursor);
  if (!safe_append(out, cap, &offset, cursor, tail))
    return FALSE;

  out[offset] = '\0';
  return TRUE;
}

boolean Mjj_Template_Render_File(
    char *out, size_t cap, const char *path, Dowa_Arena *arena)
{
  if (!out || cap == 0)
    return FALSE;
  out[0] = '\0';

  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] loading file '%s'\n", path);

  size_t file_size = 0;
  char *tmpl = renderer_load_file(path, &file_size);
  if (!tmpl)
  {
    Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] file '%s' not found\n", path);
    return FALSE;
  }

  boolean ok = Mjj_Template_Render(out, cap, tmpl, arena);
  free(tmpl);
  return ok;
}