KKTerm BentoPDF API v2 adapter source
======================================

import i18next, {
  applyTranslations,
  supportedLanguages,
  type SupportedLanguage,
} from './i18n/i18n.js';
import { resolveBentoLocale } from './kkterm-locale.js';

interface KKTermContext {
  apiVersion: 2;
  theme: string;
  locale: string;
}

interface KKTermNetworkResponse {
  status: number;
  url: string;
  headers: Record<string, string>;
  bodyBase64: string;
}

interface KKTermHost {
  readonly apiVersion: 2;
  readonly context: KKTermContext;
  ready(): Promise<boolean>;
  getContext(): Promise<KKTermContext>;
  getCapabilities(): Promise<Record<string, unknown>>;
  readonly network: {
    fetch(request: {
      url: string;
      method: string;
      headers: Record<string, string>;
      bodyBase64: string;
    }): Promise<KKTermNetworkResponse>;
  };
  on(event: 'contextChanged', listener: (detail: KKTermContext) => void): () => boolean;
  on(
    event: 'visibilityChanged' | 'focusChanged' | 'suspending' | 'closing',
    listener: (detail: unknown) => void
  ): () => boolean;
}

const FREE_TSA_URL = 'https://freetsa.org/tsr';

function bytesToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let offset = 0; offset < bytes.length; offset += 32 * 1024) {
    binary += String.fromCharCode(
      ...bytes.subarray(offset, offset + 32 * 1024)
    );
  }
  return btoa(binary);
}

function base64ToArrayBuffer(encoded: string): ArrayBuffer {
  const binary = atob(encoded);
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index++) {
    bytes[index] = binary.charCodeAt(index);
  }
  return bytes.buffer;
}

function installFreeTsaFetchBridge(host: KKTermHost): void {
  const nativeFetch = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const request = new Request(input, init);
    if (request.url !== FREE_TSA_URL || request.method !== 'POST') {
      return nativeFetch(input, init);
    }

    const headers: Record<string, string> = {};
    for (const name of ['accept', 'content-type']) {
      const value = request.headers.get(name);
      if (value) headers[name] = value;
    }
    const bodyBase64 = bytesToBase64(
      new Uint8Array(await request.arrayBuffer())
    );
    const response = await host.network.fetch({
      url: FREE_TSA_URL,
      method: 'POST',
      headers,
      bodyBase64,
    });
    return new Response(base64ToArrayBuffer(response.bodyBase64), {
      status: response.status,
      headers: response.headers,
    });
  };
}

declare global {
  interface Window {
    KKTerm?: KKTermHost;
    KKTermBentoContextReady?: Promise<void>;
  }
}

function persistHostLocale(locale: string): SupportedLanguage {
  const bentoLocale = resolveBentoLocale(
    locale,
    supportedLanguages
  ) as SupportedLanguage;
  localStorage.setItem('i18nextLng', bentoLocale);
  document.documentElement.lang = bentoLocale;
  document.documentElement.dir = bentoLocale === 'ar' ? 'rtl' : 'ltr';
  return bentoLocale;
}

function applyTheme(context: KKTermContext): void {
  document.documentElement.dataset.theme = context.theme;
  document.documentElement.style.colorScheme = context.theme
    .toLowerCase()
    .includes('dark')
    ? 'dark'
    : 'light';
}

async function applyContext(context: KKTermContext): Promise<void> {
  const bentoLocale = persistHostLocale(context.locale);
  applyTheme(context);

  if (!i18next.isInitialized) return;
  if (i18next.resolvedLanguage !== bentoLocale) {
    await i18next.changeLanguage(bentoLocale);
  }
  applyTranslations();
}

const initialContext = window.KKTerm?.context;
if (initialContext) {
  persistHostLocale(initialContext.locale);
  applyTheme(initialContext);
}

async function initializeKKTermV2(): Promise<void> {
  const host = window.KKTerm;
  if (!host || host.apiVersion !== 2) {
    throw new Error('KKTerm host API v2 is unavailable.');
  }

  installFreeTsaFetchBridge(host);

  const [context] = await Promise.all([
    host.getContext(),
    host.getCapabilities(),
  ]);
  await applyContext(context);

  host.on('contextChanged', (nextContext) => {
    void applyContext(nextContext).catch((error) => {
      console.error('Failed to apply the updated KKTerm context', error);
    });
  });
  host.on('visibilityChanged', () => undefined);
  host.on('focusChanged', () => undefined);
  host.on('suspending', () => undefined);
  host.on('closing', () => undefined);

  if (document.readyState === 'loading') {
    await new Promise<void>((resolve) => {
      document.addEventListener('DOMContentLoaded', () => resolve(), {
        once: true,
      });
    });
  }
  await host.ready();
}

const contextReady = initializeKKTermV2();
window.KKTermBentoContextReady = contextReady;
void contextReady.catch((error) => {
  console.error('Failed to initialize the BentoPDF KKTerm v2 adapter', error);
  void window.KKTerm?.ready().catch((readyError) => {
    console.error('Failed to reveal the BentoPDF startup error', readyError);
  });
});

export {};


const localeAliases: Readonly<Record<string, string>> = {
  'zh-cn': 'zh',
  'zh-sg': 'zh',
  'zh-hans': 'zh',
  'zh-tw': 'zh-TW',
  'zh-hk': 'zh-TW',
  'zh-mo': 'zh-TW',
  'zh-hant': 'zh-TW',
  'pt-br': 'pt',
};

export function resolveBentoLocale(
  hostLocale: string | undefined,
  supportedLocales: readonly string[]
): string {
  const supportedByLowercase = new Map(
    supportedLocales.map((locale) => [locale.toLowerCase(), locale])
  );
  const normalized = hostLocale?.trim().replaceAll('_', '-') ?? '';
  const lowercase = normalized.toLowerCase();

  const alias = localeAliases[lowercase];
  if (alias) {
    const supportedAlias = supportedByLowercase.get(alias.toLowerCase());
    if (supportedAlias) return supportedAlias;
  }

  const exact = supportedByLowercase.get(lowercase);
  if (exact) return exact;

  const base = supportedByLowercase.get(lowercase.split('-')[0]);
  return base ?? supportedByLowercase.get('en') ?? 'en';
}
