Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
export const STACKCHAN_MOD_FORMAT = 'tech.stackchan.mod'
export const STACKCHAN_MOD_SCHEMA_VERSION = 1
export const STACKCHAN_MOD_TYPES = Object.freeze(['block', 'text'])
const ID_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/
const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function nonEmptyString(value, label, maxLength) {
const normalized = String(value ?? '').trim()
if (!normalized || normalized.length > maxLength) throw new TypeError(`${label}が不正です`)
return normalized
}
export function validatePackagePath(value, label = 'path') {
const path = String(value ?? '')
const segments = path.split('/')
if (
!path ||
path.startsWith('/') ||
path.includes('\\') ||
/[\u0000-\u001f:]/.test(path) ||
segments.some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new TypeError(`${label}が安全な相対パスではありません`)
}
return path
}
function stringList(value, label, { required = false } = {}) {
if (value === undefined && !required) return []
if (!Array.isArray(value) || (required && value.length === 0)) throw new TypeError(`${label}が不正です`)
const result = value.map((item) => nonEmptyString(item, label, 128))
if (new Set(result).size !== result.length) throw new TypeError(`${label}に重複があります`)
return result
}
function catalogDefinitionUrl(value, catalogUrl) {
const path = String(value ?? '')
if (!path || path.startsWith('/') || path.startsWith('//') || path.includes('\\') || /[\u0000-\u001f:]/.test(path)) {
throw new TypeError('definitionが安全なサイト内パスではありません')
}
const definitionUrl = new URL(path, catalogUrl)
if (definitionUrl.origin !== catalogUrl.origin || !definitionUrl.pathname.endsWith('/stackchan-mod.json')) {
throw new TypeError('definitionがStack-chan MOD定義を指していません')
}
return definitionUrl
}
export function parseModDefinition(value) {
if (!isRecord(value)) throw new TypeError('MOD定義がJSONオブジェクトではありません')
if (value.format !== STACKCHAN_MOD_FORMAT) throw new TypeError('未対応のMOD定義形式です')
if (value.schemaVersion !== STACKCHAN_MOD_SCHEMA_VERSION) {
throw new TypeError(`未対応のMOD定義バージョンです: ${value.schemaVersion ?? 'なし'}`)
}
const id = nonEmptyString(value.id, 'id', 128)
if (!ID_PATTERN.test(id)) throw new TypeError('idは逆ドメイン形式で指定してください')
const version = nonEmptyString(value.version, 'version', 80)
if (!VERSION_PATTERN.test(version)) throw new TypeError('versionはセマンティックバージョン形式で指定してください')
if (!STACKCHAN_MOD_TYPES.includes(value.type)) throw new TypeError('typeはblockまたはtextで指定してください')
if (!isRecord(value.source)) throw new TypeError('sourceがありません')
const sourcePath = validatePackagePath(value.source.path, 'source.path')
if (value.type === 'block' && !sourcePath.endsWith('.stackchan-blocks.json')) {
throw new TypeError('block MODのsource.pathは.stackchan-blocks.jsonを指す必要があります')
}
if (value.type === 'text' && !/(^|\/)manifest[^/]*\.json$/.test(sourcePath)) {
throw new TypeError('text MODのsource.pathはModdable manifestを指す必要があります')
}
const artifacts = value.artifacts === undefined ? [] : value.artifacts
if (!Array.isArray(artifacts)) throw new TypeError('artifactsが不正です')
return {
format: STACKCHAN_MOD_FORMAT,
schemaVersion: STACKCHAN_MOD_SCHEMA_VERSION,
id,
version,
type: value.type,
name: nonEmptyString(value.name, 'name', 80),
description: nonEmptyString(value.description, 'description', 400),
...(value.author === undefined ? {} : { author: nonEmptyString(value.author, 'author', 120) }),
...(value.license === undefined ? {} : { license: nonEmptyString(value.license, 'license', 80) }),
source: { path: sourcePath },
targets: stringList(value.targets, 'targets', { required: true }),
capabilities: stringList(value.capabilities, 'capabilities'),
artifacts: artifacts.map((artifact, index) => {
if (!isRecord(artifact) || artifact.format !== 'xsa') {
throw new TypeError(`artifacts[${index}]が不正です`)
}
return {
format: 'xsa',
path: validatePackagePath(artifact.path, `artifacts[${index}].path`),
target: nonEmptyString(artifact.target, `artifacts[${index}].target`, 128),
}
}),
}
}
async function fetchJson(url, fetcher) {
const response = await fetcher(url)
if (!response.ok) throw new Error(`${url.pathname}を取得できませんでした (${response.status})`)
return response.json()
}
export async function loadModCatalog(catalogUrl, fetcher = globalThis.fetch) {
const resolvedCatalogUrl = new URL(catalogUrl, globalThis.location?.href ?? 'http://localhost/')
const catalog = await fetchJson(resolvedCatalogUrl, fetcher)
if (catalog?.format !== 'tech.stackchan.mod-catalog' || catalog.version !== 1) {
throw new TypeError('未対応のMODカタログ形式です')
}
if (!Array.isArray(catalog.definitions)) throw new TypeError('カタログにdefinitionsがありません')
const definitions = await Promise.all(
catalog.definitions.map(async (entry) => {
const definitionUrl = catalogDefinitionUrl(entry, resolvedCatalogUrl)
const definition = parseModDefinition(await fetchJson(definitionUrl, fetcher))
return {
...definition,
definitionUrl,
sourceUrl: new URL(definition.source.path, definitionUrl),
artifacts: definition.artifacts.map((artifact) => ({
...artifact,
url: new URL(artifact.path, definitionUrl),
})),
}
})
)
if (new Set(definitions.map((definition) => definition.id)).size !== definitions.length) {
throw new TypeError('カタログ内のMOD IDが重複しています')
}
return definitions
}
4bc857bacThis file is pinned to the selected release. Download the original to open it in your design tool.