Vendor opencode source for docker build
This commit is contained in:
146
opencode/packages/console/app/src/lib/changelog.ts
Normal file
146
opencode/packages/console/app/src/lib/changelog.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { query } from "@solidjs/router"
|
||||
|
||||
type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
export type HighlightMedia =
|
||||
| { type: "video"; src: string }
|
||||
| { type: "image"; src: string; width: string; height: string }
|
||||
|
||||
export type HighlightItem = {
|
||||
title: string
|
||||
description: string
|
||||
shortDescription?: string
|
||||
media: HighlightMedia
|
||||
}
|
||||
|
||||
export type HighlightGroup = {
|
||||
source: string
|
||||
items: HighlightItem[]
|
||||
}
|
||||
|
||||
export type ChangelogRelease = {
|
||||
tag: string
|
||||
name: string
|
||||
date: string
|
||||
url: string
|
||||
highlights: HighlightGroup[]
|
||||
sections: { title: string; items: string[] }[]
|
||||
}
|
||||
|
||||
export type ChangelogData = {
|
||||
ok: boolean
|
||||
releases: ChangelogRelease[]
|
||||
}
|
||||
|
||||
export async function loadChangelog(): Promise<ChangelogData> {
|
||||
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCode-Console",
|
||||
},
|
||||
cf: {
|
||||
// best-effort edge caching (ignored outside Cloudflare)
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as RequestInit).catch(() => undefined)
|
||||
|
||||
if (!response?.ok) return { ok: false, releases: [] }
|
||||
|
||||
const data = await response.json().catch(() => undefined)
|
||||
if (!Array.isArray(data)) return { ok: false, releases: [] }
|
||||
|
||||
const releases = (data as Release[]).map((release) => {
|
||||
const parsed = parseMarkdown(release.body || "")
|
||||
return {
|
||||
tag: release.tag_name,
|
||||
name: release.name,
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
highlights: parsed.highlights,
|
||||
sections: parsed.sections,
|
||||
}
|
||||
})
|
||||
|
||||
return { ok: true, releases }
|
||||
}
|
||||
|
||||
export const changelog = query(async () => {
|
||||
"use server"
|
||||
const result = await loadChangelog()
|
||||
return result.releases
|
||||
}, "changelog")
|
||||
|
||||
function parseHighlights(body: string): HighlightGroup[] {
|
||||
const groups = new Map<string, HighlightItem[]>()
|
||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
const source = match[1]
|
||||
const content = match[2]
|
||||
|
||||
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
||||
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
||||
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
|
||||
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
||||
|
||||
const media = (() => {
|
||||
if (videoMatch) return { type: "video", src: videoMatch[1] } satisfies HighlightMedia
|
||||
if (imgMatch) {
|
||||
return {
|
||||
type: "image",
|
||||
src: imgMatch[3],
|
||||
width: imgMatch[1],
|
||||
height: imgMatch[2],
|
||||
} satisfies HighlightMedia
|
||||
}
|
||||
})()
|
||||
|
||||
if (!titleMatch || !media) continue
|
||||
|
||||
const item: HighlightItem = {
|
||||
title: titleMatch[1],
|
||||
description: pMatch?.[2] || "",
|
||||
shortDescription: pMatch?.[1],
|
||||
media,
|
||||
}
|
||||
|
||||
if (!groups.has(source)) groups.set(source, [])
|
||||
groups.get(source)!.push(item)
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
|
||||
}
|
||||
|
||||
function parseMarkdown(body: string) {
|
||||
const lines = body.split("\n")
|
||||
const sections: { title: string; items: string[] }[] = []
|
||||
let current: { title: string; items: string[] } | null = null
|
||||
let skip = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
if (current) sections.push(current)
|
||||
current = { title: line.slice(3).trim(), items: [] }
|
||||
skip = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("**Thank you")) {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("- ") && !skip) current?.items.push(line.slice(2).trim())
|
||||
}
|
||||
|
||||
if (current) sections.push(current)
|
||||
return { sections, highlights: parseHighlights(body) }
|
||||
}
|
||||
83
opencode/packages/console/app/src/lib/form-error.ts
Normal file
83
opencode/packages/console/app/src/lib/form-error.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Key } from "~/i18n"
|
||||
|
||||
export const formError = {
|
||||
invalidPlan: "error.invalidPlan",
|
||||
workspaceRequired: "error.workspaceRequired",
|
||||
alreadySubscribed: "error.alreadySubscribed",
|
||||
limitRequired: "error.limitRequired",
|
||||
monthlyLimitInvalid: "error.monthlyLimitInvalid",
|
||||
workspaceNameRequired: "error.workspaceNameRequired",
|
||||
nameTooLong: "error.nameTooLong",
|
||||
emailRequired: "error.emailRequired",
|
||||
roleRequired: "error.roleRequired",
|
||||
idRequired: "error.idRequired",
|
||||
nameRequired: "error.nameRequired",
|
||||
providerRequired: "error.providerRequired",
|
||||
apiKeyRequired: "error.apiKeyRequired",
|
||||
modelRequired: "error.modelRequired",
|
||||
} as const
|
||||
|
||||
const map = {
|
||||
[formError.invalidPlan]: "error.invalidPlan",
|
||||
[formError.workspaceRequired]: "error.workspaceRequired",
|
||||
[formError.alreadySubscribed]: "error.alreadySubscribed",
|
||||
[formError.limitRequired]: "error.limitRequired",
|
||||
[formError.monthlyLimitInvalid]: "error.monthlyLimitInvalid",
|
||||
[formError.workspaceNameRequired]: "error.workspaceNameRequired",
|
||||
[formError.nameTooLong]: "error.nameTooLong",
|
||||
[formError.emailRequired]: "error.emailRequired",
|
||||
[formError.roleRequired]: "error.roleRequired",
|
||||
[formError.idRequired]: "error.idRequired",
|
||||
[formError.nameRequired]: "error.nameRequired",
|
||||
[formError.providerRequired]: "error.providerRequired",
|
||||
[formError.apiKeyRequired]: "error.apiKeyRequired",
|
||||
[formError.modelRequired]: "error.modelRequired",
|
||||
"Invalid plan": "error.invalidPlan",
|
||||
"Workspace ID is required": "error.workspaceRequired",
|
||||
"Workspace ID is required.": "error.workspaceRequired",
|
||||
"This workspace already has a subscription": "error.alreadySubscribed",
|
||||
"Limit is required.": "error.limitRequired",
|
||||
"Set a valid monthly limit": "error.monthlyLimitInvalid",
|
||||
"Set a valid monthly limit.": "error.monthlyLimitInvalid",
|
||||
"Workspace name is required.": "error.workspaceNameRequired",
|
||||
"Name must be 255 characters or less.": "error.nameTooLong",
|
||||
"Email is required": "error.emailRequired",
|
||||
"Role is required": "error.roleRequired",
|
||||
"ID is required": "error.idRequired",
|
||||
"Name is required": "error.nameRequired",
|
||||
"Provider is required": "error.providerRequired",
|
||||
"API key is required": "error.apiKeyRequired",
|
||||
"Model is required": "error.modelRequired",
|
||||
} as const satisfies Record<string, Key>
|
||||
|
||||
export function formErrorReloadAmountMin(amount: number) {
|
||||
return `error.reloadAmountMin:${amount}`
|
||||
}
|
||||
|
||||
export function formErrorReloadTriggerMin(amount: number) {
|
||||
return `error.reloadTriggerMin:${amount}`
|
||||
}
|
||||
|
||||
export function localizeError(t: (key: Key, params?: Record<string, string | number>) => string, error?: string) {
|
||||
if (!error) return ""
|
||||
|
||||
if (error.startsWith("error.reloadAmountMin:")) {
|
||||
const amount = Number(error.split(":")[1] ?? 0)
|
||||
return t("error.reloadAmountMin", { amount })
|
||||
}
|
||||
|
||||
if (error.startsWith("error.reloadTriggerMin:")) {
|
||||
const amount = Number(error.split(":")[1] ?? 0)
|
||||
return t("error.reloadTriggerMin", { amount })
|
||||
}
|
||||
|
||||
const amount = error.match(/^Reload amount must be at least \$(\d+)$/)
|
||||
if (amount) return t("error.reloadAmountMin", { amount: Number(amount[1]) })
|
||||
|
||||
const trigger = error.match(/^Balance trigger must be at least \$(\d+)$/)
|
||||
if (trigger) return t("error.reloadTriggerMin", { amount: Number(trigger[1]) })
|
||||
|
||||
const key = map[error as keyof typeof map]
|
||||
if (key) return t(key)
|
||||
return error
|
||||
}
|
||||
38
opencode/packages/console/app/src/lib/github.ts
Normal file
38
opencode/packages/console/app/src/lib/github.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { query } from "@solidjs/router"
|
||||
import { config } from "~/config"
|
||||
|
||||
export const github = query(async () => {
|
||||
"use server"
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
}
|
||||
const apiBaseUrl = config.github.repoUrl.replace("https://github.com/", "https://api.github.com/repos/")
|
||||
try {
|
||||
const [meta, releases, contributors] = await Promise.all([
|
||||
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
|
||||
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
|
||||
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
|
||||
])
|
||||
if (!Array.isArray(releases) || releases.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const [release] = releases
|
||||
const linkHeader = contributors.headers.get("Link")
|
||||
const contributorCount = linkHeader
|
||||
? Number.parseInt(linkHeader.match(/&page=(\d+)>; rel="last"/)?.at(1) ?? "0")
|
||||
: 0
|
||||
return {
|
||||
stars: meta.stargazers_count,
|
||||
release: {
|
||||
name: release.name,
|
||||
url: release.html_url,
|
||||
tag_name: release.tag_name,
|
||||
},
|
||||
contributors: contributorCount,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
return undefined
|
||||
}, "github")
|
||||
211
opencode/packages/console/app/src/lib/language.ts
Normal file
211
opencode/packages/console/app/src/lib/language.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
export const LOCALES = [
|
||||
"en",
|
||||
"zh",
|
||||
"zht",
|
||||
"ko",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"da",
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"ar",
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
"tr",
|
||||
] as const
|
||||
|
||||
export type Locale = (typeof LOCALES)[number]
|
||||
|
||||
export const LOCALE_COOKIE = "oc_locale" as const
|
||||
export const LOCALE_HEADER = "x-opencode-locale" as const
|
||||
|
||||
function fix(pathname: string) {
|
||||
if (pathname.startsWith("/")) return pathname
|
||||
return `/${pathname}`
|
||||
}
|
||||
|
||||
const LABEL = {
|
||||
en: "English",
|
||||
zh: "简体中文",
|
||||
zht: "繁體中文",
|
||||
ko: "한국어",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
it: "Italiano",
|
||||
da: "Dansk",
|
||||
ja: "日本語",
|
||||
pl: "Polski",
|
||||
ru: "Русский",
|
||||
ar: "العربية",
|
||||
no: "Norsk",
|
||||
br: "Português (Brasil)",
|
||||
th: "ไทย",
|
||||
tr: "Türkçe",
|
||||
} satisfies Record<Locale, string>
|
||||
|
||||
const TAG = {
|
||||
en: "en",
|
||||
zh: "zh-Hans",
|
||||
zht: "zh-Hant",
|
||||
ko: "ko",
|
||||
de: "de",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
it: "it",
|
||||
da: "da",
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
ar: "ar",
|
||||
no: "no",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
} satisfies Record<Locale, string>
|
||||
|
||||
export function parseLocale(value: unknown): Locale | null {
|
||||
if (typeof value !== "string") return null
|
||||
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
|
||||
return null
|
||||
}
|
||||
|
||||
export function fromPathname(pathname: string) {
|
||||
return parseLocale(fix(pathname).split("/")[1])
|
||||
}
|
||||
|
||||
export function strip(pathname: string) {
|
||||
const locale = fromPathname(pathname)
|
||||
if (!locale) return fix(pathname)
|
||||
|
||||
const next = fix(pathname).slice(locale.length + 1)
|
||||
if (!next) return "/"
|
||||
if (next.startsWith("/")) return next
|
||||
return `/${next}`
|
||||
}
|
||||
|
||||
export function route(locale: Locale, pathname: string) {
|
||||
const next = strip(pathname)
|
||||
if (next.startsWith("/docs")) return next
|
||||
if (next.startsWith("/auth")) return next
|
||||
if (next.startsWith("/workspace")) return next
|
||||
if (locale === "en") return next
|
||||
if (next === "/") return `/${locale}`
|
||||
return `/${locale}${next}`
|
||||
}
|
||||
|
||||
export function label(locale: Locale) {
|
||||
return LABEL[locale]
|
||||
}
|
||||
|
||||
export function tag(locale: Locale) {
|
||||
return TAG[locale]
|
||||
}
|
||||
|
||||
export function dir(locale: Locale) {
|
||||
if (locale === "ar") return "rtl"
|
||||
return "ltr"
|
||||
}
|
||||
|
||||
function match(input: string): Locale | null {
|
||||
const value = input.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
|
||||
if (value.startsWith("zh")) {
|
||||
if (value.includes("hant") || value.includes("-tw") || value.includes("-hk") || value.includes("-mo")) return "zht"
|
||||
return "zh"
|
||||
}
|
||||
|
||||
if (value.startsWith("ko")) return "ko"
|
||||
if (value.startsWith("de")) return "de"
|
||||
if (value.startsWith("es")) return "es"
|
||||
if (value.startsWith("fr")) return "fr"
|
||||
if (value.startsWith("it")) return "it"
|
||||
if (value.startsWith("da")) return "da"
|
||||
if (value.startsWith("ja")) return "ja"
|
||||
if (value.startsWith("pl")) return "pl"
|
||||
if (value.startsWith("ru")) return "ru"
|
||||
if (value.startsWith("ar")) return "ar"
|
||||
if (value.startsWith("tr")) return "tr"
|
||||
if (value.startsWith("th")) return "th"
|
||||
if (value.startsWith("pt")) return "br"
|
||||
if (value.startsWith("no") || value.startsWith("nb") || value.startsWith("nn")) return "no"
|
||||
if (value.startsWith("en")) return "en"
|
||||
return null
|
||||
}
|
||||
|
||||
export function detectFromLanguages(languages: readonly string[]) {
|
||||
for (const language of languages) {
|
||||
const locale = match(language)
|
||||
if (locale) return locale
|
||||
}
|
||||
return "en" satisfies Locale
|
||||
}
|
||||
|
||||
export function detectFromAcceptLanguage(header: string | null) {
|
||||
if (!header) return "en" satisfies Locale
|
||||
|
||||
const items = header
|
||||
.split(",")
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const parts = raw.split(";").map((x) => x.trim())
|
||||
const lang = parts[0] ?? ""
|
||||
const q = parts
|
||||
.slice(1)
|
||||
.find((x) => x.startsWith("q="))
|
||||
?.slice(2)
|
||||
return {
|
||||
lang,
|
||||
q: q ? Number.parseFloat(q) : 1,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.q - a.q)
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.lang || item.lang === "*") continue
|
||||
const locale = match(item.lang)
|
||||
if (locale) return locale
|
||||
}
|
||||
|
||||
return "en" satisfies Locale
|
||||
}
|
||||
|
||||
export function localeFromCookieHeader(header: string | null) {
|
||||
if (!header) return null
|
||||
|
||||
const raw = header
|
||||
.split(";")
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x.startsWith(`${LOCALE_COOKIE}=`))
|
||||
?.slice(`${LOCALE_COOKIE}=`.length)
|
||||
|
||||
if (!raw) return null
|
||||
return parseLocale(decodeURIComponent(raw))
|
||||
}
|
||||
|
||||
export function localeFromRequest(request: Request) {
|
||||
const fromHeader = parseLocale(request.headers.get(LOCALE_HEADER))
|
||||
if (fromHeader) return fromHeader
|
||||
|
||||
const fromPath = fromPathname(new URL(request.url).pathname)
|
||||
if (fromPath) return fromPath
|
||||
|
||||
return (
|
||||
localeFromCookieHeader(request.headers.get("cookie")) ??
|
||||
detectFromAcceptLanguage(request.headers.get("accept-language"))
|
||||
)
|
||||
}
|
||||
|
||||
export function cookie(locale: Locale) {
|
||||
return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
export function clearCookie() {
|
||||
return `${LOCALE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
|
||||
}
|
||||
Reference in New Issue
Block a user