Skip to content
Browse the docs

Developer Guide

Quick start recipes

Choose the recipe that matches your website. Before running it, replace every visible {{...}} value and the two example coordinates. All three recipes call Lernmark from the server, stop after five seconds, cache successful results for ten minutes, escape public text before rendering, and fail closed.

Replace the publication placeholders

Set {{PRODUCTION_API_BASE_URL}} to the confirmed public API origin and {{APPLY_URL_PATTERN}} to the confirmed hosted Apply URL pattern. Use your-school and YOUR_INSTITUTION_ID only until you have your own coordinates.

Node.js: no dependencies

This example works in a server-side Node.js route or rendering function. The in-memory cache is intentionally small; on a multi-instance host, use its shared server cache with the same ten-minute TTL.

const BASE_URL = "{{PRODUCTION_API_BASE_URL}}".replace(/\/$/, "");
const TENANT_SLUG = "your-school";
const INSTITUTION_ID = "YOUR_INSTITUTION_ID";
const APPLY_URL = "{{APPLY_URL_PATTERN}}";
const CACHE_TTL_MS = 10 * 60 * 1000;

type Programme = {
  id: string;
  title: string;
  award: string | null;
  academic_unit: string | null;
  duration: string | null;
  summary: string | null;
  application_available: boolean;
  slug: string;
  last_updated: string;
};

type ProgrammeEnvelope = {
  data: { programmes: Programme[] };
};

const cache = new Map<
  string,
  { expiresAt: number; programmes: Programme[] }
>();

async function getProgrammes({
  page = 1,
  perPage = 9,
  search = "",
}: {
  page?: number;
  perPage?: number;
  search?: string;
} = {}): Promise<Programme[]> {
  if (search.length > 100) {
    throw new RangeError("Search must be 100 characters or fewer.");
  }

  const url = new URL(
    `${BASE_URL}/api/v1/public/site/${encodeURIComponent(TENANT_SLUG)}` +
      `/institutions/${encodeURIComponent(INSTITUTION_ID)}/programmes`,
  );
  url.searchParams.set("page", String(Math.max(1, page)));
  url.searchParams.set("per_page", String(Math.min(100, Math.max(1, perPage))));
  if (search.trim()) url.searchParams.set("search", search.trim());

  const key = url.toString();
  const cached = cache.get(key);
  if (cached && cached.expiresAt > Date.now()) return cached.programmes;
  cache.delete(key); // Never serve an expired response.

  const response = await fetch(url, {
    headers: { Accept: "application/json" },
    signal: AbortSignal.timeout(5000),
  });

  if (response.status !== 200) {
    throw new Error(`Lernmark returned HTTP ${response.status}`);
  }

  const body = (await response.json()) as ProgrammeEnvelope;
  if (!Array.isArray(body?.data?.programmes)) {
    throw new Error("Lernmark returned an unexpected response.");
  }

  cache.set(key, {
    expiresAt: Date.now() + CACHE_TTL_MS,
    programmes: body.data.programmes,
  });

  return body.data.programmes;
}

function escapeHtml(value: string): string {
  const entities: Record<string, string> = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#039;",
  };

  return value.replace(
    /[&<>"']/g,
    (character) => entities[character] ?? character,
  );
}

function renderProgrammes(programmes: Programme[]): string {
  if (programmes.length === 0) {
    return '<p role="status">No programmes are currently listed.</p>';
  }

  const items = programmes.map((programme) => {
    const details = [programme.award, programme.duration]
      .filter((value): value is string => Boolean(value))
      .map(escapeHtml)
      .join(" · ");
    const summary = programme.summary
      ? `<p>${escapeHtml(programme.summary)}</p>`
      : "";
    const application = programme.application_available
      ? `<p><a href="${escapeHtml(APPLY_URL)}">Apply</a></p>`
      : "<p>Applications are not currently open.</p>";

    return `<li>
  <article>
    <h3>${escapeHtml(programme.title)}</h3>
    ${details ? `<p>${details}</p>` : ""}
    ${summary}
    ${application}
  </article>
</li>`;
  });

  return `<ul aria-label="Available programmes">\n${items.join("\n")}\n</ul>`;
}

