Skip to content

Plugin

A Kiku plugin is a JavaScript module named _kiku_plugin.js. This module must export a named variable called plugin. The type definitions for this module are available here.

ts
import type {
  createComputed,
  createContext,
  createEffect,
  createMemo,
  createResource,
  createSignal,
  ErrorBoundary,
  For,
  getOwner,
  lazy,
  Match,
  onCleanup,
  onMount,
  runWithOwner,
  Show,
  Suspense,
  Switch,
  untrack,
  useContext,
} from "solid-js";
import type h from "solid-js/h";
import type { JSX } from "solid-js/jsx-runtime";
import type { createStore } from "solid-js/store";
import type { Portal } from "solid-js/web";
import type { PitchInfo } from "#/components/_kiku_lazy/util/hatsuon";
import type { UseAnkiFieldContext } from "#/components/shared/AnkiFieldsContext";
import type { UseBreakpointContext } from "#/components/shared/BreakpointContext";
import type { UseCardContext } from "#/components/shared/CardContext";
import type { UseConfigContext } from "#/components/shared/ConfigContext";
import type { AnkiBackFields, AnkiDroidAPI, AnkiFrontFields } from "#/types";

export type Ctx = {
  h: typeof h;
  createSignal: typeof createSignal;
  createEffect: typeof createEffect;
  createMemo: typeof createMemo;
  createResource: typeof createResource;
  createComputed: typeof createComputed;
  onMount: typeof onMount;
  onCleanup: typeof onCleanup;
  createContext: typeof createContext;
  useContext: typeof useContext;
  lazy: typeof lazy;
  ErrorBoundary: typeof ErrorBoundary;
  For: typeof For;
  Portal: typeof Portal;
  Show: typeof Show;
  Suspense: typeof Suspense;
  Switch: typeof Switch;
  Match: typeof Match;
  untrack: typeof untrack;
  runWithOwner: typeof runWithOwner;
  getOwner: typeof getOwner;
  createStore: typeof createStore;
  //
  ankiFields: AnkiFrontFields | AnkiBackFields;
  ankiDroidAPI: () => AnkiDroidAPI | undefined;
  useAnkiFieldContext: UseAnkiFieldContext;
  useBreakpointContext: UseBreakpointContext;
  useCardContext: UseCardContext;
  useConfigContext: UseConfigContext;
};

export type KikuPlugin = {
  ExternalLinks?: (props: {
    DefaultExternalLinks: () => JSX.Element;
    ctx: Ctx;
  }) => JSX.Element | JSX.Element[];
  Sentence?: (props: {
    DefaultSentence: () => JSX.Element;
    ctx: Ctx;
  }) => JSX.Element | JSX.Element[];
  Pitch?: (props: {
    pitchInfo: PitchInfo;
    index: number;
    DefaultPitch: (props: {
      pitchInfo: PitchInfo;
      index: number;
      ref?: (ref: HTMLDivElement) => void;
    }) => JSX.Element;
    ctx: Ctx;
  }) => JSX.Element | JSX.Element[];
  onPluginLoad?: (props: { ctx: Ctx }) => void;
  onSettingsMount?: (props: { ctx: Ctx }) => void;
};

The plugin system is currently very basic, but more APIs will be added in the future.

If defined, this component will be mounted under the Definition section on the back side of the note, replacing the default ExternalLinks.

Example

js
/**
 * @import { KikuPlugin } from "#/plugins/pluginTypes";
 */

/**
 * @type { KikuPlugin }
 */
export const plugin = {
  ExternalLinks: (props) => {
    const h = props.ctx.h;

    // Create an arbitrary link
    const NadeshikoLink = h(
      "a",
      {
        href: (() => {
          const url = new URL("https://nadeshiko.co/search/sentence");
          url.searchParams.set("query", props.ctx.ankiFields.Expression);
          return url.toString();
        })(),
        target: "_blank",
      },
      h("img", {
        class: "size-5 object-contain rounded-xs",
        src: "https://nadeshiko.co/favicon.ico",
      }),
    );

    // Create an arbitrary button with a custom on:click handler
    const AnkiDroidBrowseButton = h(
      "button",
      {
        class: "text-sm btn btn-sm",
        "on:click": () => {
          props.ctx
            .ankiDroidAPI()
            ?.ankiSearchCard(
              `("note:Kiku" OR "note:Lapis") AND "Expression:*${props.ctx.ankiFields.Expression}*"`,
            );
        },
      },
      "Browse",
    );

    return [
      // includes the default ExternalLinks
      props.DefaultExternalLinks(),
      NadeshikoLink(),
      AnkiDroidBrowseButton(),
      // you can create as many links as you want
    ];
  },
};

