From 07be506d452c3eb3f174f77ab4c00ad7f168bc36 Mon Sep 17 00:00:00 2001 From: KrimsN Date: Sun, 4 Oct 2026 01:15:00 +0700 Subject: [PATCH 1/3] feat(config): add a shared form for asking for a missing plugin through a prefilled GitHub issue --- src/core/issue.test.ts | 58 ++++++++++ src/core/issue.ts | 53 ++++++++++ src/i18n.ts | 53 ++++++++++ src/style.css | 23 ++++ src/tools/config/MissingPlugin.tsx | 164 +++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+) create mode 100644 src/core/issue.test.ts create mode 100644 src/core/issue.ts create mode 100644 src/tools/config/MissingPlugin.tsx diff --git a/src/core/issue.test.ts b/src/core/issue.test.ts new file mode 100644 index 0000000..bff238f --- /dev/null +++ b/src/core/issue.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { pluginRequestUrl } from "./issue"; + +const base = { + service: " Gandi ", + apiDocs: "https://api.gandi.net/docs/", + why: "My domain lives there.", + extra: "", + willing: false, +}; + +describe("pluginRequestUrl", () => { + test("fills the fields of the provider request form", () => { + const url = new URL(pluginRequestUrl("provider", base)); + expect(url.origin + url.pathname).toBe( + "https://github.com/dnspatch/dnspatch/issues/new", + ); + expect(url.searchParams.get("template")).toBe("provider_request.yml"); + expect(url.searchParams.get("title")).toBe("Provider request: Gandi"); + expect(url.searchParams.get("service")).toBe("Gandi"); + expect(url.searchParams.get("api-docs")).toBe(base.apiDocs); + expect(url.searchParams.get("why")).toBe(base.why); + }); + + test("opens the retriever form for a retriever", () => { + const url = new URL(pluginRequestUrl("retriever", base)); + expect(url.searchParams.get("template")).toBe("retriever_request.yml"); + expect(url.searchParams.get("title")).toBe("Retriever request: Gandi"); + }); + + test("opens the notifier form for a notifier", () => { + const url = new URL(pluginRequestUrl("notifier", base)); + expect(url.searchParams.get("template")).toBe("notifier_request.yml"); + expect(url.searchParams.get("title")).toBe("Notifier request: Gandi"); + }); + + test("leaves the optional field out when it is empty", () => { + const url = new URL(pluginRequestUrl("provider", base)); + expect(url.searchParams.has("auth")).toBe(false); + }); + + test("puts the last field under the id its form uses", () => { + const extra = { ...base, extra: "plain text" }; + const provider = new URL(pluginRequestUrl("provider", extra)); + const retriever = new URL(pluginRequestUrl("retriever", extra)); + expect(provider.searchParams.get("auth")).toBe("plain text"); + expect(retriever.searchParams.get("details")).toBe("plain text"); + }); + + test("adds the willingness to the reason", () => { + const url = new URL( + pluginRequestUrl("provider", { ...base, willing: true }), + ); + expect(url.searchParams.get("why")).toBe( + "My domain lives there.\n\nI'd be willing to write and test this plugin myself.", + ); + }); +}); diff --git a/src/core/issue.ts b/src/core/issue.ts new file mode 100644 index 0000000..6708283 --- /dev/null +++ b/src/core/issue.ts @@ -0,0 +1,53 @@ +export type RequestKind = "provider" | "retriever" | "notifier"; + +/** What the "my service is missing" form collects. */ +export interface PluginRequest { + service: string; + apiDocs: string; + why: string; + /** The last field of the form: how the API authenticates, or what a retriever answers with. */ + extra: string; + willing: boolean; +} + +const newIssue = "https://github.com/dnspatch/dnspatch/issues/new"; + +/** Id of the form's last field in .github/ISSUE_TEMPLATE/_request.yml. */ +const extraField: Record = { + provider: "auth", + retriever: "details", + notifier: "auth", +}; + +const titlePrefix: Record = { + provider: "Provider request", + retriever: "Retriever request", + notifier: "Notifier request", +}; + +/** + * A link that opens dnspatch's "new request" issue form with the answers + * filled in. The page has no token and sends nothing itself: the person reads + * the result on GitHub and presses "Submit" there. Only text fields can be + * filled this way (GitHub ignores dropdowns and checkboxes), so the forms have + * none. Query keys are the field ids of the form. + */ +export function pluginRequestUrl(kind: RequestKind, r: PluginRequest): string { + const service = r.service.trim(); + const why = [ + r.why.trim(), + r.willing ? "I'd be willing to write and test this plugin myself." : "", + ] + .filter(Boolean) + .join("\n\n"); + const params = new URLSearchParams({ + template: `${kind}_request.yml`, + title: `${titlePrefix[kind]}: ${service}`, + service, + "api-docs": r.apiDocs.trim(), + why, + }); + const extra = r.extra.trim(); + if (extra) params.set(extraField[kind], extra); + return `${newIssue}?${params}`; +} diff --git a/src/i18n.ts b/src/i18n.ts index 30ab514..127a3fa 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -89,6 +89,32 @@ const ru = { platformLabel: "Где будет работать dnspatch", helpMeBuild: "Помогите мне собрать dnspatch", close: "Закрыть", + noPluginButton: "Нет моего сервиса?", + noProviderTitle: "Попросить добавить сервис", + noProviderIntro: + "Если вашего сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.", + noProviderService: "Название сервиса", + noProviderDocs: "Ссылка на документацию API", + noPluginWhy: "Почему нужен именно этот сервис", + noPluginWhyHint: + "Что вы хотите сделать такого, чего не закрывают существующие плагины", + noProviderAuth: "Как API проверяет доступ (необязательно)", + noRetrieverTitle: "Попросить добавить способ узнавать адрес", + noRetrieverIntro: + "Если нужного сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.", + noRetrieverService: "Название сервиса или источника", + noRetrieverDocs: "Ссылка на документацию или на адрес, который отдаёт IP", + noRetrieverDetails: "Что сервис отвечает и есть ли лимиты (необязательно)", + noNotifierTitle: "Попросить добавить способ уведомлений", + noNotifierIntro: + "Если нужного сервиса нет в списке, расскажите о нём: мы откроем заявку в репозитории dnspatch.", + noNotifierService: "Название сервиса или протокола", + noNotifierDocs: "Ссылка на документацию", + noNotifierAuth: "Как к нему подключаются (необязательно)", + noPluginWilling: "Готов(а) сам(а) написать и проверить этот плагин", + noPluginNote: + "Форма откроется на GitHub уже заполненной. Там её можно поправить и отправить; для этого нужен аккаунт GitHub.", + noPluginSubmit: "Открыть заявку на GitHub", noGo: "У меня не установлен Go", noGoTitle: "Go ставить не нужно, достаточно архива", noGoStep1: "Скачайте архив Go", @@ -207,6 +233,33 @@ const en: Record = { platformLabel: "Where dnspatch will run", helpMeBuild: "Help me build dnspatch", close: "Close", + noPluginButton: "Can't find your service?", + noProviderTitle: "Ask for a new service", + noProviderIntro: + "If your service is not in the list, tell us about it: we will open a request in the dnspatch repository.", + noProviderService: "Service name", + noProviderDocs: "Link to the API documentation", + noPluginWhy: "Why this one", + noPluginWhyHint: + "What you are trying to do that existing plugins do not cover", + noProviderAuth: "How the API checks access (optional)", + noRetrieverTitle: "Ask for a new way to find your address", + noRetrieverIntro: + "If the service you need is not in the list, tell us about it: we will open a request in the dnspatch repository.", + noRetrieverService: "Service or source name", + noRetrieverDocs: + "Link to the documentation or to the URL that returns the IP", + noRetrieverDetails: "What the service answers with and any limits (optional)", + noNotifierTitle: "Ask for a new way to be notified", + noNotifierIntro: + "If the service you need is not in the list, tell us about it: we will open a request in the dnspatch repository.", + noNotifierService: "Service or protocol name", + noNotifierDocs: "Link to the documentation", + noNotifierAuth: "How a client connects to it (optional)", + noPluginWilling: "I'd be willing to write and test this plugin myself", + noPluginNote: + "The form opens on GitHub already filled in. You can edit and submit it there; that needs a GitHub account.", + noPluginSubmit: "Open the request on GitHub", noGo: "I do not have Go installed", noGoTitle: "No need to install Go, an archive is enough", noGoStep1: "Download the Go archive", diff --git a/src/style.css b/src/style.css index b855292..163d77e 100644 --- a/src/style.css +++ b/src/style.css @@ -152,6 +152,7 @@ h3 { } input[type="text"], +input[type="url"], textarea, select { width: 100%; @@ -190,12 +191,14 @@ select { select:hover, input[type="text"]:hover, +input[type="url"]:hover, textarea:hover { border-color: var(--accent); } select:focus-visible, input[type="text"]:focus-visible, +input[type="url"]:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; @@ -727,6 +730,26 @@ button.primary:hover { margin: 0.3rem 0; } +/* The request form: prose in the text areas, narrower than the build helper. */ +.modal.narrow { + width: min(36rem, calc(100vw - 2rem)); +} + +.modal form .label { + margin-top: 0.75rem; +} + +.modal form textarea { + min-height: 4.5rem; + white-space: pre-wrap; + font-family: inherit; + font-size: inherit; +} + +.missing-plugin { + margin-top: 0.75rem; +} + .cards.two { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); margin: 0.75rem 0; diff --git a/src/tools/config/MissingPlugin.tsx b/src/tools/config/MissingPlugin.tsx new file mode 100644 index 0000000..7bc6b6b --- /dev/null +++ b/src/tools/config/MissingPlugin.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { pluginRequestUrl, type RequestKind } from "../../core/issue"; +import { type Key, t } from "../../i18n"; + +/** The texts that differ between the forms; the rest is shared. */ +const texts: Record< + RequestKind, + { + title: Key; + intro: Key; + service: Key; + docs: Key; + extra: Key; + example: string; + } +> = { + provider: { + title: "noProviderTitle", + intro: "noProviderIntro", + service: "noProviderService", + docs: "noProviderDocs", + extra: "noProviderAuth", + example: "Gandi, DuckDNS, …", + }, + retriever: { + title: "noRetrieverTitle", + intro: "noRetrieverIntro", + service: "noRetrieverService", + docs: "noRetrieverDocs", + extra: "noRetrieverDetails", + example: "api.myip.com, …", + }, + notifier: { + title: "noNotifierTitle", + intro: "noNotifierIntro", + service: "noNotifierService", + docs: "noNotifierDocs", + extra: "noNotifierAuth", + example: "Matrix, NATS, …", + }, +}; + +/** A button that opens a form for asking for a service the list lacks. */ +export function MissingPlugin({ kind }: { kind: RequestKind }) { + const text = texts[kind]; + const dialog = useRef(null); + const [service, setService] = useState(""); + const [apiDocs, setApiDocs] = useState(""); + const [why, setWhy] = useState(""); + const [extra, setExtra] = useState(""); + const [willing, setWilling] = useState(false); + + // A click on the dimmed area around the window closes it; Esc works natively. + useEffect(() => { + const el = dialog.current; + const onClick = (e: MouseEvent) => { + if (e.target === el) el?.close(); + }; + el?.addEventListener("click", onClick); + return () => el?.removeEventListener("click", onClick); + }, []); + + const submit = (e: Event) => { + e.preventDefault(); + const url = pluginRequestUrl(kind, { + service, + apiDocs, + why, + extra, + willing, + }); + window.open(url, "_blank", "noopener"); + dialog.current?.close(); + }; + + const id = (field: string) => `mp-${kind}-${field}`; + + return ( +
+ + + + +

{t(text.intro)}

+ +
+ + setService(e.currentTarget.value)} + /> + + + setApiDocs(e.currentTarget.value)} + /> + + +