async function renderProgrammePage(): Promise<string> {
  try {
    const programmes = await getProgrammes({ page: 1, perPage: 9 });
    return renderProgrammes(programmes);
  } catch {
    return '<p role="status">Programme information is temporarily unavailable.</p>';
  }
}

renderProgrammePage().then(console.log);

Accessible HTML produced:

<ul aria-label="Available programmes">
  <li>
    <article>
      <h3>BSc Computing</h3>
      <p>BSc (Hons) · 3 years full-time</p>
      <p>A short public summary of the programme.</p>
      <p><a href="{{APPLY_URL_PATTERN}}">Apply</a></p>
    </article>
  </li>
</ul>

PHP: cURL and a file cache

Place this in server-side application code. Make sure the PHP process can write to its system temporary directory. The file cache stores only successful public responses and is ignored as soon as its ten-minute TTL expires.

<?php

const LERNMARK_API_BASE_URL = '{{PRODUCTION_API_BASE_URL}}';
const LERNMARK_TENANT_SLUG = 'your-school';
const LERNMARK_INSTITUTION_ID = 'YOUR_INSTITUTION_ID';
const LERNMARK_APPLY_URL = '{{APPLY_URL_PATTERN}}';
const LERNMARK_CACHE_SECONDS = 600;

function get_lernmark_programmes(
    int $page = 1,
    int $perPage = 9,
    string $search = ''
): array {
    if (mb_strlen($search) > 100) {
        throw new InvalidArgumentException('Search must be 100 characters or fewer.');
    }

    $path = sprintf(
        '/api/v1/public/site/%s/institutions/%s/programmes',
        rawurlencode(LERNMARK_TENANT_SLUG),
        rawurlencode(LERNMARK_INSTITUTION_ID)
    );
    $query = http_build_query([
        'page' => max(1, $page),
        'per_page' => min(100, max(1, $perPage)),
        'search' => trim($search),
    ]);
    $url = rtrim(LERNMARK_API_BASE_URL, '/') . $path . '?' . $query;
    $cacheFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR
        . 'lernmark-programmes-' . hash('sha256', $url) . '.json';

    if (is_file($cacheFile) && filemtime($cacheFile) > time() - LERNMARK_CACHE_SECONDS) {
        $cached = json_decode((string) file_get_contents($cacheFile), true);
        if (is_array($cached)) {
            return $cached;
        }
    }

    $curl = curl_init($url);
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 5,
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
    ]);
    $raw = curl_exec($curl);
    $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $curlError = curl_error($curl);
    curl_close($curl);

    if ($raw === false || $status !== 200) {
        throw new RuntimeException(
            $curlError !== '' ? $curlError : "Lernmark returned HTTP {$status}"
        );
    }

    $body = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    $programmes = $body['data']['programmes'] ?? null;
    if (!is_array($programmes)) {
        throw new RuntimeException('Lernmark returned an unexpected response.');
    }

    file_put_contents(
        $cacheFile,
        json_encode($programmes, JSON_THROW_ON_ERROR),
        LOCK_EX
    );
    return $programmes;
}