Sentence

If defined, this component will replace the default Sentence component.

Example

js
/**
 * @import { KikuPlugin } from "#/plugins/pluginTypes";
 */

/**
 * @type { KikuPlugin }
 */
export const plugin = {
  Sentence: (props) => {
    const h = props.ctx.h;

    function SentenceTranslation() {
      const translation =
        "SentenceTranslation" in props.ctx.ankiFields
          ? props.ctx.ankiFields?.SentenceTranslation
          : document.getElementById("SentenceTranslation")?.innerHTML;

      if (!translation) return null;
      return h("div", {
        class: "text-lg text-base-content-calm sentence-translation",
        innerHTML: translation,
      })();
    }

    // You can inline the CSS here
    const style = h(
      "style",
      `
      .sentence-translation { filter: blur(4px); } 
      .sentence-translation:hover { filter: none; }
    `,
    );

    return [props.DefaultSentence(), SentenceTranslation(), style()];
  },
};

Pitch

If defined, this component will replace the default Pitch component.

Example

js
/**
 * @import { KikuPlugin } from "#/plugins/pluginTypes";
 */

/**
 * @type { KikuPlugin }
 */
export const plugin = {
  Pitch: (props) => {
    const h = props.ctx.h;
    const onMount = props.ctx.onMount;
    const createSignal = props.ctx.createSignal;
    const DefaultPitch = props.DefaultPitch;
    const pitchInfo = props.pitchInfo;

    const color = () => {
      // you can customize the color here
      switch (pitchInfo.pitchNum) {
        case 0:
          return { color: "#d46a6a", colorContent: "#8b3f3f" };
        case 1:
          return { color: "#6ad46a", colorContent: "#3f8b3f" };
        case 2:
          return { color: "#6a6ad4", colorContent: "#3f3f8b" };
        case 3:
          return { color: "#d4d46a", colorContent: "#8b8b3f" };
        default:
          return { color: "#8b8b8b", colorContent: "#3f3f3f" };
      }
    };

    const css = `
        .custom-pitch {
          color: ${color().color};
          border-color: ${color().color}
        }
        .custom-pitch::after {
          background-color: ${color().color};
        }
      `;
    const style = h("style", css);

    const [ref, setRef] = createSignal();
    onMount(() => {
      const el = ref();
      if (!el) return;

      const spans = Array.from(el.querySelectorAll("[data-is-even] span"));
      spans.forEach((span) => {
        span.classList.add("custom-pitch");
      });

      const indicator = el.querySelector("[data-is-even]")?.nextElementSibling;
      indicator.style.backgroundColor = color()?.color;
      indicator.style.color = color()?.colorContent;
    });

    return [
      DefaultPitch({
        index: props.index,
        pitchInfo: props.pitchInfo,
        ref: setRef,
      }),
      style(),
    ];
  },
};

onPluginLoad

This function is called when the plugin is loaded.

Example

js
/**
 * @import { KikuPlugin } from "#/plugins/pluginTypes";
 */

/**
 * @type { KikuPlugin }
 */
export const plugin = {
  onPluginLoad: () => {
    const root = KIKU_STATE.root;
    const settingsMounted = sessionStorage.getItem("settings-mounted");
    // stop if settings has ever been mounted
    if (settingsMounted) return;

    // unblur NSFW automatically if it's not work time
    if (!isWorkTime() && root) {
      root.dataset.blurNsfw = "false";
    }
  },
};

function isWorkTime(workdays = [1, 2, 3, 4, 5], startHour = 9, endHour = 17) {
  const now = new Date();
  const day = now.getDay(); // 0 = Sun, 6 = Sat
  const hour = now.getHours();
  const mins = now.getMinutes();

  const isWeekday = workdays.includes(day);

  // Compare hour + minutes together
  const nowMinutes = hour * 60 + mins;
  const startMinutes = startHour * 60;
  const endMinutes = endHour * 60;

  const isWorkHours = nowMinutes >= startMinutes && nowMinutes < endMinutes;

  return isWeekday && isWorkHours;
}

onSettingsMount

This function is called when the settings page is mounted.

Example

js
/**
 * @import { KikuPlugin } from "#/plugins/pluginTypes";
 */

/**
 * @type { KikuPlugin }
 */
export const plugin = {
  onSettingsMount: () => {
    sessionStorage.setItem("settings-mounted", "true");
  },
};