function h(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function render_lernmark_programmes(array $programmes): string
{
    if ($programmes === []) {
        return '<p role="status">No programmes are currently listed.</p>';
    }

    $html = '<ul aria-label="Available programmes">';
    foreach ($programmes as $programme) {
        $details = array_filter([
            $programme['award'] ?? null,
            $programme['duration'] ?? null,
        ]);

        $html .= '<li><article>';
        $html .= '<h3>' . h($programme['title'] ?? '') . '</h3>';
        if ($details !== []) {
            $html .= '<p>' . implode(' · ', array_map('h', $details)) . '</p>';
        }
        if (!empty($programme['summary'])) {
            $html .= '<p>' . h($programme['summary']) . '</p>';
        }
        $html .= !empty($programme['application_available'])
            ? '<p><a href="' . h(LERNMARK_APPLY_URL) . '">Apply</a></p>'
            : '<p>Applications are not currently open.</p>';
        $html .= '</article></li>';
    }

    return $html . '</ul>';
}

try {
    echo render_lernmark_programmes(get_lernmark_programmes());
} catch (Throwable) {
    echo '<p role="status">Programme information is temporarily unavailable.</p>';
}

Accessible HTML produced:

<ul aria-label="Available programmes">
  <li>
    <article>
      <h3>BSc Computing</h3>
      <p>BSc (Hons) · 3 years full-time</p>
      <p>A short public summary of the programme.</p>
      <p><a href="{{APPLY_URL_PATTERN}}">Apply</a></p>
    </article>
  </li>
</ul>

WordPress: shortcode and Transients API

Put the coordinates in wp-config.php, above the line that says WordPress editing should stop. Do not hardcode them in a theme:

define('LERNMARK_API_BASE_URL', '{{PRODUCTION_API_BASE_URL}}');
define('LERNMARK_TENANT_SLUG', 'your-school');
define('LERNMARK_INSTITUTION_ID', 'YOUR_INSTITUTION_ID');
define('LERNMARK_APPLY_URL', '{{APPLY_URL_PATTERN}}');

Save the following as a small site plugin, activate it, then place [lernmark_programmes] in a page or post.

<?php
/**
 * Plugin Name: Lernmark Programmes
 * Description: Adds the [lernmark_programmes] public programme-list shortcode.
 */

function lernmark_fetch_programmes(int $page = 1, int $perPage = 9, string $search = '')
{
    if (mb_strlen($search) > 100) {
        return new WP_Error('lernmark_search', 'Search must be 100 characters or fewer.');
    }

    $path = sprintf(
        '/api/v1/public/site/%s/institutions/%s/programmes',
        rawurlencode(LERNMARK_TENANT_SLUG),
        rawurlencode(LERNMARK_INSTITUTION_ID)
    );
    $url = add_query_arg([
        'page' => max(1, $page),
        'per_page' => min(100, max(1, $perPage)),
        'search' => trim($search),
    ], rtrim(LERNMARK_API_BASE_URL, '/') . $path);
    $cacheKey = 'lernmark_programmes_' . hash('sha256', $url);
    $cached = get_transient($cacheKey);

    if (is_array($cached)) {
        return $cached;
    }

    $response = wp_remote_get($url, [
        'timeout' => 5,
        'headers' => ['Accept' => 'application/json'],
    ]);

    if (is_wp_error($response)) {
        return $response;
    }

    $status = wp_remote_retrieve_response_code($response);
    if ($status !== 200) {
        return new WP_Error('lernmark_http', "Lernmark returned HTTP {$status}");
    }

    $body = json_decode(wp_remote_retrieve_body($response), true);
    $programmes = $body['data']['programmes'] ?? null;
    if (!is_array($programmes)) {
        return new WP_Error('lernmark_json', 'Lernmark returned an unexpected response.');
    }

    set_transient($cacheKey, $programmes, 10 * MINUTE_IN_SECONDS);
    return $programmes;
}

function lernmark_programmes_shortcode(): string
{
    $programmes = lernmark_fetch_programmes();
    if (is_wp_error($programmes)) {
        return '<p role="status">Programme information is temporarily unavailable.</p>';
    }
    if ($programmes === []) {
        return '<p role="status">No programmes are currently listed.</p>';
    }

    $html = '<ul class="lernmark-programmes" aria-label="Available programmes">';
    foreach ($programmes as $programme) {
        $details = array_filter([
            $programme['award'] ?? null,
            $programme['duration'] ?? null,
        ]);

        $html .= '<li><article>';
        $html .= '<h3>' . esc_html($programme['title'] ?? '') . '</h3>';
        if ($details !== []) {
            $html .= '<p>' . implode(' · ', array_map('esc_html', $details)) . '</p>';
        }
        if (!empty($programme['summary'])) {
            $html .= '<p>' . esc_html($programme['summary']) . '</p>';
        }
        $html .= !empty($programme['application_available'])
            ? '<p><a href="' . esc_url(LERNMARK_APPLY_URL) . '">Apply</a></p>'
            : '<p>Applications are not currently open.</p>';
        $html .= '</article></li>';
    }

    return $html . '</ul>';
}

add_shortcode('lernmark_programmes', 'lernmark_programmes_shortcode');

Accessible HTML produced:

<ul class="lernmark-programmes" aria-label="Available programmes">
  <li>
    <article>
      <h3>BSc Computing</h3>
      <p>BSc (Hons) · 3 years full-time</p>
      <p>A short public summary of the programme.</p>
      <p><a href="{{APPLY_URL_PATTERN}}">Apply</a></p>
    </article>
  </li>
</ul>

Next, read Application status before enabling the Apply link in production.