Moderation Changes Phase 2.-1 (#6833)

* better default state handling

* make next stage button work for going to next project

* fix skill issue

* actually fix initial values this time,
make single line inputs with varying lengths (text input and dropdown) use their widest possible length instead of whatever the hell they used to be

* loaders in progress

* loaders and versions test (probably dont use them as the messages suck and idrk what they should do quick fix/message wise)

* WIP the wip

* good enough for now slug taken improvements

* trim all those extra newlines

* viewedness

* batch prefetch queue + technically slightly faster checklist init

* show all valid alternative project types regardless of current project type

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* gimme them versions

* better moderation permission stage shown check?

* betterer moderation permission stage shown check?

* bettererer moderation permission stage shown check? + also began deleting legacy checklist types

* fix certain stages being able to be considered viewed before being viewed

* more removals + i forgot to remove an import last commit

* changed nothing™️

* remove severities + bandaid post-approval message priorities + dependencies

* fix message preview tooltips

* next stage = next project when done

* insufficient description custom fix

* fix that thing coolbot told me was broken I forgot what it was oops

* oh there was a second bug there

* make alternate versions and incorrect project type required cuz they are

* re-navigate button also damn that's a lot of other stuff that pnpm fix... fixed?

* you cant just quick fix a slug into a taken one

* maybe lets not just try and set a slug to something entirely invalid?

* what if we just didn't drop ur queue :smart:

* more queue upgrades

* tweaks

* I love unbreaking things

* un-nuked renavigate button

* prepr

* prepr worked this time i think

* undo more mistakes

* i love intellij refactoring

* ok i think we're good
This commit is contained in:
chyz
2026-08-07 23:40:11 +00:00
committed by GitHub
parent e4cbec6b2c
commit 5072c1d298
77 changed files with 3596 additions and 3502 deletions
+5 -12
View File
@@ -1,8 +1,6 @@
import type { Ref } from 'vue'
import { provide, ref } from 'vue'
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
import { group, STAGES_KEY } from '../types/node'
import type { NodeState, StageNode } from '../types/node'
import useCategoriesStage from './stages/categories'
import useDescriptionStage from './stages/description'
import useGalleryStage from './stages/gallery'
@@ -13,7 +11,7 @@ import usePermissionsStage from './stages/permissions'
import usePostApprovalStage from './stages/post-approval'
import useReReviewStage from './stages/re-review'
import useReuploadsStage from './stages/reupload'
import useOtherRulesStage from './stages/other-rules'
import useRulesStage from './stages/rules'
import useStatusAlertsStage from './stages/status-alerts'
import useSummaryStage from './stages/summary'
import useTitleSlugStage from './stages/title-slug'
@@ -22,8 +20,8 @@ import useVersionsStage from './stages/versions'
export function useStages(
globalState: Ref<Record<string, Record<string, NodeState>>>,
): StageNodeBuilder[] {
const mainStages: StageNodeBuilder[] = [
): StageNode[] {
const mainStages: StageNode[] = [
usePostApprovalStage(),
useUndefinedProjectStage(),
useReReviewStage(),
@@ -38,12 +36,7 @@ export function useStages(
useVersionsStage(),
useReuploadsStage(),
usePermissionsStage(),
useOtherRulesStage(),
useRulesStage(),
]
provide(STAGES_KEY, ref(mainStages))
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
}
export const stages: ReadonlyArray<StageFn> = []
export default group()
+2 -2
View File
@@ -1,6 +1,7 @@
import type { KeybindListener } from '../types/keybinds'
import type { Labrinth } from '@modrinth/api-client'
import type { KeybindListener } from '../types/keybinds'
const copyProjectLink = async (
project: Labrinth.Projects.v2.Project,
permalink: boolean,
@@ -32,7 +33,6 @@ const keybinds: { [id: string]: KeybindListener } = {
keybind: 'ArrowRight',
description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
@@ -2,5 +2,3 @@
Per section 2.1 of %RULES%, your %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your %PROJECT_TYPE%.</br>
Currently, it looks like there are some missing details.
%CUSTOM_ADVICE%
@@ -0,0 +1,4 @@
## Missing Dependencies
Per section 5.6 of %RULES%, it is important that relevant dependencies be listed in the dependencies section of your project.
Please ensure that all relevant dependencies are included in the Dependencies section of each version of your project.
@@ -0,0 +1 @@
This project should have the following game version(s) selected: %GAME_VERSIONS%.
@@ -0,0 +1,5 @@
## Game Version Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which Minecraft versions are selected.
%CORRECT%
@@ -0,0 +1 @@
This project should have the following loader(s) selected: %LOADERS%.
@@ -0,0 +1,5 @@
## Loader Metadata
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which loaders are selected.
%CORRECT%
+1 -2
View File
@@ -1,8 +1,7 @@
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, licensesRequiringSource, notSourceAsDistributed } from '../../utils'
import license from '../stages/license'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
export const commonLinkDomains = {
source: [
@@ -8,4 +8,5 @@ export const Priorities = new (class extends Priority {
rejected = this.before()
withheld = this.before()
note = this.after()
tempLast = this.after()
})()
@@ -36,7 +36,7 @@ export default function () {
),
group().children(
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').severity('low').message(),
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').message(),
toggle('optimization-misused', 'Optimization')
.shown(
@@ -47,7 +47,6 @@ export default function () {
),
)
.suggestedStatus('flagged')
.severity('low')
.rawMessage(optimizationMsg)
.fix(
fix().project((patch) => {
@@ -61,7 +60,6 @@ export default function () {
toggle('resolutions-misused', 'Resolutions')
.shown(computed(() => project.value.project_types.includes('resourcepack')))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(resolutionsMsg)
.fix(
fix().project((patch) => {
@@ -15,24 +15,23 @@ export default function () {
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080508042e70089dd787e',
)
.icon(LibraryIcon)
.navigate('/')
.navigate()
.children(
group()
.title('Description Issues?')
.children(
toggle('insufficient', 'Insufficient')
.suggestedStatus('flagged')
.severity('medium')
.message('insufficient/header', (s) => ({ CUSTOM_ADVICE: s.custom?.explainer }))
.message('insufficient/header')
.children(
group()
.title('Why is this Description Insufficient?')
.multiSelect('reason')
.children(
toggle('custom', 'Custom').children(
markdown('explainer')
.title('How can the author improve their description?')
.required(),
.required()
.rawMessage((state) => `${state.value}\n\n`),
),
toggle('fork', 'Fork').message('piece/fork'),
toggle('unfinished', 'Unfinished').message('piece/unfinished'),
@@ -44,15 +43,13 @@ export default function () {
`insufficient/default/${project.value?.minecraft_java_server ? 'servers' : project.value?.project_types?.includes('modpack') ? 'packs' : 'projects'}`,
)
.rawMessage(async (state) => {
const reasons = state?.reason instanceof Set ? state.reason : new Set<string>()
return SHOW_SPOILER_ADVICE.some((reason) => reasons.has(reason))
return SHOW_SPOILER_ADVICE.some((reason) => state?.[reason] === true)
? await md('checklist/messages/description/insufficient/piece/spoiler-guide')(state)
: ''
}),
toggle('non-english', 'Non-english')
.suggestedStatus('flagged')
.severity('medium')
.message(() => `non-english${project.value.minecraft_java_server ? '-server' : ''}`)
.shown(
computed(() => {
@@ -63,25 +60,13 @@ export default function () {
}),
),
toggle('headers-as-body', 'Headers as body text')
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('headers-as-body', 'Headers as body text').suggestedStatus('flagged').message(),
toggle('image-only', 'Image-only')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('image-only', 'Image-only').suggestedStatus('flagged').message(),
toggle('non-standard-text', 'Non-standard text')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('non-standard-text', 'Non-standard text').suggestedStatus('flagged').message(),
toggle('clarity', 'Unclear / Misleading')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('clarity', 'Unclear / Misleading').suggestedStatus('rejected').message(),
),
)
}
@@ -16,18 +16,14 @@ export default function () {
.navigate('/gallery')
.children(
group().children(
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').severity('low').message(),
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').message(),
toggle('not-relevant', 'Not relevant')
.shown(computed(() => project.value.gallery.length > 0))
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('showcase-clarity', 'Showcase Clarity')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('showcase-clarity', 'Showcase Clarity').suggestedStatus('rejected').message(),
),
)
}
@@ -53,7 +53,6 @@ export default function () {
toggle('invalid-link', 'Invalid Link')
.shown(computed(() => !!project.value.license?.url))
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(check('custom-license', 'Invalid Link: Custom License').message())
.collect(),
@@ -61,12 +60,11 @@ export default function () {
toggle('no-source', 'No Source')
.shown(needSource)
.suggestedStatus('rejected')
.severity('medium')
.rawMessage(async (state) => {
if (state.fork) return noSourceForkMsg(state)
return noSourceMsg(state)
})
.children(check('fork', 'No Source: Fork').severity('high')),
.children(check('fork', 'No Source: Fork')),
),
)
}
@@ -3,15 +3,15 @@ import { injectProjectPageContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed } from 'vue'
import { group, md, mdOptional, stage, toggle } from '../../types/node'
import type { ChildEntry, GroupNodeBuilder } from '../../types/node'
import { promptSourceRequired } from '../..'
import type { ChildEntry, GroupNode } from '../../types/node'
import { group, md, mdOptional, stage, toggle } from '../../types/node'
export default function () {
const { projectV3: project } = injectProjectPageContext()
const linkNames: Record<string, string> = {}
type LinkSectionBuilder = GroupNodeBuilder & {
type LinkSectionBuilder = GroupNode & {
children(...extras: ChildEntry[]): LinkSectionBuilder
label(badge: Ref<boolean>): LinkSectionBuilder
}
@@ -75,7 +75,6 @@ export default function () {
.navigate('/settings/links')
.shown(computed(() => Object.keys(project.value.link_urls).length > 0))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(async (state) => {
const sections = Object.entries(state).filter(
([, s]) => s && typeof s === 'object' && !(s instanceof Set),
+107 -66
View File
@@ -3,7 +3,16 @@ import { DatabaseIcon } from '@modrinth/assets'
import { ENVIRONMENTS_COPY, injectProjectPageContext, injectTags } from '@modrinth/ui'
import { computed } from 'vue'
import { dropdown, fix, group, md, option, stage, toggle } from '../../types/node'
import {
appComponent as _appComponent,
dropdown,
fix,
group,
md,
option,
stage,
toggle,
} from '../../types/node'
import { requiresEnvironmentInfo } from '../../utils'
const loaderLabels: Record<string, string> = {
@@ -13,7 +22,7 @@ const loaderLabels: Record<string, string> = {
resourcepack: 'Resource Pack',
}
function formatLoaderLabel(id: string): string {
function _formatLoaderLabel(id: string): string {
return (
loaderLabels[id] ??
id
@@ -25,7 +34,11 @@ function formatLoaderLabel(id: string): string {
export default function () {
const { projectV3: project } = injectProjectPageContext()
const { loaders } = injectTags()
const { loaders: _loaders, gameVersions: _gameVersions } = injectTags()
const _currentGameVersions = computed(
() => (project.value.game_versions as string[] | undefined) ?? [],
)
return (
stage('metadata', 'Metadata')
@@ -64,7 +77,6 @@ export default function () {
toggle('environment', 'Environment')
.shown(computed(() => requiresEnvironmentInfo(project.value.project_types)))
.suggestedStatus('flagged')
.severity('low')
.rawMessage(async (state) => {
const correctEnvironment = state?.['correct-environment'] as string | undefined
@@ -94,7 +106,7 @@ export default function () {
.title('Correct Environment')
.children(
dropdown('correct-environment')
.children(
.options(
...(Object.keys(ENVIRONMENTS_COPY) as Labrinth.Projects.v3.Environment[])
.filter((id) => id !== 'unknown')
.map((id) => option(id, ENVIRONMENTS_COPY[id].title.defaultMessage ?? id)),
@@ -103,67 +115,96 @@ export default function () {
.none('Unknown'),
),
),
// TODO: chyz, fix pls (make into single set of buttons where current loaders start selected and non current start non selected
// toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`).children(
// group()
// .title('Loader Issues?')
// .action(
// action()
// .suggestedStatus('flagged')
// .severity('medium')
// .message(async (state) => {
// //TODO: chyz
// //TODO: coolbot this one is a bit of a doozy
// const header = await md('checklist/messages/metadata/loader/incorrect')(state)
// const selected = state.loaders
// if (selected instanceof Set && selected.size > 0) {
// const list = [...selected]
// .map((id) => `- ${formatLoaderLabel(id)}`)
// .join('\n')
// return `${header}\n${list}`
// }
// return header
// }),
// )
// .children(
// toggle('incorrect', 'Incorrect').children(
// group()
// .title('Incorrect Loaders')
// .multiSelect('loaders')
// .children(
// ...project.value.loaders.map((id) => option(id, formatLoaderLabel(id))),
// ),
// ),
// TODO: chyz, this should be the same interface as incorrect, as a corrections scheme, with selected loaders default on.
// toggle('missing', 'Missing').children(
// group()
// .title('Missing Loaders')
// .multiSelect('loaders')
// .children(
// ...(() => {
// //TODO: chyz maybe this can be done better
// // (plugin loaders and datapack are marked as valid for mods which makes this suck)
// const existingTypes = new Set(
// loaders.value
// .filter((l) => project.value.loaders.includes(l.name))
// .flatMap((l) => l.supported_project_types),
// )
// const referenceTypes =
// existingTypes.size > 0
// ? existingTypes
// : new Set(project.value.project_types)
// return loaders.value
// .filter(
// (loader) =>
// loader.supported_project_types.every((t) => referenceTypes.has(t)) &&
// !project.value.loaders.includes(loader.name),
// )
// .map((loader) => option(loader.name, formatLoaderLabel(loader.name)))
// })(),
// )/
// ),
// ),
// ),
toggle('dependencies', 'Dependencies').suggestedStatus('flagged').message(),
// toggle('loader', 'Loaders (WIP)')
// .suggestedStatus('flagged')
// .rawMessage(async (state) => {
// const selected =
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
// const current = new Set(project.value.loaders)
// const isCorrected =
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
//
// let correct = ''
// if (isCorrected) {
// const list = [...selected].map((id) => formatLoaderLabel(id)).join(', ')
// correct = await md('checklist/messages/metadata/loader/correction', () => ({
// LOADERS: list || 'none',
// }))(state)
// }
//
// return md('checklist/messages/metadata/loader/inaccurate', () => ({
// CORRECT: correct,
// }))(state)
// })
// .fix(
// fix().project((patch, state) => {
// const selected =
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
// const next = [...selected]
// const current = project.value.loaders
// if (next.length === current.length && next.every((id) => current.includes(id)))
// return
// patch.loaders = next
// }),
// )
// .children(
// appComponent('loaders', 'loader-picker')
// .valueKind('set')
// .initial(() => new Set(project.value.loaders))
// .props((ctx) => ({
// loaders: loaders.value,
// toggleLoader: ctx.toggleSetValue,
// })),
// ),
//
// toggle('game-version', 'Game Versions (WIP)')
// .suggestedStatus('flagged')
// .rawMessage(async (state) => {
// const selected =
// state['game-versions'] instanceof Set
// ? state['game-versions']
// : new Set(currentGameVersions.value)
// const current = new Set(currentGameVersions.value)
// const isCorrected =
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
//
// let correct = ''
// if (isCorrected) {
// const list = [...selected].join(', ')
// correct = await md('checklist/messages/metadata/game-version/correction', () => ({
// GAME_VERSIONS: list || 'none',
// }))(state)
// }
//
// return md('checklist/messages/metadata/game-version/inaccurate', () => ({
// CORRECT: correct,
// }))(state)
// })
// .fix(
// fix().project((patch, state) => {
// const selected =
// state['game-versions'] instanceof Set
// ? state['game-versions']
// : new Set(currentGameVersions.value)
// const next = [...selected]
// const current = currentGameVersions.value
// if (next.length === current.length && next.every((id) => current.includes(id)))
// return
// patch.game_versions = next
// }),
// )
// .children(
// appComponent('game-versions', 'game-version-picker')
// .valueKind('set')
// .initial(() => new Set(currentGameVersions.value))
// .props(() => ({
// gameVersions: gameVersions.value,
// noHeader: true,
// })),
// ),
),
)
)
@@ -1,11 +1,30 @@
import type { Labrinth } from '@modrinth/api-client'
import { SignatureIcon } from '@modrinth/assets'
import { injectProjectPageContext } from '@modrinth/ui'
import { injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { group, stage, toggle } from '../../types/node'
function isResolved(attributionGroup: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const attribution = attributionGroup.attribution
if (!attribution) return false
if (attribution.kind === 'globally_allowed') return true
return attribution.moderation_status?.kind === 'approved'
}
export default function () {
const { projectV3: project } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { data: attributionData } = useQuery({
queryKey: ['project-attribution', project.value.id],
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
})
const unresolvedCount = computed(
() => (attributionData.value ?? []).filter((g) => !isResolved(g)).length,
)
return stage('permissions', 'Modpack Permissions')
.hint("Does this project's external content have any issues?")
@@ -16,30 +35,24 @@ export default function () {
computed(
() =>
(project.value.project_types?.includes('modpack') ?? false) &&
!project.value.minecraft_server,
!project.value.minecraft_server &&
unresolvedCount.value > 0,
),
)
.sticky()
.children(
group().children(
toggle('invalid-permissions', 'Invalid permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('invalid-permissions', 'Invalid permissions').suggestedStatus('rejected').message(),
toggle('prohibited-external-content', 'Prohibited externals')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('missing-permissions', 'Missing permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('missing-permissions', 'Missing permissions').suggestedStatus('rejected').message(),
toggle('non-commercial-external-content', 'Non-commercial externals')
.shown(computed(() => project.value.monetization_status === 'monetized'))
.suggestedStatus('rejected')
.severity('high')
.message(),
),
)
@@ -3,6 +3,7 @@ import { injectProjectPageContext } from '@modrinth/ui'
import { computed } from 'vue'
import { group, stage, text, toggle } from '../../types/node'
import { Priorities } from '../priorities.ts'
//TODO chyz
//TODO coolbot needs discussion
@@ -20,15 +21,15 @@ export default function () {
group().children(
toggle('issue-warning', 'Issue warning')
.suggestedStatus('approved')
.severity('low')
.message(),
.message()
.priority(Priorities.tempLast),
toggle('missed-deadline', 'Missed due date')
.suggestedStatus('flagged')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
STATUS: state.status,
}))
.priority(Priorities.tempLast)
.children(
//TODO: chyz this shouldn't need to be provided by moderator
text('status').title('What status is the project being set to?').required(),
@@ -36,35 +37,31 @@ export default function () {
toggle('metadata-issue', 'Incorrect metadata')
.suggestedStatus('approved')
.severity('low')
.message()
.children(
toggle('dependencies', 'Missing Dependencies')
.severity('low')
.message(undefined, (state) => ({
DEPENDENCY_NAME: state['dependency-name'],
DEPENDENCY_LINK: state['dependency-link'],
.message((state) => ({
DEPENDENCY_NAME: state['name'],
DEPENDENCY_LINK: state['link'],
}))
.children(
text('dependency-name').title('Dependency name').required(),
text('dependency-link').title('Dependency link').required(),
text('name').title('Dependency name').required(),
text('link').title('Dependency link').required(),
),
toggle('mc-versions', 'Game versions')
.severity('low')
.message(undefined, (state) => ({
.message((state) => ({
SPECIFICS: state.specifics,
}))
.children(text('specifics').title('More details about the game versions issue?')),
toggle('loaders', 'Loaders')
.severity('low')
.message(undefined, (state) => ({
.message((state) => ({
SPECIFICS: state.specifics,
}))
.children(text('specifics').title('More details about the loaders issue?')),
toggle('license', 'Inconsistent Licensing').severity('low').message(),
toggle('license', 'Inconsistent Licensing').message(),
)
.collect(),
),
@@ -32,13 +32,9 @@ export default function () {
group().children(
toggle('ignored', 'Yes')
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(
toggle('warning', 'Multiple times in a row')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('warning', 'Multiple times in a row').suggestedStatus('rejected').message(),
)
.collect(),
),
@@ -20,14 +20,13 @@ export default function () {
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080d1a0a2cda3ff2ce997',
)
.icon(CopyrightIcon)
.navigate('/')
.navigate()
.children(
group().children(
toggle('reupload', 'Re-upload')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
ORIGINAL_PROJECT: state['original-project'],
ORIGINAL_AUTHOR: state['original-author'],
}))
@@ -39,25 +38,19 @@ export default function () {
toggle('unclear-fork', 'Unclear Fork')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('insufficient-fork', 'Insufficient Fork')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('request-proof', 'Proof of permissions')
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('request-proof', 'Proof of permissions').suggestedStatus('rejected').message(),
toggle('identity-verification', 'Verify Identity')
.shown(computed(() => !project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
PLATFORM: state.platform,
}))
.children(text('platform').title('Where else can the project be found?').required()),
@@ -65,8 +58,7 @@ export default function () {
toggle('identity-verification-server', 'Verify Identity')
.shown(computed(() => !!project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
CONTACT: state.contact,
}))
.children(text('contact').title('Known public contact method').required()),
@@ -74,17 +66,15 @@ export default function () {
toggle('request-proof-server', 'Reuploaded pack')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message(),
toggle('custom-pack-verification', 'Override verification')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message()
.children(
check('list', 'List overrides?')
.message(undefined, (state) => ({
.message((state) => ({
OVERRIDES: state.overrides,
}))
.children(markdown('overrides').title('Add list of overrides.')),
@@ -94,8 +84,7 @@ export default function () {
toggle('custom-pack-prohibited', 'Forbidden Overrides')
.shown(isServerModpack)
.suggestedStatus('rejected')
.severity('high')
.message(undefined, (state) => ({
.message((state) => ({
OVERRIDES: state.overrides,
}))
.children(markdown('overrides').title('Forbidden overrides list').required()),
@@ -2,9 +2,8 @@ import { ListBulletedIcon } from '@modrinth/assets'
import { injectProjectPageContext } from '@modrinth/ui'
import { computed } from 'vue'
import { group, markdown, option, stage, toggle } from '../../types/node'
import { group, markdown, stage, toggle } from '../../types/node'
//TODO: coolbot have fun :3
export default function () {
const { projectV3: project } = injectProjectPageContext()
@@ -20,61 +19,49 @@ export default function () {
toggle('paid-access-server', 'Paid access server')
.shown(computed(() => !!project.value.minecraft_server))
.suggestedStatus('rejected')
.severity('critical')
.message(),
// TODO: chyz, the lists built by these message have empty line gaps.
toggle('prohibited-content', 'Prohibited Content')
.suggestedStatus('rejected')
.severity('critical')
.message('prohibited-content-header')
.collect()
.children(
group()
.multiSelect('options')
.title('Which Prohibited Content rules does this project violate?')
.children(
option('objectionable', 'Objectionable').message(),
option('discriminatory', 'Discriminatory or Explicit').message(),
option('ip-infringement', 'IP Infringement').message(),
option('legal-rights', 'Rights Violation').message(),
option('illegal-activity', 'Illegal Activity').message(),
option('harmful', 'Harmful or Deceptive').message(),
option('misleading', 'Misleading claims').message(),
option('impersonation', 'Impersonation').message(),
option('false-endorsement', 'False Endorsement').message(),
option('profanity', 'Profanity').message(),
option('undisclosed-upload', 'Undisclosed Data Upload').message(),
option('mojang-bypass', 'Mojang Bypass').message(),
toggle('objectionable', 'Objectionable').message(),
toggle('discriminatory', 'Discriminatory or Explicit').message(),
toggle('ip-infringement', 'IP Infringement').message(),
toggle('legal-rights', 'Rights Violation').message(),
toggle('illegal-activity', 'Illegal Activity').message(),
toggle('harmful', 'Harmful or Deceptive').message(),
toggle('misleading', 'Misleading claims').message(),
toggle('impersonation', 'Impersonation').message(),
toggle('false-endorsement', 'False Endorsement').message(),
toggle('profanity', 'Profanity').message(),
toggle('undisclosed-upload', 'Undisclosed Data Upload').message(),
toggle('mojang-bypass', 'Mojang Bypass').message(),
),
),
toggle('cheat-or-hack-advertising', 'Hacks')
.suggestedStatus('rejected')
.severity('critical')
.message(),
toggle('cheat-or-hack-advertising', 'Hacks').suggestedStatus('rejected').message(),
toggle('server-side-opt-out', 'Opt-out')
.suggestedStatus('flagged')
.severity('high')
.message(),
toggle('server-side-opt-out', 'Opt-out').suggestedStatus('flagged').message(),
toggle('server-side-opt-in', 'Opt-in')
.suggestedStatus('flagged')
.severity('high')
.message('server-side-opt-in-header')
.collect()
.children(
group()
.multiSelect('options')
.title('Which features require a Server-side Opt-in?')
.children(
option('x-ray', 'X-ray').message(),
option('aim-bot', 'Aim Assist').message(),
option('movement', 'Movement').message(),
option('pvp', 'PvP').message(),
option('hiding-mods', 'Anti 3.x').message(),
option('item-duplication', 'Dupe').message(),
toggle('x-ray', 'X-ray').message(),
toggle('aim-bot', 'Aim Assist').message(),
toggle('movement', 'Movement').message(),
toggle('pvp', 'PvP').message(),
toggle('hiding-mods', 'Anti 3.x').message(),
toggle('item-duplication', 'Dupe').message(),
),
),
@@ -88,14 +75,11 @@ export default function () {
),
)
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('rule-breaking-other', 'Other')
// TODO: chyz, the required asterisk is on a separate line
.suggestedStatus('rejected')
.severity('critical')
.message(undefined, (state) => ({ MESSAGE: state.message }))
.message((state) => ({ MESSAGE: state.message }))
.children(
markdown('message').title('Explain how it infringes on content rules.').required(),
),
@@ -3,9 +3,9 @@ import { injectProjectPageContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed } from 'vue'
import type { NodeState, StageNodeBuilder } from '../../types/node'
import type { AnyNode, ChildNode, NodeState, StageNode } from '../../types/node'
import {
NodeBuilder,
externalGroup,
getBooleanChildState,
group,
isNodeActive,
@@ -18,7 +18,7 @@ import {
import { Priorities } from '../priorities.ts'
export default function (
mainStages: StageNodeBuilder[],
mainStages: StageNode[],
globalState: Ref<Record<string, Record<string, NodeState>>>,
) {
const { projectV3: project } = injectProjectPageContext()
@@ -47,23 +47,26 @@ export default function (
.priority(Priorities.alerts)
.applyFixes()
.children(
computed<NodeBuilder | null>(() => {
const fixNodes: NodeBuilder[] = []
computed<AnyNode | null>(() => {
const fixGroups: ChildNode[] = []
walkNodes(
[group().children(...mainStages)],
(globalState.value ?? {}) as unknown as Record<string, NodeState>,
(node, nodeState) => {
if (!node._fixes.length) return
(node, nodeState, _localState, path) => {
if (!('_fixes' in node) || !(node as { _fixes: unknown[] })._fixes.length) return
if (!isNodeActive(node, nodeState)) return
const childState = getBooleanChildState(nodeState)
fixNodes.push(
...resolveChildren(node, childState).filter(
(c): c is NodeBuilder => c instanceof NodeBuilder,
),
const children = resolveChildren(
node as never,
getBooleanChildState(nodeState),
).filter(
(c): c is Exclude<ChildNode, string | (() => unknown)> =>
typeof c === 'object' && c !== null,
)
if (children.length === 0) return
fixGroups.push(externalGroup(path).children(...children))
},
)
return fixNodes.length > 0 ? group().children(...fixNodes) : null
return fixGroups.length > 0 ? group().children(...fixGroups) : null
}),
),
@@ -31,20 +31,17 @@ export default function () {
toggle('insufficient', 'Insufficient')
.enabled((state) => !state['repeat-title'])
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('repeat-title', 'Repeat of Title')
.enabled((state) => !state.insufficient)
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('formatting', 'Formatting').suggestedStatus('flagged').severity('low').message(),
toggle('formatting', 'Formatting').suggestedStatus('flagged').message(),
toggle('non-english', 'Non-english')
.suggestedStatus('flagged')
.severity('medium')
.message()
.shown(
computed(() => {
@@ -62,7 +59,6 @@ export default function () {
toggle('repeat-ip', 'Repeat of IP')
.shown(computed(() => !!project.value?.minecraft_server))
.suggestedStatus('flagged')
.severity('medium')
.message(),
),
)
+108 -133
View File
@@ -5,18 +5,23 @@ import {
TagCategoryRefreshCcwIcon,
TagCategoryWandSparklesIcon,
UserPlusIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Alert, injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
import {
Alert,
injectModrinthClient,
injectProjectPageContext,
ProjectStatusLink,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { md, type NodeState } from '../../types/node'
import { button, fix, group, option, stage, text, toggle } from '../../types/node'
import { check, fix, group, md, stage, text, toggle } from '../../types/node'
const STALE_TIME = 1000 * 60 * 5
type AutoSlugStatus = 'loading' | 'available' | 'unavailable'
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | null
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | 'empty' | 'invalid' | null
//TODO: make this not a copy of frontend/src/utils/slugs.generateUrlSlug
// (as in move the other one so we can use it here)
@@ -46,12 +51,6 @@ export default function () {
const slugValidation = ref<SlugValidation>(null)
let slugDebounceTimer: ReturnType<typeof setTimeout> | undefined
function currentSlug(state: Record<string, NodeState>) {
return (
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value ?? project.value.slug
)
}
async function checkSlugTaken(slug: string): Promise<Labrinth.Projects.v3.Project | null> {
try {
return await queryClient.fetchQuery({
@@ -66,8 +65,13 @@ export default function () {
}
const SlugStatus = () => {
const v = slugValidation.value
if (v === null) return null
const v =
slugValidation.value ??
(autoSlugStatus.value === 'loading'
? 'checking'
: autoSlugStatus.value === 'unavailable'
? 'taken'
: 'available')
if (v === 'checking')
return (
<Alert type="checking" class="w-full">
@@ -86,19 +90,25 @@ export default function () {
Slug is available
</Alert>
)
if (v === 'empty')
return (
<Alert type="error" class="w-full">
Slug cannot be empty
</Alert>
)
if (v === 'invalid')
return (
<Alert type="error" class="w-full">
Invalid Slug
</Alert>
)
const by = correctSlugConflict.value
return (
<Alert type="error" class="w-full">
Slug taken
{by ? (
<>
{' by '}
<a href={`/project/${by.slug}`} target="_blank" class="underline">
{by.name}
</a>
{` (${by.status})`}
</>
) : null}
<div class="flex flex-wrap items-center gap-1.5">
Slug taken
{by ? <ProjectStatusLink project={by} newTab /> : null}
</div>
</Alert>
)
}
@@ -157,7 +167,9 @@ export default function () {
return
}
}
} catch {}
} catch {
// fall through to 'unavailable' below
}
autoSlugStatus.value = 'unavailable'
},
@@ -190,12 +202,7 @@ export default function () {
{autoSlugStatus.value === 'loading' ? (
'...'
) : by ? (
<>
<a href={`/project/${by.slug}`} target="_blank" class="underline">
{by.name}
</a>
{` (${by.status})`}
</>
<ProjectStatusLink project={by} newTab />
) : (
'No'
)}
@@ -206,30 +213,22 @@ export default function () {
group('title')
.title('Title Issues?')
.children(
toggle('useless-info', 'Contains Useless Info')
.suggestedStatus('flagged')
.severity('low')
.message(),
toggle('useless-info', 'Contains Useless Info').suggestedStatus('flagged').message(),
toggle('minecraft-branding', 'Minecraft Title')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('minecraft-branding', 'Minecraft Title').suggestedStatus('flagged').message(),
toggle('similarities', 'Title Similarities')
.suggestedStatus('flagged')
.severity('medium')
.message()
.children(
group()
.title('Similarities Additional Info')
.multiSelect('options')
.children(
option('modpack', 'Modpack Named After Mod')
check('modpack', 'Modpack Named After Mod')
.shown(computed(() => project.value.project_types.includes('modpack')))
.message(),
option('fork', 'Forked Project')
check('fork', 'Forked Project')
.shown(computed(() => !project.value?.minecraft_server))
.message(),
),
@@ -241,100 +240,76 @@ export default function () {
.title('Slug Issues')
.shown(computed(() => hasCustomSlug(project.value)))
.children(
group()
.multiSelect('issues')
.children(
toggle('misused', 'Misused')
.children(
group()
.title('Correct Slug')
.children(
text('correct-slug')
.initial(() => resolvedAutoSlug.value ?? project.value.slug)
.onChange((value, { override }) => {
if (!value) return override(project.value.slug ?? '')
clearTimeout(slugDebounceTimer)
if (value === project.value.slug) {
slugValidation.value = 'unchanged'
return
group().children(
toggle('misused', 'Misused')
.children(
group()
.title('Correct Slug')
.children(
text('correct-slug')
.initial(() => resolvedAutoSlug.value ?? project.value.slug ?? '')
.onChange((value) => {
clearTimeout(slugDebounceTimer)
if (value === project.value.slug) {
slugValidation.value = 'unchanged'
return
}
if (!value) {
slugValidation.value = 'empty'
correctSlugConflict.value = null
return
}
if (generateUrlSlug(value) !== value) {
slugValidation.value = 'invalid'
correctSlugConflict.value = null
return
}
slugValidation.value = 'checking'
slugDebounceTimer = setTimeout(async () => {
const conflict = await checkSlugTaken(value).catch(() => null)
if (conflict !== null && conflict.id !== project.value.id) {
correctSlugConflict.value = conflict
slugValidation.value = 'taken'
} else {
correctSlugConflict.value = null
slugValidation.value = 'available'
}
slugValidation.value = 'checking'
slugDebounceTimer = setTimeout(async () => {
const conflict = await checkSlugTaken(value).catch(() => null)
if (conflict !== null && conflict.id !== project.value.id) {
correctSlugConflict.value = conflict
slugValidation.value = 'taken'
} else {
correctSlugConflict.value = null
slugValidation.value = 'available'
}
}, 400)
}),
}, 400)
})
.tweak(TagCategoryWandSparklesIcon, () =>
autoSlugStatus.value === 'available' ? resolvedAutoSlug.value : null,
)
.tweak(UserPlusIcon, (current) =>
ownerUsername.value && !current?.includes(ownerUsername.value)
? `${current}-${ownerUsername.value}`
: null,
)
.tweak(WrenchIcon, (current) => generateUrlSlug(current ?? ''))
.tweak(TagCategoryRefreshCcwIcon, () => project.value.slug),
button()
.icon(TagCategoryWandSparklesIcon)
.tooltip(computed(() => resolvedAutoSlug.value ?? ''))
.enabled(
(state) =>
autoSlugStatus.value === 'available' &&
resolvedAutoSlug.value !== null &&
resolvedAutoSlug.value !== currentSlug(state),
)
.onClick((state) => {
if (resolvedAutoSlug.value) state['correct-slug'] = resolvedAutoSlug.value
}),
button()
.icon(UserPlusIcon)
.tooltip((state) => {
const current = currentSlug(state)
if (!ownerUsername.value || current?.includes(ownerUsername.value))
return current ?? ''
return `${current}-${ownerUsername.value}`
})
.enabled(
(state) =>
ownerUsername.value !== null &&
!currentSlug(state)?.includes(ownerUsername.value),
)
.onClick((state) => {
state['correct-slug'] = `${currentSlug(state)}-${ownerUsername.value}`
}),
button()
.icon(TagCategoryRefreshCcwIcon)
.tooltip(computed(() => project.value.slug ?? ''))
.enabled((state) => currentSlug(state) !== project.value.slug)
.onClick((state) => {
state['correct-slug'] = project.value.slug
}),
SlugStatus,
),
)
.rawMessage(async (state) => {
let correct = ''
if (slugValidation.value === 'available') {
const slug = state['correct-slug'] as string | undefined
if (slug)
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
SUGGESTED_SLUG: slug,
}))(state)
}
return md('checklist/messages/title-slug/slug/misused', () => ({
CORRECT: correct,
}))(state)
})
.fix(
//TODO chyz think of some way to have initial values actually be reflected in state without having to store them
fix().project((patch, state) => {
const slug =
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value
if (!slug || slug === project.value.slug) return
patch.slug = slug
}),
),
),
SlugStatus,
),
)
.rawMessage(async (state) => {
let correct = ''
if (slugValidation.value === 'available') {
const slug = state['correct-slug'] as string | undefined
if (slug)
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
SUGGESTED_SLUG: slug,
}))(state)
}
return md('checklist/messages/title-slug/slug/misused', () => ({
CORRECT: correct,
}))(state)
})
.fix(
fix().project((patch, state) => {
if (slugValidation.value !== 'available') return
patch.slug = state['correct-slug'] as string
}),
),
),
),
)
}
@@ -19,38 +19,31 @@ export default function () {
group().children(
toggle('incorrect-additional-files', 'Incorrect additional files')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('incorrect-project-type', 'Incorrect Project Type')
.suggestedStatus('rejected')
.severity('medium')
.children(
dropdown('type')
.title('Correct Project Type')
.required()
.none('Unknown')
.children(
option('modpack', 'Modpack')
.shown(computed(() => !project.value.project_types.includes('modpack')))
.message(),
option('resourcepack', 'Resource Pack')
.shown(computed(() => !project.value.project_types.includes('resourcepack')))
.message(),
option('datapack', 'Data Pack')
.shown(computed(() => !project.value.loaders.includes('datapack')))
.message(),
.options(
option('modpack', 'Modpack').message(),
option('resourcepack', 'Resource Pack').message(),
option('datapack', 'Data Pack').message(),
),
)
.collect(),
toggle('alternate-versions', 'Alternate Versions')
.suggestedStatus('rejected')
.severity('high')
.children(
dropdown('distribution')
.title('Distribution Type')
.required()
.none('Unknown')
.children(
.options(
option('primary', 'Primary Files').message(),
option('additional', 'Additional Files').message(),
option('mono', 'Monofile')
@@ -78,7 +71,6 @@ export default function () {
toggle('vanilla-assets', 'Vanilla Assets')
.shown(computed(() => project.value.project_types.includes('resourcepack')))
.suggestedStatus('rejected')
.severity('medium')
.message(),
toggle('redist-libs', 'Packed Libs')
@@ -90,18 +82,15 @@ export default function () {
),
)
.suggestedStatus('rejected')
.severity('medium')
.message(),
toggle('duplicate-primary-files', 'Duplicate Primary Files')
.suggestedStatus('flagged')
.severity('medium')
.message(),
toggle('unsupported', 'Unsupported')
.suggestedStatus('rejected')
.severity('medium')
.message(undefined, (state) => ({
.message((state) => ({
INVALID_TYPE: state['invalid-type'],
}))
.children(text('invalid-type').title('Unsupported Type').required()),
+5 -1
View File
@@ -1,3 +1,4 @@
import keybinds from '../data/keybinds.ts'
import {
type KeybindDefinition,
type KeybindListener,
@@ -5,7 +6,6 @@ import {
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
@@ -58,6 +58,9 @@ export class Keybinds {
continue
}
// The scope check above guarantees ctx matches keybind's expected context shape,
// but TS can't correlate that narrowing across these two independently-typed variables.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
@@ -66,6 +69,7 @@ export class Keybinds {
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
+9 -5
View File
@@ -1,12 +1,12 @@
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
import { moderationSettings } from '../index.ts'
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly settings: { [id: string]: unknown }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
settings: { [id: string]: unknown } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
@@ -16,11 +16,15 @@ export class Settings {
get<T>(definition: SettingDefinitionBase<T>): T {
const value = this.settings[definition.id]
return (isValidFor(definition, value) ? value : undefined) ?? definition.default
return (
(isValidFor(definition as SettingDefinitionBase<unknown>, value)
? (value as T)
: undefined) ?? definition.default
)
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
const previous = (this.settings[definition.id] as T | undefined) ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
+5 -9
View File
@@ -1,6 +1,5 @@
export { default as checklist, stages, useStages } from './data/checklist'
export { useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -9,17 +8,14 @@ export {
type TechReviewContext,
default as techReviewQuickReplies,
} from './data/quick-replies/tech-review-quick-replies'
export { default as moderationSettings } from './data/settings'
export * from './handles/keybinds'
export * from './handles/settings'
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages'
export * from './types/nags'
export * from './types/node'
export * from './types/priority'
export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './types/settings'
export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
-298
View File
@@ -1,298 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { WeightedMessage } from './messages'
export type ActionType =
| 'button'
| 'dropdown'
| 'multi-select-chips'
| 'toggle'
| 'conditional-button'
export type Action =
| ButtonAction
| DropdownAction
| MultiSelectChipsAction
| ToggleAction
| ConditionalButtonAction
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface ChecklistActionContext {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v2.Version[] | null
}
export interface BaseAction {
/**
* The type of action, which determines how the action is presented to the moderator and what it does.
*/
type: ActionType
/**
* Any additional text data that is required to complete the action.
*/
relevantExtraInput?: AdditionalTextInput[]
/**
* Suggested moderation status when this action is selected.
*/
suggestedStatus?: ModerationStatus
/**
* Suggested severity level for this moderation action.
*/
severity?: ModerationSeverity
/**
* Actions that become available when this action is selected.
*/
enablesActions?: Action[]
/**
* Actions that become unavailable when this action is selected.
*/
disablesActions?: string[] // Array of action IDs
/**
* Unique identifier for this action, used for conditional logic.
*/
id?: string
/**
* A function that determines whether this action should be shown for a given project.
*
* By default, it returns `true`, meaning the action is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
/**
* Represents a conditional message that changes based on other selected actions.
*/
export interface ConditionalMessage extends WeightedMessage {
/**
* Conditions that must be met for this message to be used.
*/
conditions: {
/**
* Action IDs that must be selected for this message to apply.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this message to apply.
*/
excludedActions?: string[]
}
}
/**
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
*/
export interface ButtonAction extends BaseAction, WeightedMessage {
type: 'button'
/**
* The label of the button, which is displayed to the moderator. The text on the button.
*/
label: string
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a simple toggle/checkbox action with separate layout handling.
*/
export interface ToggleAction extends BaseAction, WeightedMessage {
type: 'toggle'
/**
* The label of the toggle, which is displayed to the moderator.
*/
label: string
/**
* Description text that appears below the toggle.
*/
description?: string
/**
* Whether the toggle is checked by default.
*/
defaultChecked?: boolean
/**
* Alternative messages based on other selected actions.
*/
conditionalMessages?: ConditionalMessage[]
}
/**
* Represents a button that has different behavior based on other selected actions.
*/
export interface ConditionalButtonAction extends BaseAction {
type: 'conditional-button'
/**
* The label of the button, which is displayed to the moderator.
*/
label: string
/**
* Different message configurations based on conditions.
*/
messageVariants: ConditionalMessage[]
/**
* Global fallback message if no variants match their conditions.
*/
fallbackMessage?: () => Promise<string>
/**
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
*/
fallbackWeight?: number
}
export interface DropdownActionOption extends WeightedMessage {
/**
* The label of the option, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
export interface DropdownAction extends BaseAction {
type: 'dropdown'
/**
* The label associated with the dropdown.
*/
label: string
/**
* The options available in the dropdown.
*/
options: DropdownActionOption[]
/**
* The default option selected in the dropdown, by index.
*/
defaultOption?: number
}
export interface MultiSelectChipsOption extends WeightedMessage {
/**
* Stable identifier for the option. If omitted, the label is used.
*/
id?: string
/**
* The label of the chip, which is displayed to the moderator.
*/
label: string
/**
* A function that determines whether this option should be shown for a given project.
*
* By default, it returns `true`, meaning the option is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3: Labrinth.Projects.v3.Project,
context?: ChecklistActionContext,
) => boolean
}
export type MultiSelectChipsOptionsResolver = (
context: ChecklistActionContext,
) => MultiSelectChipsOption[]
export interface MultiSelectChipsAction extends BaseAction {
type: 'multi-select-chips'
/**
* The label associated with the multi-select chips.
*/
label: string
/**
* The options available in the multi-select chips.
*/
options: MultiSelectChipsOption[] | MultiSelectChipsOptionsResolver
/**
* If set, all selected option messages are joined with this string and emitted as a single
* message part rather than individual parts. Useful for building bullet lists under a shared header.
*/
joinWith?: string
}
export interface AdditionalTextInput {
/**
* The label of the text input, which is displayed to the moderator.
*/
label: string
/**
* The placeholder text for the text input.
*/
placeholder?: string
/**
* Whether the text input is required to be filled out before the action can be completed.
*/
required?: boolean
/**
* Whether the text input should use the full markdown editor rather than a simple text input.
*/
large?: boolean
/**
* The variable name that will be replaced in the message with the input value.
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
* will be replaced with the input value.
*/
variable?: string
/**
* Conditions that determine when this input is shown.
*/
showWhen?: {
/**
* Action IDs that must be selected for this input to be shown.
*/
requiredActions?: string[]
/**
* Action IDs that must NOT be selected for this input to be shown.
*/
excludedActions?: string[]
}
/**
* Optional suggestions for the input. Useful for repeating phrases or common responses.
*/
suggestions?: string[]
}
-13
View File
@@ -1,13 +0,0 @@
export interface WeightedMessage {
/**
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
*/
weight: number
/**
* The message which is appended to the final moderation message if the button is active.
* @returns A function that lazily loads the message which is appended if the button is active.
* @example async () => (await import('../messages/example.md?raw')).default,
*/
message: () => Promise<string>
}
-880
View File
@@ -1,880 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue'
import { markRaw, toValue } from 'vue'
import {
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
} from '../utils'
import type { ModerationSeverity, ModerationStatus } from './actions'
import type { Priority } from './priority.ts'
// ─── State ────────────────────────────────────────────────────────────────────
export type NodeState =
| boolean
| string
| number
| Set<string>
| NodeStateWithChildren
| null
| undefined
export interface NodeStateWithChildren {
value?: NodeState
[childId: string]: NodeState
}
// ─── Function types ───────────────────────────────────────────────────────────
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
concat(...others: MessageFn[]): MessageFn
}
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
export type Reactive<T> = T | Ref<T>
export function resolve<T>(value: Reactive<T>): T {
return toValue(value as T | Ref<T>)
}
export type ChildNode = NodeBuilder | (() => unknown) | string
export type ChildEntry =
| NodeBuilder
| string
| (() => unknown)
| null
| Ref<NodeBuilder | null>
| ((state?: Record<string, NodeState>) => NodeBuilder | NodeBuilder[] | null)
export type NodeType =
| 'toggle'
| 'check'
| 'button'
| 'text'
| 'markdown'
| 'group'
| 'dropdown'
| 'option'
| 'stage'
// ─── Message helpers ──────────────────────────────────────────────────────────
const messageFiles = import.meta.glob('../data/messages/**/*.md', {
query: '?raw',
import: 'default',
})
export function mdOptional(
path: string,
getVars?: (state: Record<string, any>) => Record<string, any>,
): MessageFn {
return makeMessageFn(async (state) => {
const loader = messageFiles[`../data/messages/${path}.md`]
if (!loader) return ''
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
})
}
export function mdEscape(text: string): string {
return text.replace(/[\\*_`[~]/g, '\\$&')
}
const USER_CONTENT_KEYS = [
'PROJECT_TITLE',
'PROJECT_SLUG',
'PROJECT_SUMMARY',
'PROJECT_TYPE',
'PROJECT_STATUS',
]
export async function loadMd(
path: string,
state: Record<string, NodeState>,
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
getVars?: (state: Record<string, any>) => Record<string, any>,
): Promise<string> {
// Call getVars before any await so Vue's watchEffect tracks reactive reads inside it
const extraVars = getVars ? getVars(state) : null
const loader = messageFiles[`../data/messages/${path}.md`]
if (!loader) {
_onMissingMd?.(path)
return ''
}
const raw = (await loader()) as string
const vars: Record<string, string> = {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2),
...flattenProjectV3Variables(project),
}
for (const key of USER_CONTENT_KEYS) {
if (key in vars) vars[key] = mdEscape(vars[key])
}
if (extraVars) {
for (const [k, v] of Object.entries(extraVars)) {
vars[k] = String(v ?? '')
}
}
const expanded = expandVariables(raw, projectV2, project, vars)
// Code spans render literally — markdown escapes inside them show as-is, so strip them
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
}
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
const rich = fn as MessageFn
rich.concat = (...others) =>
makeMessageFn(async (state) =>
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
)
return rich
}
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
let _onMissingMd: ((path: string) => void) | null = null
export function setMissingMdHandler(handler: (path: string) => void) {
_onMissingMd = handler
}
export function setMessageProject(
project: Ref<Labrinth.Projects.v3.Project>,
projectV2: Ref<Labrinth.Projects.v2.Project>,
) {
_project = project
_projectV2 = projectV2
}
export function md(
path: string | ((state: Record<string, NodeState>) => string),
getVars?: (state: Record<string, any>) => Record<string, any>,
): MessageFn {
return makeMessageFn(async (state) => {
const resolvedPath = typeof path === 'function' ? path(state) : path
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
})
}
// ─── Fix builder ──────────────────────────────────────────────────────────────
export function createTrackedPatch<T extends object>(
source: T,
): { proxy: T; changes: () => Partial<T> } {
const written = new Set<string | symbol>()
const data = { ...source }
const proxy = new Proxy(data, {
set(target, key, value) {
if (value !== (source as Record<string | symbol, unknown>)[key]) {
written.add(key)
} else {
written.delete(key)
}
;(target as Record<string | symbol, unknown>)[key] = value
return true
},
})
return {
proxy,
changes: () =>
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
}
}
export class FixBuilder {
_projectFn?: (
patch: Labrinth.Projects.v3.EditProjectRequest,
state: Record<string, NodeState>,
) => void
_versionFn?: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void
project(
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
): this {
this._projectFn = fn
return this
}
version(
fn: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void,
): this {
this._versionFn = fn
return this
}
}
export function fix(): FixBuilder {
return new FixBuilder()
}
// ─── Message segments ─────────────────────────────────────────────────────────
type GetVarsFn = (state: Record<string, any>) => Record<string, any>
export type MessageSegment =
| { type: 'fn'; fn: ContentFn }
| { type: 'auto'; getVars?: GetVarsFn }
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
| { type: 'collect'; fallback?: MessageSegment }
export function resolveRelativeMessagePath(
messagePath: string | (() => string),
statePath: string[],
): string {
const name = typeof messagePath === 'function' ? messagePath() : messagePath
if (name.startsWith('/')) return `checklist/messages${name}`
const parts = [...statePath.slice(0, -1), ...name.split('/')]
const normalized = parts.reduce<string[]>((acc, p) => {
if (p === '..') acc.pop()
else if (p) acc.push(p)
return acc
}, [])
return `checklist/messages/${normalized.join('/')}`
}
export async function evalSegment(
seg: MessageSegment,
state: Record<string, NodeState>,
statePath: string[],
): Promise<string> {
if (seg.type === 'collect') return ''
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
if (seg.type === 'auto')
return loadMd(
`checklist/messages/${statePath.join('/')}`,
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
return loadMd(
resolveRelativeMessagePath(seg.path, statePath),
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
// ─── Node builders ────────────────────────────────────────────────────────────
export abstract class NodeBuilder {
abstract readonly type: NodeType
_shown?: Reactive<boolean>
_title?: Reactive<string>
_icon?: FunctionalComponent<SVGAttributes>
_tooltip?: Reactive<string> | ((state: Record<string, NodeState>) => string)
shown(condition: Reactive<boolean>): this {
this._shown = condition
return this
}
title(t: Reactive<string>): this {
this._title = t
return this
}
icon(i: FunctionalComponent<SVGAttributes>): this {
this._icon = markRaw(i)
return this
}
tooltip(t: Reactive<string> | ((state: Record<string, NodeState>) => string)): this {
this._tooltip = t
return this
}
}
export class ButtonNodeBuilder extends NodeBuilder {
readonly type = 'button' as const
readonly label?: string
_onClick?: (state: Record<string, NodeState>) => void
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
constructor(nodeLabel?: string) {
super()
this.label = nodeLabel
}
onClick(fn: (state: Record<string, NodeState>) => void): this {
this._onClick = fn
return this
}
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
this._enabled = condition
return this
}
}
export abstract class IdentifiedNodeBuilder extends NodeBuilder {
readonly id: string | undefined
_children: ChildEntry[] = []
_childrenFn?: ChildrenFn
_computingChildren = false
_segments: MessageSegment[] = []
_suggestedStatus?: ModerationStatus
_severity?: ModerationSeverity
_priority?: Priority
_fixes: FixBuilder[] = []
_applyFixes = false
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
_statePath?: string[]
constructor(id: string | undefined) {
super()
this.id = id
}
children(fn: ChildrenFn): this
children(...entries: ChildEntry[]): this
children(...args: [ChildrenFn] | ChildEntry[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
this._childrenFn = args[0] as ChildrenFn
} else {
this._children.push(...(args as ChildEntry[]))
}
return this
}
message(path?: string | (() => string), getVars?: GetVarsFn): this {
const segment: MessageSegment =
path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
this._segments.push(segment)
return this
}
rawMessage(content: string | ContentFn): this {
const fn: ContentFn = typeof content === 'string' ? () => content : content
this._segments.push({ type: 'fn', fn })
return this
}
collect(path?: string | (() => string), getVars?: GetVarsFn): this {
const fallback =
path !== undefined || getVars !== undefined
? path === undefined
? { type: 'auto' as const, ...(getVars && { getVars }) }
: { type: 'path' as const, path, ...(getVars && { getVars }) }
: undefined
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
return this
}
suggestedStatus(s: ModerationStatus): this {
this._suggestedStatus = s
return this
}
severity(s: ModerationSeverity): this {
this._severity = s
return this
}
priority(p: Priority): this {
this._priority = p
return this
}
fix(f: FixBuilder): this {
this._fixes.push(f)
return this
}
applyFixes(): this {
this._applyFixes = true
return this
}
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
this._enabled = condition
return this
}
statePath(path: string[]): this {
this._statePath = path
return this
}
}
export abstract class LabeledNodeBuilder extends IdentifiedNodeBuilder {
readonly label: string
constructor(id: string, label: string) {
super(id)
this.label = label
}
}
export abstract class ValueNodeBuilder extends LabeledNodeBuilder {
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
_required?: boolean
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
this._defaultValue = v
return this
}
required(v = true): this {
this._required = v
return this
}
}
export class BooleanNodeBuilder extends ValueNodeBuilder {
readonly type: 'toggle' | 'check'
constructor(id: string, nodeLabel: string, type: 'toggle' | 'check') {
super(id, nodeLabel)
this.type = type
}
}
export type OverrideValue = { readonly __override: string }
export type OnChangeFn = (
value: string,
helpers: { override: (value: string) => OverrideValue },
) => OverrideValue | void
export class InputNodeBuilder extends IdentifiedNodeBuilder {
readonly type: 'text' | 'markdown'
_placeholder?: Reactive<string>
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
_required?: boolean
_onChange?: OnChangeFn
constructor(id: string, type: 'text' | 'markdown') {
super(id)
this.type = type
}
placeholder(p: Reactive<string>): this {
this._placeholder = p
return this
}
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
this._defaultValue = v
return this
}
required(v = true): this {
this._required = v
return this
}
onChange(fn: OnChangeFn): this {
this._onChange = fn
return this
}
}
export class GroupNodeBuilder extends IdentifiedNodeBuilder {
readonly type = 'group' as const
_layout?: 'flex' | 'column'
_required?: boolean
_selectMode?: 'single' | 'multi'
_selectId?: string
layout(l: 'flex' | 'column'): this {
this._layout = l
return this
}
required(v = true): this {
this._required = v
return this
}
singleSelect(id?: string): this {
this._selectMode = 'single'
if (id !== undefined) this._selectId = id
return this
}
multiSelect(id?: string): this {
this._selectMode = 'multi'
if (id !== undefined) this._selectId = id
return this
}
}
export class DropdownNodeBuilder extends IdentifiedNodeBuilder {
readonly type = 'dropdown' as const
_none?: string
override children(fn: ChildrenFn): this
override children(...nodes: OptionNodeBuilder[]): this
override children(...args: [ChildrenFn] | OptionNodeBuilder[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
super.children(args[0] as ChildrenFn)
} else {
super.children(...(args as OptionNodeBuilder[]))
}
return this
}
none(text: string): this {
this._none = text
return this
}
}
export class OptionNodeBuilder extends LabeledNodeBuilder {
readonly type = 'option' as const
}
export class StageNodeBuilder extends LabeledNodeBuilder {
readonly type = 'stage' as const
_hint?: string
_guidanceUrl?: string
_icon?: FunctionalComponent<SVGAttributes>
_navigate?: string
hint(h: string): this {
this._hint = h
return this
}
guidance(url: string): this {
this._guidanceUrl = url
return this
}
icon(i: FunctionalComponent<SVGAttributes>): this {
this._icon = markRaw(i)
return this
}
navigate(path: string): this {
this._navigate = path
return this
}
override children(fn: ChildrenFn): this
override children(...entries: ChildEntry[]): this
override children(...args: [ChildrenFn] | ChildEntry[]): this {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
super.children(args[0] as ChildrenFn)
if (!this._statePath) this._statePath = [this.id!]
return this
}
const entries = args as ChildEntry[]
super.children(...entries)
if (!this._statePath) this._statePath = [this.id!]
stampChildPaths(entries, [this.id!])
return this
}
}
export const STAGES_KEY: InjectionKey<Ref<StageNodeBuilder[]>> = Symbol('checklistStages')
export const GLOBAL_STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistGlobalState')
// ─── Node traversal ───────────────────────────────────────────────────────────
function childrenScopePath(node: IdentifiedNodeBuilder): string[] | null {
if (!node._statePath) return null
switch (node.type) {
case 'toggle':
case 'check':
case 'option':
case 'stage':
return node._statePath
case 'group': {
const g = node as GroupNodeBuilder
if (g._selectMode) {
// Option ids become namespaces at the same level as the group
return node._statePath.slice(0, -1)
}
return node._statePath
}
case 'dropdown':
// Option ids become namespaces at the same level as the dropdown
return node._statePath.slice(0, -1)
default:
return null
}
}
function stampChildPaths(entries: ChildEntry[], scopePath: string[]): void {
for (const entry of entries) {
// Skip reactive entries (refs, functions, nulls) — they get stamped on resolution
if (!(entry instanceof NodeBuilder)) continue
if (!(entry instanceof IdentifiedNodeBuilder)) continue
const selectId = entry instanceof GroupNodeBuilder ? entry._selectId : undefined
if (!entry.id && !selectId) {
// Truly transparent: no structural id and no select id
stampChildPaths(entry._children, scopePath)
continue
}
if (!entry._statePath) {
// Structural id contributes to path first, then select id
const pathComponents = entry.id
? selectId
? [...scopePath, entry.id, selectId]
: [...scopePath, entry.id]
: [...scopePath, selectId!]
entry._statePath = pathComponents
}
const childScope = childrenScopePath(entry)
if (childScope) stampChildPaths(entry._children, childScope)
}
}
export function isNodeActive(node: NodeBuilder, state: NodeState): boolean {
switch (node.type) {
case 'toggle':
case 'check':
case 'option': {
if (typeof state === 'boolean') return state
if (state && typeof state === 'object' && !(state instanceof Set)) {
const v = (state as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
return (node as BooleanNodeBuilder)._defaultValue === true
}
case 'group': {
const g = node as GroupNodeBuilder
if (g._selectMode === 'single') return typeof state === 'string' && state !== ''
if (g._selectMode === 'multi') return state instanceof Set && state.size > 0
return false
}
case 'dropdown':
return typeof state === 'string' && state !== ''
case 'text':
case 'markdown':
return typeof state === 'string' && state !== ''
default:
return false
}
}
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
return nodeState as Record<string, NodeState>
}
return {}
}
export function resolveChildren(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): ChildNode[] {
let entries: ChildEntry[]
let stampStatic = false
if (node._childrenFn) {
if (node._computingChildren) return []
node._computingChildren = true
try {
entries = node._childrenFn(state)
stampStatic = true // childrenFn results haven't been pre-stamped
} finally {
node._computingChildren = false
}
} else {
entries = node._children
}
const scopePath = childrenScopePath(node)
const result: ChildNode[] = []
for (const entry of entries) {
if (entry == null) continue
if (entry instanceof NodeBuilder) {
if (entry._shown !== undefined && !resolve(entry._shown)) continue
if (stampStatic && scopePath) stampChildPaths([entry], scopePath)
result.push(entry)
continue
}
if (typeof entry === 'string') {
result.push(entry)
continue
}
if (typeof entry === 'function') {
if (entry.length === 0) {
result.push(entry as () => unknown)
continue
}
const resolved = entry(state)
if (resolved == null) continue
if (Array.isArray(resolved)) {
for (const r of resolved) {
if (r == null) continue
if (r._shown !== undefined && !resolve(r._shown)) continue
if (scopePath) stampChildPaths([r], scopePath)
result.push(r)
}
continue
}
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
if (scopePath) stampChildPaths([resolved], scopePath)
result.push(resolved)
continue
}
// Ref<NodeBuilder | null>
const resolved = toValue(entry as Ref<NodeBuilder | null>)
if (resolved == null) continue
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
if (scopePath) stampChildPaths([resolved], scopePath)
result.push(resolved)
}
return result
}
export function walkNodes(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
visitor: (
node: IdentifiedNodeBuilder,
nodeState: NodeState,
localState: Record<string, NodeState>,
) => void,
): void {
for (const node of nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node._shown !== undefined && !resolve(node._shown)) continue
if (node.type === 'stage') {
const identified = node as IdentifiedNodeBuilder
if (identified.id) {
const raw = stageState[identified.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(identified, childState), childState, visitor)
} else {
walkNodes(resolveChildren(identified, stageState), stageState, visitor)
}
continue
}
if (node.type === 'group') {
const g = node as GroupNodeBuilder
if (!g._selectMode) {
// Plain container: traverse children in group's own state scope
if (g.id) {
const raw = stageState[g.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(g, childState), childState, visitor)
} else {
walkNodes(resolveChildren(g, stageState), stageState, visitor)
}
continue
}
// Fall through to value-node path for groups with selectMode
}
if (node.type === 'button') continue
const identified = node as IdentifiedNodeBuilder
const stateKey =
node.type === 'group'
? ((node as GroupNodeBuilder)._selectId ?? identified.id!)
: identified.id!
const nodeState = stageState[stateKey]
visitor(identified, nodeState, stageState)
const active = isNodeActive(node, nodeState)
const children = resolveChildren(identified, stageState)
if (children.length === 0 || !active) continue
if (node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'multi') {
const selected = nodeState instanceof Set ? nodeState : new Set<string>()
for (const child of children) {
const childId = child as IdentifiedNodeBuilder
if (!selected.has(childId.id!) || (child._shown !== undefined && !resolve(child._shown)))
continue
const rawChildState = stageState[childId.id!]
// Option children are active by virtue of being in the selected Set, not by their own
// boolean state. If they have child state but no explicit value, inject value: true
// so isNodeActive returns true even after child state has been written to their path.
const childState: NodeState =
rawChildState !== null &&
rawChildState !== undefined &&
typeof rawChildState === 'object' &&
!(rawChildState instanceof Set) &&
(rawChildState as NodeStateWithChildren).value === undefined
? { ...(rawChildState as NodeStateWithChildren), value: true }
: (rawChildState ?? true)
visitor(childId, childState, stageState)
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
}
} else if (node.type === 'toggle' || node.type === 'check') {
const childState = getBooleanChildState(nodeState)
walkNodes(children, childState, visitor)
} else if (
(node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'single') ||
node.type === 'dropdown'
) {
const selectedId = typeof nodeState === 'string' ? nodeState : undefined
if (selectedId) {
for (const child of children) {
const childId = child as IdentifiedNodeBuilder
if (childId.id !== selectedId || (child._shown !== undefined && !resolve(child._shown)))
continue
const rawChildState = stageState[childId.id!]
const childState: NodeState =
rawChildState !== null &&
rawChildState !== undefined &&
typeof rawChildState === 'object' &&
!(rawChildState instanceof Set) &&
(rawChildState as NodeStateWithChildren).value === undefined
? { ...(rawChildState as NodeStateWithChildren), value: true }
: (rawChildState ?? true)
visitor(childId, childState, stageState)
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
break
}
}
} else {
walkNodes(children, stageState, visitor)
}
}
}
// ─── Factory functions ────────────────────────────────────────────────────────
export type StageFn = (
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
) => StageNodeBuilder
export function stageFn(factory: StageFn): StageFn {
const cache = new WeakMap<object, StageNodeBuilder>()
return (project, projectV2) => {
if (!cache.has(project)) cache.set(project, factory(project, projectV2))
return cache.get(project)!
}
}
export const toggle = (id: string, nodeLabel: string) =>
new BooleanNodeBuilder(id, nodeLabel, 'toggle')
export const check = (id: string, nodeLabel: string) =>
new BooleanNodeBuilder(id, nodeLabel, 'check')
export const button = (nodeLabel?: string) => new ButtonNodeBuilder(nodeLabel)
export const text = (id: string) => new InputNodeBuilder(id, 'text')
export const markdown = (id: string) => new InputNodeBuilder(id, 'markdown')
export const group = (id?: string) => new GroupNodeBuilder(id)
export const dropdown = (id: string) => new DropdownNodeBuilder(id)
export const option = (id: string, nodeLabel: string) => new OptionNodeBuilder(id, nodeLabel)
export const stage = (id: string, title: string) => new StageNodeBuilder(id, title)
@@ -0,0 +1,56 @@
import type { Ref } from 'vue'
import type { Configurable, Visible } from './capabilities'
import type { NodeState } from './state'
export type AnyNode = Visible
export type ChildNode = AnyNode | (() => unknown) | string
export type ChildEntry =
| AnyNode
| string
| (() => unknown)
| null
| Ref<AnyNode | null>
| ((state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null)
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
export interface HasChildren {
_children: ChildEntry[]
_childrenFn?: ChildrenFn
children(this: this, fn: ChildrenFn): this
children(this: this, ...entries: ChildEntry[]): this
}
export function withChildren<T extends object>(node: T): T & HasChildren {
return Object.assign(node, {
_children: [] as ChildEntry[],
children(this: HasChildren, ...args: [ChildrenFn] | ChildEntry[]) {
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
this._childrenFn = args[0] as ChildrenFn
} else {
this._children.push(...(args as ChildEntry[]))
}
return this
},
})
}
export function withAutoProps<T extends Configurable>(
node: T,
): T & { [key: string]: (value: unknown) => T } {
return new Proxy(node, {
get(target, prop, receiver) {
if (typeof prop === 'string' && !prop.startsWith('_') && !(prop in target)) {
return (value: unknown) => {
const prev = target._extraProps
target._extraProps = (ctx) => ({ ...prev?.(ctx), [prop]: value })
return receiver
}
}
return Reflect.get(target, prop, receiver)
},
}) as T & { [key: string]: (value: unknown) => T }
}
@@ -0,0 +1,449 @@
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
import { markRaw } from 'vue'
import { Priority } from '../priority.ts'
import type { FixBuilder } from './fix'
import type { GetVarsFn, MessageSegment, ModerationStatus, NodeState, Reactive } from './state'
export interface Visible {
_shown: Reactive<boolean> | undefined
shown(this: this, condition: Reactive<boolean>): this
}
export function withShown<T extends object>(node: T): T & Visible {
return Object.assign(node, {
_shown: undefined as Reactive<boolean> | undefined,
shown(this: Visible, condition: Reactive<boolean>) {
this._shown = condition
return this
},
})
}
export interface Iconable {
_icon: FunctionalComponent<SVGAttributes> | undefined
icon(this: this, icon: FunctionalComponent<SVGAttributes> | undefined): this
}
export function withIcon<T extends object>(node: T): T & Iconable {
return Object.assign(node, {
_icon: undefined as FunctionalComponent<SVGAttributes> | undefined,
icon(this: Iconable, icon: FunctionalComponent<SVGAttributes> | undefined) {
this._icon = icon ? markRaw(icon) : undefined
return this
},
})
}
export interface Tooltippable {
_tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined
tooltip(
this: this,
tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined,
): this
}
export function withTooltip<T extends object>(node: T): T & Tooltippable {
return Object.assign(node, {
_tooltip: undefined as Tooltippable['_tooltip'],
tooltip(this: Tooltippable, tooltip: Tooltippable['_tooltip']) {
this._tooltip = tooltip
return this
},
})
}
export interface Titled {
_title: Reactive<string> | undefined
title(this: this, title: Reactive<string>): this
}
export function withTitle<T extends object>(node: T): T & Titled {
return Object.assign(node, {
_title: undefined as Reactive<string> | undefined,
title(this: Titled, title: Reactive<string>) {
this._title = title
return this
},
})
}
export interface Identified {
id: string
_statePath?: string[]
statePath(this: this, path: string[]): this
}
export function withId<T extends object>(node: T, id: string): T & Identified {
return Object.assign(node, {
id,
statePath(this: Identified, path: string[]) {
this._statePath = path
return this
},
})
}
export interface Prioritizable {
_priority: Priority
priority(this: this, p: Priority): this
}
export function withPriority<T extends object>(node: T): T & Prioritizable {
return Object.assign(node, {
_priority: new Priority(),
priority(this: Prioritizable, p: Priority) {
this._priority = p
return this
},
})
}
export interface Messageable {
_segments: MessageSegment[]
_suggestedStatus?: ModerationStatus
suggestedStatus(this: this, status: ModerationStatus): this
message(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
rawMessage(
this: this,
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
): this
collect(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
}
function splitPathAndVars(
pathOrGetVars: string | (() => string) | GetVarsFn | undefined,
getVars: GetVarsFn | undefined,
): { path: string | (() => string) | undefined; getVars: GetVarsFn | undefined } {
if (typeof pathOrGetVars === 'function' && pathOrGetVars.length >= 1) {
return { path: undefined, getVars: pathOrGetVars as GetVarsFn }
}
return { path: pathOrGetVars as string | (() => string) | undefined, getVars }
}
export function withMessaging<T extends object>(node: T): T & Messageable {
return Object.assign(node, {
_segments: [] as MessageSegment[],
suggestedStatus(this: Messageable, status: ModerationStatus) {
this._suggestedStatus = status
return this
},
message(
this: Messageable,
pathOrGetVars?: string | (() => string) | GetVarsFn,
getVarsArg?: GetVarsFn,
) {
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
const segment: MessageSegment =
path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
this._segments.push(segment)
return this
},
rawMessage(
this: Messageable,
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
) {
const fn = typeof content === 'string' ? () => content : content
this._segments.push({ type: 'fn', fn })
return this
},
collect(
this: Messageable,
pathOrGetVars?: string | (() => string) | GetVarsFn,
getVarsArg?: GetVarsFn,
) {
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
const fallback: MessageSegment | undefined =
path !== undefined || getVars !== undefined
? path === undefined
? { type: 'auto', ...(getVars && { getVars }) }
: { type: 'path', path, ...(getVars && { getVars }) }
: undefined
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
return this
},
})
}
export interface Requireable {
_required: boolean | undefined
required(this: this, v?: boolean): this
}
export function withRequired<T extends object>(node: T): T & Requireable {
return Object.assign(node, {
_required: undefined as boolean | undefined,
required(this: Requireable, v = true) {
this._required = v
return this
},
})
}
export interface Fixable {
_fixes: FixBuilder[]
_applyFixes?: boolean
fix(this: this, f: FixBuilder): this
applyFixes(this: this): this
}
export function withFix<T extends object>(node: T): T & Fixable {
return Object.assign(node, {
_fixes: [] as FixBuilder[],
fix(this: Fixable, f: FixBuilder) {
this._fixes.push(f)
return this
},
applyFixes(this: Fixable) {
this._applyFixes = true
return this
},
})
}
export interface Enableable {
_enabled: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean) | undefined
enabled(this: this, condition: Enableable['_enabled']): this
}
export function withEnabled<T extends object>(node: T): T & Enableable {
return Object.assign(node, {
_enabled: undefined as Enableable['_enabled'],
enabled(this: Enableable, condition: Enableable['_enabled']) {
this._enabled = condition
return this
},
})
}
export interface HasValue<V = unknown> {
_defaultValue?: V | ((state: Record<string, NodeState>) => V)
_getValue: (raw: NodeState) => V
_setValue: (raw: NodeState, next: V, isDefault?: boolean) => NodeState
_isActive: (value: V) => boolean
initial(this: this, v: V | ((state: Record<string, NodeState>) => V)): this
}
export function withValue<T extends object, V>(
node: T,
behavior: Pick<HasValue<V>, '_getValue' | '_setValue' | '_isActive'>,
): T & HasValue<V> {
return Object.assign(node, {
...behavior,
initial(this: HasValue<V>, v: V | ((state: Record<string, NodeState>) => V)) {
this._defaultValue = v
return this
},
})
}
export interface Clickable {
_onClick: ((state: Record<string, NodeState>) => void) | undefined
onClick(this: this, fn: Clickable['_onClick']): this
}
export function withOnClick<T extends object>(node: T): T & Clickable {
return Object.assign(node, {
_onClick: undefined as Clickable['_onClick'],
onClick(this: Clickable, fn: Clickable['_onClick']) {
this._onClick = fn
return this
},
})
}
export interface ComponentNodePropsContext {
onImageUpload?: (file: File) => Promise<string>
toggleSetValue?: (value: string) => void
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
tooltip?: Record<string, unknown>
}
export interface Renderable {
_component: Component | undefined
_rendererKey: string | undefined
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_modelProp: string
}
export function withComponent<T extends object>(
node: T,
opts: {
component?: Component
rendererKey?: string
modelProp?: string
componentProps?: Renderable['_componentProps']
},
): T & Renderable {
return Object.assign(node, {
_component: opts.component,
_rendererKey: opts.rendererKey,
_componentProps: opts.componentProps,
_modelProp: opts.modelProp ?? 'modelValue',
})
}
export interface Configurable {
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
}
export function withExtraProps<T extends object>(node: T): T & Configurable {
return Object.assign(node, {
_extraProps: undefined as Configurable['_extraProps'],
})
}
export interface HasNoneLabel {
_none: string | undefined
none(this: this, text: string): this
}
export function withNoneLabel<T extends object>(node: T): T & HasNoneLabel {
return Object.assign(node, {
_none: undefined as string | undefined,
none(this: HasNoneLabel, text: string) {
this._none = text
return this
},
})
}
export interface Layoutable {
_layout: 'flex' | 'column' | undefined
layout(this: this, value: 'flex' | 'column'): this
}
export function withLayout<T extends object>(node: T): T & Layoutable {
return Object.assign(node, {
_layout: undefined as 'flex' | 'column' | undefined,
layout(this: Layoutable, value: 'flex' | 'column') {
this._layout = value
return this
},
})
}
export interface Selectable {
_exclusive: boolean | undefined
singleSelect(this: this): this
}
export function withSelectable<T extends object>(node: T): T & Selectable {
return Object.assign(node, {
_exclusive: undefined as boolean | undefined,
singleSelect(this: Selectable) {
this._exclusive = true
return this
},
})
}
export interface StageMeta {
_hint: string | undefined
_guidanceUrl: string | undefined
_navigate: string | undefined
_shownSticky: boolean | undefined
hint(this: this, hint: string): this
guidance(this: this, url: string): this
navigate(this: this, path?: string): this
sticky(this: this): this
}
export function withStageMeta<T extends object>(node: T): T & StageMeta {
return Object.assign(node, {
_hint: undefined as string | undefined,
_guidanceUrl: undefined as string | undefined,
_navigate: undefined as string | undefined,
_shownSticky: undefined as boolean | undefined,
hint(this: StageMeta, hint: string) {
this._hint = hint
return this
},
guidance(this: StageMeta, url: string) {
this._guidanceUrl = url
return this
},
navigate(this: StageMeta, path?: string) {
this._navigate = path ?? ''
return this
},
sticky(this: StageMeta) {
this._shownSticky = true
return this
},
})
}
export type OverrideValue = { readonly __override: string }
export type OnChangeFn = (
value: string,
helpers: { override: (value: string) => OverrideValue },
) => OverrideValue | void
export interface Editable {
_placeholder: Reactive<string> | undefined
_onChange: OnChangeFn | undefined
_showTooltip: boolean | undefined
_imperativeSync: boolean | undefined
placeholder(this: this, p: Reactive<string>): this
onChange(this: this, fn: OnChangeFn): this
}
export function withEditable<T extends object>(node: T): T & Editable {
return Object.assign(node, {
_placeholder: undefined as Reactive<string> | undefined,
_onChange: undefined as OnChangeFn | undefined,
_showTooltip: undefined as boolean | undefined,
_imperativeSync: undefined as boolean | undefined,
placeholder(this: Editable, p: Reactive<string>) {
this._placeholder = p
return this
},
onChange(this: Editable, fn: OnChangeFn) {
this._onChange = fn
return this
},
})
}
export interface TweakDef<V = unknown> {
icon: FunctionalComponent<SVGAttributes>
compute: (current: V, state: Record<string, NodeState>) => V | null | undefined
}
export interface Tweakable<V = unknown> {
_tweaks: TweakDef<V>[]
tweak(this: this, icon: FunctionalComponent<SVGAttributes>, compute: TweakDef<V>['compute']): this
}
export function withTweak<T extends HasValue<V>, V>(node: T): T & Tweakable<V> {
return Object.assign(node, {
_tweaks: [] as TweakDef<V>[],
tweak(
this: Tweakable<V>,
icon: FunctionalComponent<SVGAttributes>,
compute: TweakDef<V>['compute'],
) {
this._tweaks.push({ icon: markRaw(icon), compute })
return this
},
})
}
export interface StateOrigin {
_stateOrigin: string[] | undefined
stateOrigin(this: this, path: string[]): this
}
export function withStateOrigin<T extends object>(node: T): T & StateOrigin {
return Object.assign(node, {
_stateOrigin: undefined as string[] | undefined,
stateOrigin(this: StateOrigin, path: string[]) {
this._stateOrigin = path
return this
},
})
}
@@ -0,0 +1,97 @@
import type { ChildNode } from './builder'
import { evalSegment } from './messages'
import { hasCap, isNodeActive, resolveActionState, walkNodes } from './resolve'
import type { MessageSegment, NodeState } from './state'
export interface ActiveAction {
node: object
state: Record<string, NodeState>
statePath: string[]
active: boolean
}
export function collectActiveActions(
children: ChildNode[],
stageState: Record<string, NodeState>,
basePath: string[] = [],
): ActiveAction[] {
const actions: ActiveAction[] = []
walkNodes(
children,
stageState,
(node, nodeState, localState, path) => {
if (!hasCap(node, '_segments')) return
const segments = node._segments as MessageSegment[]
if (segments.length === 0) return
if (!isNodeActive(node, nodeState, localState)) return
actions.push({
node,
state: resolveActionState(node, nodeState, localState),
statePath: path,
active: true,
})
},
basePath,
)
return actions
}
export function collectMessageNodes(
children: ChildNode[],
stageState: Record<string, NodeState>,
basePath: string[] = [],
): ActiveAction[] {
const actions: ActiveAction[] = []
walkNodes(
children,
stageState,
(node, nodeState, localState, path) => {
if (!hasCap(node, '_segments')) return
const segments = node._segments as MessageSegment[]
if (segments.length === 0) return
const active = isNodeActive(node, nodeState, localState)
actions.push({
node,
state: resolveActionState(node, nodeState, localState),
statePath: path,
active,
})
},
basePath,
)
return actions
}
function isDescendant(childPath: string[], ancestorPath: string[]): boolean {
return (
childPath.length > ancestorPath.length && ancestorPath.every((key, i) => childPath[i] === key)
)
}
export async function evalActiveAction(
entry: ActiveAction,
allActions: ActiveAction[],
consumed: Set<object>,
): Promise<string> {
let result = ''
const segments = (entry.node as { _segments: MessageSegment[] })._segments
for (const seg of segments) {
if (seg.type === 'collect') {
let collected = ''
for (const child of allActions) {
if (consumed.has(child.node)) continue
if (!isDescendant(child.statePath, entry.statePath)) continue
consumed.add(child.node)
if (!child.active) continue
collected += await evalActiveAction(child, allActions, consumed)
}
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, entry.state, entry.statePath)
}
result += collected
} else {
result += await evalSegment(seg, entry.state, entry.statePath)
}
}
return result
}
@@ -0,0 +1,39 @@
<template>
<Button
v-tooltip="tooltip"
:type="color === 'standard' ? 'base' : 'colored'"
:color="color === 'standard' ? undefined : color"
:disabled="disabled"
:aria-label="icon ? label : undefined"
@click="emit('update:modelValue', !modelValue)"
>
<component :is="icon" v-if="icon" />
<template v-else>{{ label }}</template>
</Button>
</template>
<script lang="ts" setup>
import { Button } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: boolean
label?: string
icon?: Component
disabled?: boolean
needsAttention?: boolean
fixActionable?: boolean
tooltip?: Record<string, unknown>
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const color = computed(() => {
if (!props.modelValue) return 'standard'
if (props.needsAttention) return 'orange'
return props.fixActionable ? 'blue' : 'brand'
})
</script>
@@ -0,0 +1,401 @@
<script lang="ts" setup>
import { Button, IconButton } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed, inject, watchEffect } from 'vue'
import type { AnyNode, ChildNode, HasChildren } from '../builder'
import type {
ComponentNodePropsContext,
Enableable,
HasValue,
Identified,
OnChangeFn,
TweakDef,
} from '../capabilities'
import { CHECKLIST_META_KEY } from '../context'
import type { Writer } from '../mutate'
import { childWriter, originScope, writeNodeValue } from '../mutate'
import {
getBooleanChildState,
getEffectiveValue,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveChildren,
withStateDefaults,
} from '../resolve'
import type { NodeState, Reactive } from '../state'
import { resolve } from '../state'
import ActionButton from './ActionButton.vue'
const metaCtx = inject(CHECKLIST_META_KEY)
const props = defineProps<{
nodes: ChildNode[]
state: Record<string, NodeState>
write: Writer
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
appComponents?: Record<string, Component>
globalState?: Record<string, Record<string, NodeState>>
}>()
type RenderableValueNode = AnyNode &
HasValue &
Identified &
Partial<Enableable> & {
_component: Component | undefined
_rendererKey: string | undefined
_modelProp: string
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_tweaks?: TweakDef[]
}
function resolveComponent(node: RenderableValueNode): Component | undefined {
return (
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
)
}
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function getTitle(node: object): string | undefined {
if (!hasCap(node, '_title')) return undefined
const title = node._title as Reactive<string> | undefined
if (title === undefined) return undefined
return resolve(title) || undefined
}
function needsAttention(node: object): boolean {
return metaCtx?.value.attentionMap.get(node) ?? false
}
function isFixActionable(node: object): boolean {
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
}
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
function isEnabled(node: Partial<Enableable>): boolean {
if (node._enabled === undefined) return true
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
return resolve(node._enabled)
}
function toggleSetValue(node: RenderableValueNode, value: string): void {
const current = getEffectiveValue(
node,
props.state[node.id],
wrappedState.value,
) as unknown as string[]
const set = new Set(Array.isArray(current) ? current : [])
if (set.has(value)) set.delete(value)
else set.add(value)
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
}
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
let measureEl: HTMLSpanElement | null = null
function measureLabelWidth(label: string): number {
if (typeof document === 'undefined') return 0
if (!measureEl) {
measureEl = document.createElement('span')
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
Object.assign(measureEl.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'nowrap',
left: '-9999px',
top: '0',
})
document.body.appendChild(measureEl)
}
measureEl.textContent = label
return measureEl.getBoundingClientRect().width
}
const dropdownMinWidthCache = new Map<string, string>()
function getDropdownMinWidth(options: { label: string }[]): string {
const key = options.map((o) => o.label).join(' ')
const cached = dropdownMinWidthCache.get(key)
if (cached) return cached
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
dropdownMinWidthCache.set(key, result)
return result
}
function componentProps(node: RenderableValueNode): Record<string, unknown> {
const ctx: ComponentNodePropsContext = {
onImageUpload: props.onImageUpload,
toggleSetValue: (value) => toggleSetValue(node, value),
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
tooltip: resolveTooltip(node),
}
const dropdownStyle = hasOptionsCap(node)
? {
class: '!w-auto max-w-full',
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
}
: undefined
return {
disabled: !isEnabled(node),
...dropdownStyle,
...node._componentProps?.(ctx),
...node._extraProps?.(ctx),
}
}
function containerScope(node: HasChildren & Partial<Identified>): {
state: Record<string, NodeState>
write: Writer
} {
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
return originScope(props.globalState, node._stateOrigin as string[])
}
if (!hasIdCap(node)) return { state: props.state, write: props.write }
const raw = props.state[node.id]
const state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
return { state, write: childWriter(props.state, props.write, node.id) }
}
function valueScope(node: HasValue & Identified): {
state: Record<string, NodeState>
write: Writer
} {
const state = getBooleanChildState(props.state[node.id])
return { state, write: childWriter(props.state, props.write, node.id) }
}
const TOOLTIP_BASE = {
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
function resolveTooltip(node: object): Record<string, unknown> | undefined {
if (hasCap(node, '_tooltip')) {
const t = node._tooltip as
| Reactive<string>
| ((state: Record<string, NodeState>) => string)
| undefined
if (t !== undefined) {
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
if (content) return { ...TOOLTIP_BASE, content }
}
}
const hasSegments = hasCap(node, '_segments')
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
}
function clickButton(node: object): void {
if (!hasCap(node, '_onClick')) return
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
}
function tweakCurrent(node: RenderableValueNode): unknown {
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
}
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
return tweak.compute(tweakCurrent(node), wrappedState.value)
}
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined && result !== tweakCurrent(node)
}
function tweakTooltip(
tweak: TweakDef,
node: RenderableValueNode,
): Record<string, unknown> | undefined {
if (!tweakEnabled(tweak, node)) return undefined
const content = tweakResult(tweak, node)
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
}
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
}
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
const result = tweakResult(tweak, node)
if (result !== null && result !== undefined) {
updateValue(node, result)
}
}
function nodeKey(item: ChildNode, idx: number): string {
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
}
function modelProp(item: object): string {
return (item as RenderableValueNode)._modelProp
}
function updateEvent(item: object): string {
return `update:${modelProp(item)}`
}
function updateValue(item: RenderableValueNode, v: unknown): void {
const onChange = hasCap(item, '_onChange')
? (item._onChange as OnChangeFn | undefined)
: undefined
if (onChange) {
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
if (result && typeof result === 'object' && '__override' in result) {
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
return
}
}
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
}
const seenOnChangeValues = new Map<object, unknown>()
watchEffect(() => {
for (const node of props.nodes) {
if (typeof node !== 'object' || node === null) continue
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
if (!hasValueCap(node) || !hasIdCap(node)) continue
if (!isShown(node as AnyNode)) continue
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
seenOnChangeValues.set(node, value)
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
}
})
</script>
<template>
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
<template v-if="typeof item !== 'object' || item === null">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isShown(item)">
<div
:class="
hasChildrenCap(item) && !hasValueCap(item)
? 'w-full'
: !getTitle(item)
? 'contents'
: undefined
"
>
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
<span
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="needsAttention(item)" class="text-red">*</span>
<!-- eslint-enable vue/no-v-html -->
</div>
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
<NodeRenderer
:nodes="resolveChildren(item, containerScope(item).state)"
:state="containerScope(item).state"
:write="containerScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:flex="(item as any)._layout !== 'column'"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
/>
</template>
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
<component
:is="resolveComponent(item as RenderableValueNode)"
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<component
:is="resolveComponent(item as RenderableValueNode)"
v-else
v-tooltip="resolveTooltip(item)"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<template
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
:key="`tweak-${tIdx}`"
>
<IconButton
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
:label="tweakLabel(tweak, item as RenderableValueNode)"
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
@click="applyTweak(tweak, item as RenderableValueNode)"
>
<component :is="tweak.icon" />
</IconButton>
</template>
</template>
<template v-else-if="hasCap(item, '_onClick')">
<Button
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item as any)"
:aria-label="(item as any)._icon ? (item as any).label : undefined"
@click="clickButton(item)"
>
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
<template v-else>{{ (item as any).label }}</template>
</Button>
</template>
</div>
</template>
</template>
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
<NodeRenderer
v-if="
typeof item === 'object' &&
item !== null &&
isShown(item) &&
hasValueCap(item) &&
hasIdCap(item) &&
hasChildrenCap(item) &&
isNodeActive(item, state[item.id], wrappedState) &&
resolveChildren(item, valueScope(item).state).length
"
:nodes="resolveChildren(item, valueScope(item).state)"
:state="valueScope(item).state"
:write="valueScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,11 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeMeta } from './node-meta'
export interface ChecklistMetaContext {
metaMap: Map<object, NodeMeta>
attentionMap: Map<object, boolean>
tooltipHtml: Map<object, string>
}
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
@@ -0,0 +1,372 @@
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
import { markRaw } from 'vue'
import { withAutoProps, withChildren } from './builder'
import type { ComponentNodePropsContext, Configurable } from './capabilities'
import {
withComponent,
withEditable,
withEnabled,
withExtraProps,
withFix,
withIcon,
withId,
withLayout,
withMessaging,
withNoneLabel,
withOnClick,
withPriority,
withRequired,
withSelectable,
withShown,
withStageMeta,
withStateOrigin,
withTitle,
withTooltip,
withTweak,
withValue,
} from './capabilities'
import ActionButton from './components/ActionButton.vue'
import { pipe } from './pipe'
import type { NodeState, NodeStateWithChildren } from './state'
function getBooleanValue(raw: NodeState): boolean {
if (typeof raw === 'boolean') return raw
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const v = (raw as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
return false
}
function setBooleanValue(raw: NodeState, next: boolean, isDefault?: boolean): NodeState {
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
}
return isDefault ? undefined : next
}
export const booleanValue = {
_getValue: getBooleanValue,
_setValue: setBooleanValue,
_isActive: (v: boolean) => v === true,
}
export function toggle(id: string, label: string) {
return pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withIcon,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(ActionButton),
componentProps: (ctx) => ({
label: n.label,
icon: n._icon,
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
tooltip: ctx.tooltip,
}),
}),
)
}
export function button(label?: string) {
return pipe(
{ label } as { label?: string },
withShown,
withIcon,
withTooltip,
withEnabled,
withOnClick,
)
}
export function check(id: string, label: string) {
return withAutoProps(
pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) =>
withComponent(n, {
component: markRaw(Checkbox),
componentProps: () => ({ label }),
}),
withExtraProps,
),
)
}
export function toggleSwitch(id: string, label: string) {
return withAutoProps(
pipe(
{ label } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, booleanValue),
(n) => withComponent(n, { component: markRaw(Toggle) }),
withExtraProps,
),
)
}
export function group(id?: string) {
const base = pipe(
{} as Record<string, never>,
withChildren,
withShown,
withTitle,
withRequired,
withSelectable,
withLayout,
)
return id === undefined ? base : withId(base, id)
}
export type GroupNode = ReturnType<typeof group>
export function externalGroup(path: string[]) {
return pipe({} as Record<string, never>, withChildren, withShown, (n) =>
withStateOrigin(n).stateOrigin(path),
)
}
const optionValue = {
_getValue: () => true,
_setValue: () => undefined,
_isActive: () => true,
}
export function option(value: string, label: string) {
return pipe(
{ value, label } as { value: string; label: string },
withChildren,
withShown,
withMessaging,
withPriority,
(n) => withValue(n, optionValue),
)
}
type OptionNode = ReturnType<typeof option>
interface HasOptions {
_options: OptionNode[]
options(this: this, ...opts: OptionNode[]): this
}
function withOptions<T extends object>(node: T): T & HasOptions {
return Object.assign(node, {
_options: [] as OptionNode[],
options(this: HasOptions, ...opts: OptionNode[]) {
this._options = opts
return this
},
})
}
function getStringValue(raw: NodeState): string {
if (typeof raw === 'string') return raw
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const v = (raw as NodeStateWithChildren).value
if (typeof v === 'string') return v
}
return ''
}
function setStringValue(raw: NodeState, next: string, isDefault?: boolean): NodeState {
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
}
return isDefault ? undefined : next
}
const dropdownValue = {
_getValue: getStringValue,
_setValue: setStringValue,
_isActive: (v: string) => v !== '',
}
export function dropdown(id: string) {
return pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withShown,
withTitle,
withRequired,
(n) => withValue(n, dropdownValue),
withNoneLabel,
withOptions,
(n) =>
withComponent(n, {
component: markRaw(Combobox),
componentProps: () => ({
options: [
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
...n._options.map((o) => ({ value: o.value, label: o.label })),
],
triggerClass:
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
}),
}),
)
}
export function stage(id: string, title: string) {
return pipe(
{ label: title } as { label: string },
(n) => withId(n, id),
withChildren,
withShown,
withIcon,
withStageMeta,
withMessaging,
withPriority,
)
}
export type StageNode = ReturnType<typeof stage>
const stringValue = dropdownValue
export function text(id: string) {
return withAutoProps(
pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
withEditable,
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
(n) => withValue(n, stringValue),
withTweak,
(n) =>
withComponent(n, {
component: markRaw(StyledInput),
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
}),
withExtraProps,
),
)
}
export function markdown(id: string) {
return withAutoProps(
pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
withEditable,
(n) => withValue(n, stringValue),
(n) =>
withComponent(n, {
component: markRaw(MarkdownEditor),
componentProps: (ctx) => ({
maxHeight: 300,
disabled: false,
headingButtons: false,
onImageUpload: ctx.onImageUpload,
}),
}),
withExtraProps,
),
)
}
function getSetValue(raw: NodeState): string[] {
return raw instanceof Set ? Array.from(raw) : []
}
function setSetValue(_raw: NodeState, next: string[], isDefault?: boolean): NodeState {
return isDefault || next.length === 0 ? undefined : new Set(next)
}
const setValue = {
_getValue: getSetValue,
_setValue: setSetValue,
_isActive: (v: string[]) => v.length > 0,
}
const stringValueBehavior = {
_getValue: getStringValue,
_setValue: setStringValue,
_isActive: (v: string) => v !== '',
}
export function appComponent(id: string, rendererKey: string) {
const node = pipe(
{} as Record<string, never>,
(n) => withId(n, id),
withChildren,
withShown,
withTooltip,
withTitle,
withMessaging,
withPriority,
withRequired,
withFix,
withEnabled,
(n) => withValue(n, stringValueBehavior),
(n) => withComponent(n, { rendererKey }),
withExtraProps,
)
return Object.assign(node, {
valueKind(this: Configurable, kind: 'boolean' | 'string' | 'set') {
Object.assign(
this,
kind === 'boolean' ? booleanValue : kind === 'set' ? setValue : stringValueBehavior,
)
return this
},
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
this._extraProps = fn
return this
},
})
}
+58
View File
@@ -0,0 +1,58 @@
import type { Labrinth } from '@modrinth/api-client'
import type { NodeState } from './state'
export class FixBuilder {
_projectFn?: (
patch: Labrinth.Projects.v3.EditProjectRequest,
state: Record<string, NodeState>,
) => void
_versionFn?: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void
project(
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
): this {
this._projectFn = fn
return this
}
version(
fn: (
patch: Labrinth.Versions.v3.ModifyVersionRequest,
state: Record<string, NodeState>,
) => void,
): this {
this._versionFn = fn
return this
}
}
export function fix(): FixBuilder {
return new FixBuilder()
}
export function createTrackedPatch<T extends object>(
source: T,
): { proxy: T; changes: () => Partial<T> } {
const written = new Set<string | symbol>()
const data = { ...source }
const proxy = new Proxy(data, {
set(target, key, value) {
if (value !== (source as Record<string | symbol, unknown>)[key]) {
written.add(key)
} else {
written.delete(key)
}
;(target as Record<string | symbol, unknown>)[key] = value
return true
},
})
return {
proxy,
changes: () =>
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
}
}
@@ -0,0 +1,12 @@
export * from './builder'
export * from './capabilities'
export * from './collect'
export * from './context'
export * from './factories'
export * from './fix'
export * from './messages'
export * from './mutate'
export * from './node-meta'
export * from './pipe'
export * from './resolve'
export * from './state'
@@ -0,0 +1,145 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import {
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
} from '../../utils'
import type { GetVarsFn, MessageSegment, NodeState } from './state'
const messageFiles = import.meta.glob('../../data/messages/**/*.md', {
query: '?raw',
import: 'default',
})
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
concat(...others: MessageFn[]): MessageFn
}
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
const rich = fn as MessageFn
rich.concat = (...others) =>
makeMessageFn(async (state) =>
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
)
return rich
}
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
let _onMissingMd: ((path: string) => void) | null = null
export function setMissingMdHandler(handler: (path: string) => void) {
_onMissingMd = handler
}
export function setMessageProject(
project: Ref<Labrinth.Projects.v3.Project>,
projectV2: Ref<Labrinth.Projects.v2.Project>,
) {
_project = project
_projectV2 = projectV2
}
export function mdEscape(text: string): string {
return text.replace(/[\\*_`[~]/g, '\\$&')
}
const USER_CONTENT_KEYS = [
'PROJECT_TITLE',
'PROJECT_SLUG',
'PROJECT_SUMMARY',
'PROJECT_TYPE',
'PROJECT_STATUS',
]
export async function loadMd(
path: string,
state: Record<string, NodeState>,
project: Labrinth.Projects.v3.Project,
projectV2: Labrinth.Projects.v2.Project,
getVars?: GetVarsFn,
): Promise<string> {
const extraVars = getVars ? getVars(state) : null
const loader = messageFiles[`../../data/messages/${path}.md`]
if (!loader) {
_onMissingMd?.(path)
return ''
}
const raw = (await loader()) as string
const vars: Record<string, string> = {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2),
...flattenProjectV3Variables(project),
}
for (const key of USER_CONTENT_KEYS) {
if (key in vars) vars[key] = mdEscape(vars[key])
}
if (extraVars) {
for (const [k, v] of Object.entries(extraVars)) {
vars[k] = String(v ?? '')
}
}
const expanded = expandVariables(raw, projectV2, project, vars)
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
}
export function mdOptional(path: string, getVars?: GetVarsFn): MessageFn {
return makeMessageFn(async (state) => {
const loader = messageFiles[`../../data/messages/${path}.md`]
if (!loader) return ''
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
})
}
export function md(
path: string | ((state: Record<string, NodeState>) => string),
getVars?: GetVarsFn,
): MessageFn {
return makeMessageFn(async (state) => {
const resolvedPath = typeof path === 'function' ? path(state) : path
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
})
}
export function resolveRelativeMessagePath(
messagePath: string | (() => string),
statePath: string[],
): string {
const name = typeof messagePath === 'function' ? messagePath() : messagePath
if (name.startsWith('/')) return `checklist/messages${name}`
const parts = [...statePath.slice(0, -1), ...name.split('/')]
const normalized = parts.reduce<string[]>((acc, p) => {
if (p === '..') acc.pop()
else if (p) acc.push(p)
return acc
}, [])
return `checklist/messages/${normalized.join('/')}`
}
export async function evalSegment(
seg: MessageSegment,
state: Record<string, NodeState>,
statePath: string[],
): Promise<string> {
if (seg.type === 'collect') return ''
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
if (seg.type === 'auto') {
return loadMd(
`checklist/messages/${statePath.join('/')}`,
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
return loadMd(
resolveRelativeMessagePath(seg.path, statePath),
state,
_project!.value,
_projectV2!.value,
seg.getVars,
)
}
@@ -0,0 +1,64 @@
import type { HasValue, Identified } from './capabilities'
import { getEffectiveValue } from './resolve'
import type { NodeState } from './state'
function valuesEqual(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
const setB = new Set(b)
return a.every((v) => setB.has(v))
}
return a === b
}
export type Writer = (id: string, value: NodeState) => void
export function childWriter(
parentRead: Record<string, NodeState>,
parentWrite: Writer,
containerId: string,
): Writer {
return (childId, value) => {
const existing = parentRead[containerId]
const container: Record<string, NodeState> =
existing && typeof existing === 'object' && !(existing instanceof Set)
? { ...(existing as Record<string, NodeState>) }
: existing !== undefined
? { value: existing }
: {}
if (value === undefined) Reflect.deleteProperty(container, childId)
else container[childId] = value
parentWrite(containerId, Object.keys(container).length === 0 ? undefined : container)
}
}
export function writeNodeValue<V>(
node: HasValue<V> & Identified,
read: Record<string, NodeState>,
write: Writer,
next: V,
contextState: Record<string, NodeState> = read,
): void {
const isDefault = valuesEqual(next, getEffectiveValue(node, undefined, contextState))
write(node.id, node._setValue(read[node.id], next, isDefault))
}
export function originScope(
root: Record<string, NodeState>,
path: string[],
): { state: Record<string, NodeState>; write: Writer } {
let state = root
let write: Writer = (id, value) => {
if (value === undefined) Reflect.deleteProperty(root, id)
else root[id] = value
}
for (const segment of path) {
write = childWriter(state, write, segment)
const raw = state[segment]
state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
}
return { state, write }
}
@@ -0,0 +1,129 @@
import type { AnyNode, ChildNode, HasChildren } from './builder'
import type { FixBuilder } from './fix'
import {
getBooleanChildState,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveActionState,
resolveChildren,
walkNodes,
} from './resolve'
import type { NodeState } from './state'
export interface NodeMeta {
hasRequiredMissing: boolean
isFixActionable: boolean
}
export function computeNodeMeta(
children: ChildNode[],
stageState: Record<string, NodeState>,
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean,
): Map<object, NodeMeta> {
const map = new Map<object, NodeMeta>()
walkNodes(children, stageState, (node, nodeState, localState) => {
const active = isNodeActive(node, nodeState, localState)
const required = hasCap(node, '_required') && node._required === true
const hasRequiredMissing = required && !active
let fixActionable = false
if (active && hasCap(node, '_fixes')) {
const fixes = node._fixes as FixBuilder[]
if (fixes.length > 0) {
fixActionable = isFixActionable(fixes, resolveActionState(node, nodeState, localState))
}
}
map.set(node, { hasRequiredMissing, isFixActionable: fixActionable })
})
return map
}
export function computeAttentionMap(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
metaMap: Map<object, NodeMeta>,
attention: Map<object, boolean> = new Map(),
): Map<object, boolean> {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (!isShown(node as AnyNode)) continue
if (hasChildrenCap(node) && !hasValueCap(node)) {
const childState = childScopeFor(node, stageState)
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
const childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
const ownRequired = hasCap(node, '_required') && node._required === true
const unsatisfied = ownRequired && !hasActiveChild(node, childState)
attention.set(node, unsatisfied || childrenNeedAttention)
continue
}
if (hasValueCap(node) && hasIdCap(node)) {
const rawNodeState = stageState[node.id]
const active = isNodeActive(node, rawNodeState, stageState)
let childrenNeedAttention = false
if (active) {
if (hasOptionsCap(node)) {
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (selected && isShown(selected) && hasChildrenCap(selected)) {
computeAttentionMap(
resolveChildren(selected, stageState),
stageState,
metaMap,
attention,
)
childrenNeedAttention = someChildNeedsAttention(selected, stageState, attention)
}
} else if (hasChildrenCap(node)) {
const childState = getBooleanChildState(rawNodeState)
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
}
}
const ownMissing = metaMap.get(node)?.hasRequiredMissing ?? false
attention.set(node, ownMissing || childrenNeedAttention)
}
}
return attention
}
function childScopeFor(
node: HasChildren & { id?: string },
state: Record<string, NodeState>,
): Record<string, NodeState> {
if (!hasIdCap(node)) return state
const raw = state[node.id]
return raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
}
function hasActiveChild(node: HasChildren, childState: Record<string, NodeState>): boolean {
return resolveChildren(node, childState).some((child) => {
if (typeof child !== 'object' || child === null) return false
const raw = hasIdCap(child) ? childState[child.id] : undefined
return isNodeActive(child, raw, childState)
})
}
function someChildNeedsAttention(
node: HasChildren,
childState: Record<string, NodeState>,
attention: Map<object, boolean>,
): boolean {
return resolveChildren(node, childState).some(
(child) => typeof child === 'object' && child !== null && attention.get(child) === true,
)
}
@@ -0,0 +1,29 @@
export function pipe<A>(a: A): A
export function pipe<A, B>(a: A, f1: (a: A) => B,): B
export function pipe<A, B, C>(a: A, f1: (a: A) => B, f2: (b: B) => C,): C
export function pipe<A, B, C, D>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D,): D
export function pipe<A, B, C, D, E>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E,): E
export function pipe<A, B, C, D, E, F>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F,): F
export function pipe<A, B, C, D, E, F, G>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G,): G
export function pipe<A, B, C, D, E, F, G, H>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H,): H
export function pipe<A, B, C, D, E, F, G, H, I>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I,): I
export function pipe<A, B, C, D, E, F, G, H, I, J>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J,): J
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K,): K
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L,): L
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M,): M
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N,): N
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O,): O
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P,): P
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q,): Q
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R,): R
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S,): S
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T,): T
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U,): U
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V,): V
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W,): W
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X,): X
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y,): Y
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y, f25: (y: Y) => Z,): Z
export function pipe(a: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
return fns.reduce((acc, fn) => fn(acc), a)
}
@@ -0,0 +1,325 @@
import type { Ref } from 'vue'
import { toValue } from 'vue'
import type { AnyNode, ChildEntry, ChildNode, HasChildren } from './builder'
import type { HasValue, Identified } from './capabilities'
import type { Writer } from './mutate'
import { childWriter, writeNodeValue } from './mutate'
import type { NodeState } from './state'
import { resolve } from './state'
export function hasCap<K extends string>(node: object, key: K): node is Record<K, unknown> {
return key in node
}
export function hasValueCap(node: object): node is HasValue {
return hasCap(node, '_getValue') && hasCap(node, '_isActive')
}
export function hasChildrenCap(node: object): node is HasChildren {
return hasCap(node, '_children')
}
export function hasOptionsCap(node: object): node is { _options: OptionLike[] } {
return hasCap(node, '_options')
}
export function hasIdCap(node: object): node is Identified {
return hasCap(node, 'id')
}
interface OptionLike extends AnyNode, Partial<HasChildren> {
value: string
_segments?: unknown[]
}
export function isShown(node: AnyNode): boolean {
return node._shown === undefined || resolve(node._shown)
}
export function getEffectiveValue<V>(
node: HasValue<V>,
rawState: NodeState,
contextState: Record<string, NodeState> = {},
): V {
if (rawState !== undefined) return node._getValue(rawState)
if (node._defaultValue !== undefined) {
const def =
typeof node._defaultValue === 'function'
? (node._defaultValue as (state: Record<string, NodeState>) => NodeState)(contextState)
: (node._defaultValue as unknown as NodeState)
return node._getValue(def)
}
return node._getValue(rawState)
}
export function isNodeActive(
node: object,
rawState: NodeState,
contextState: Record<string, NodeState> = {},
): boolean {
if (hasValueCap(node)) return node._isActive(getEffectiveValue(node, rawState, contextState))
return false
}
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
return nodeState as Record<string, NodeState>
}
return {}
}
export function resolveChildren(node: HasChildren, state: Record<string, NodeState>): ChildNode[] {
const entries: ChildEntry[] = node._childrenFn ? node._childrenFn(state) : node._children
const result: ChildNode[] = []
for (const entry of entries) {
if (entry == null) continue
if (typeof entry === 'string') {
result.push(entry)
continue
}
if (typeof entry === 'function') {
if (entry.length === 0) {
result.push(entry as () => unknown)
continue
}
const fn = entry as (state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null
const resolved = fn(state)
if (resolved == null) continue
if (Array.isArray(resolved)) {
for (const r of resolved) {
if (r != null && isShown(r)) result.push(r)
}
continue
}
if (isShown(resolved)) result.push(resolved)
continue
}
const resolved = toValue(entry as AnyNode | Ref<AnyNode | null>)
if (resolved != null && isShown(resolved)) result.push(resolved)
}
return result
}
function buildChildMap(
children: ChildNode[],
stateForFlattening: Record<string, NodeState>,
): Map<string, object> {
const map = new Map<string, object>()
function collect(nodes: ChildNode[]) {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (hasIdCap(node)) {
map.set(node.id, node)
continue
}
if (hasChildrenCap(node)) collect(resolveChildren(node, stateForFlattening))
}
}
collect(children)
return map
}
function optionChildrenFor(node: object, state: Record<string, NodeState>): ChildNode[] {
if (!hasOptionsCap(node) || !hasIdCap(node)) return []
const raw = state[node.id]
const selectedValue = typeof raw === 'string' ? raw : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (!selected || !hasChildrenCap(selected)) return []
return resolveChildren(selected, state)
}
export function withStateDefaults<T extends Record<string, NodeState>>(
rawState: T,
children: ChildNode[],
write?: Writer,
): T {
if (rawState == null || typeof rawState !== 'object' || rawState instanceof Set) return rawState
const childMap = buildChildMap(children, rawState)
for (const [, node] of childMap) {
for (const c of optionChildrenFor(node, rawState)) {
if (typeof c === 'object' && c !== null && hasIdCap(c)) childMap.set(c.id, c)
}
}
const resolving = new Set<string>()
const proxy = new Proxy(rawState, {
get(target, key, receiver) {
if (typeof key !== 'string') return Reflect.get(target, key, receiver)
const child = childMap.get(key)
let value: NodeState = Reflect.get(target, key, receiver)
if (value === undefined && child && hasValueCap(child) && !resolving.has(key)) {
resolving.add(key)
try {
const effective = getEffectiveValue(child, undefined, proxy)
const grandchildren = hasChildrenCap(child) ? resolveChildren(child, {}) : []
if (grandchildren.length > 0) {
return withStateDefaults(
{ value: effective } as Record<string, NodeState>,
grandchildren,
write && childWriter(target, write, key),
)
}
value = child._setValue(undefined, effective)
} finally {
resolving.delete(key)
}
}
if (
child &&
hasChildrenCap(child) &&
value !== null &&
typeof value === 'object' &&
!(value instanceof Set)
) {
const nested = value as Record<string, NodeState>
return withStateDefaults(
nested,
resolveChildren(child, nested),
write && childWriter(target, write, key),
)
}
if (value === undefined && child && hasChildrenCap(child)) {
const grandchildren = resolveChildren(child, {})
if (grandchildren.length > 0) return emptyScope(grandchildren)
}
return value
},
set(target, key, value, receiver) {
if (typeof key !== 'string') return Reflect.set(target, key, value, receiver)
const child = childMap.get(key)
if (child && hasValueCap(child) && write) {
writeNodeValue(child as HasValue & Identified, target, write, value as never, proxy)
return true
}
return Reflect.set(target, key, value, receiver)
},
}) as T
return proxy
}
export function resolveActionState(
node: object,
nodeState: NodeState,
localState: Record<string, NodeState>,
): Record<string, NodeState> {
if (!hasValueCap(node)) return localState
const childState = getBooleanChildState(nodeState)
const base = hasChildrenCap(node)
? withStateDefaults(childState, resolveChildren(node, childState))
: childState
return new Proxy(base, {
get(target, key, receiver) {
if (key === 'value') return getEffectiveValue(node, nodeState, localState) as NodeState
return Reflect.get(target, key, receiver)
},
})
}
function emptyScope(children: ChildNode[]): Record<string, NodeState> {
const childMap = buildChildMap(children, {})
const resolving = new Set<string>()
const cache = new Map<string, Record<string, NodeState>>()
const proxy: Record<string, NodeState> = new Proxy(
{},
{
get(_target, key) {
if (typeof key !== 'string') return undefined
const child = childMap.get(key)
if (!child) return undefined
if (hasValueCap(child) && !resolving.has(key)) {
resolving.add(key)
try {
const def = getEffectiveValue(child, undefined, proxy)
if (def !== undefined) return child._setValue(undefined, def)
} finally {
resolving.delete(key)
}
}
if (!hasChildrenCap(child)) return undefined
const cached = cache.get(key)
if (cached) return cached
const grandchildren = resolveChildren(child, {})
if (grandchildren.length === 0) return undefined
const nested = emptyScope(grandchildren)
cache.set(key, nested)
return nested
},
},
) as Record<string, NodeState>
return proxy
}
export type Visitor = (
node: object,
nodeState: NodeState,
localState: Record<string, NodeState>,
path: string[],
) => void
export function walkNodes(
nodes: ChildNode[],
stageState: Record<string, NodeState>,
visitor: Visitor,
path: string[] = [],
): void {
for (const node of nodes) {
if (typeof node !== 'object' || node === null) continue
if (!isShown(node as AnyNode)) continue
if (hasChildrenCap(node) && !hasValueCap(node)) {
if (hasIdCap(node)) {
const raw = stageState[node.id]
const childState =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
walkNodes(resolveChildren(node, childState), childState, visitor, [...path, node.id])
} else {
walkNodes(resolveChildren(node, stageState), stageState, visitor, path)
}
continue
}
if (hasValueCap(node) && hasIdCap(node)) {
const rawNodeState = stageState[node.id]
const nodePath = [...path, node.id]
visitor(node, rawNodeState, stageState, nodePath)
const active = isNodeActive(node, rawNodeState, stageState)
if (!active) continue
if (hasOptionsCap(node)) {
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
const selected = node._options.find((o) => o.value === selectedValue)
if (selected && isShown(selected)) {
const selectedPath = [...path, selected.value]
visitor(selected, undefined, stageState, selectedPath)
if (hasChildrenCap(selected)) {
walkNodes(resolveChildren(selected, stageState), stageState, visitor, selectedPath)
}
}
continue
}
if (hasChildrenCap(node)) {
const childState = getBooleanChildState(rawNodeState)
walkNodes(resolveChildren(node, childState), childState, visitor, nodePath)
}
}
}
}
@@ -0,0 +1,33 @@
import type { Ref } from 'vue'
import { toValue } from 'vue'
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
export type NodeState =
| boolean
| string
| number
| Set<string>
| NodeStateWithChildren
| null
| undefined
export interface NodeStateWithChildren {
value?: NodeState
[childId: string]: NodeState
}
export type Reactive<T> = T | Ref<T>
export function resolve<T>(value: Reactive<T>): T {
return toValue(value as T | Ref<T>)
}
export type GetVarsFn = (state: Record<string, NodeState>) => Record<string, unknown>
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
export type MessageSegment =
| { type: 'fn'; fn: ContentFn }
| { type: 'auto'; getVars?: GetVarsFn }
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
| { type: 'collect'; fallback?: MessageSegment }
+5 -5
View File
@@ -37,14 +37,14 @@ export const setting = {
asString: (data: StringSettingDefinition) => data,
}
export function isValidFor(definitionBase: SettingDefinitionBase<any>, value: any): boolean {
export function isValidFor(
definitionBase: SettingDefinitionBase<unknown>,
value: unknown,
): boolean {
if (value != null) {
// Tried my best with type safety but sadly having the `SettingDefinitions` as the type leads to issues handling types else where...
const definition = definitionBase as SettingDefinitions
if (
definition.type == 'enum' &&
definition.entries.map((entry) => entry.value).includes(value)
) {
if (definition.type == 'enum' && definition.entries.some((entry) => entry.value === value)) {
return true
} else if (definition.type == 'toggle' && typeof value === 'boolean') {
return true
-64
View File
@@ -1,64 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import type { Action } from './actions'
/**
* Represents a moderation stage with associated actions and optional navigation logic.
*/
export interface Stage {
/**
* The title of the stage, displayed in the checklist header.
*/
title: string
/**
* The hint for this stage, tells the moderator what to do.
*/
hint: string
/**
* An optional description or additional text for the stage.
*/
text?: (
project: Labrinth.Projects.v2.Project,
projectV3?: Labrinth.Projects.v3.Project,
) => Promise<string>
/**
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
*/
id?: string
/**
* Optional icon for the stage, displayed in the stage list and next to the title.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* URL to the guidance document for this stage.
*/
guidance_url: string
/**
* An array of actions that can be taken in this stage.
*/
actions: Action[]
/**
* Optional navigation path to redirect the moderator when this stage is shown.
*
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
*/
navigate?: string
/**
* A function that determines whether this stage should be shown for a given project.
*
* By default, it returns `true`, meaning the stage is always shown.
*/
shouldShow?: (
project: Labrinth.Projects.v2.Project,
projectV3?: Labrinth.Projects.v3.Project,
) => boolean
}
-205
View File
@@ -1,181 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import type {
Action,
AdditionalTextInput,
ButtonAction,
ConditionalMessage,
ToggleAction,
} from './types/actions'
export interface ActionState {
selected: boolean
value?: Set<string | number> | number | string | unknown
}
export interface MessagePart {
weight: number
content: string
actionId: string
stageIndex: number
}
export function getActionIdForStage(
action: Action,
stageIndex: number,
actionIndex?: number,
enabledIndex?: number,
): string {
if (action.id) {
return `stage-${stageIndex}-${action.id}`
}
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
}
export function getActionId(action: Action, currentStage: number, index?: number): string {
return getActionIdForStage(action, currentStage, index)
}
export function getActionKey(
action: Action,
currentStage: number,
visibleActions: Action[],
): string {
const index = visibleActions.indexOf(action)
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
}
export function initializeActionState(action: Action): ActionState {
if (action.type === 'toggle') {
return {
selected: action.defaultChecked || false,
}
} else if (action.type === 'dropdown') {
return {
selected: true,
value: action.defaultOption || 0,
}
} else if (action.type === 'multi-select-chips') {
return {
selected: false,
value: new Set<string | number>(),
}
} else {
return {
selected: false,
}
}
}
export function processMessage(
message: string,
action: Action,
stageIndex: number,
textInputValues: Record<string, string>,
): string {
let processedMessage = message
if (action.relevantExtraInput) {
action.relevantExtraInput.forEach((input, index) => {
if (input.variable) {
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
const value = textInputValues[inputKey] || ''
const regex = new RegExp(`%${input.variable}%`, 'g')
processedMessage = processedMessage.replace(regex, value)
}
})
}
return processedMessage
}
export function findMatchingVariant(
variants: ConditionalMessage[],
selectedActionIds: string[],
allValidActionIds?: string[],
currentStageIndex?: number,
): ConditionalMessage | null {
for (const variant of variants) {
const conditions = variant.conditions
const meetsRequired =
!conditions.requiredActions ||
conditions.requiredActions.every((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
return false
}
return selectedActionIds.includes(fullId)
})
const meetsExcluded =
!conditions.excludedActions ||
!conditions.excludedActions.some((id) => {
let fullId = id
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
fullId = `stage-${currentStageIndex}-${id}`
}
return selectedActionIds.includes(fullId)
})
if (meetsRequired && meetsExcluded) {
return variant
}
}
return null
}
export async function getActionMessage(
action: ButtonAction | ToggleAction,
selectedActionIds: string[],
allValidActionIds?: string[],
): Promise<string> {
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
const matchingConditional = findMatchingVariant(
action.conditionalMessages,
selectedActionIds,
allValidActionIds,
)
if (matchingConditional) {
return (await matchingConditional.message()) as string
}
}
return (await action.message()) as string
}
export function getVisibleInputs(
action: Action,
actionStates: Record<string, ActionState>,
): AdditionalTextInput[] {
if (!action.relevantExtraInput) return []
const selectedActionIds = Object.entries(actionStates)
.filter(([, state]) => state.selected)
.map(([id]) => id)
return action.relevantExtraInput.filter((input) => {
if (!input.showWhen) return true
const meetsRequired =
!input.showWhen.requiredActions ||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
const meetsExcluded =
!input.showWhen.excludedActions ||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
return meetsRequired && meetsExcluded
})
}
export function expandVariables(
template: string,
project: Labrinth.Projects.v2.Project,
@@ -283,35 +107,6 @@ export function formatProjectTypes(type: string, lower: boolean = false) {
return value
}
export function formatEnvironments(environment: string, lower: boolean = false) {
let value = environment
try {
value = value
.replaceAll('client_only_server_optional', 'Client and server: Optional on server')
.replaceAll('client_and_server', 'Required on both')
.replaceAll('client_only', 'Client-side only')
.replaceAll('server_only_client_optional', 'Client optional')
.replaceAll('dedicated_server_only', 'Dedicated server only')
.replaceAll('server_only', 'Servers and Singleplayer')
.replaceAll('singleplayer_only', 'Singleplayer only')
.replaceAll(
'client_or_server_prefers_both',
'Optional on both, works best when installed on both sides',
)
.replaceAll(
'client_or_server',
'Optional on both, works the same if installed on either side',
)
// This shouldn't come up for this use but yk
.replaceAll('unknown', 'Unknown')
} catch {
return 'No project environment'
}
if (lower === true) value = value.toLowerCase()
return value
}
export function requiresEnvironmentInfo(projectTypes): boolean {
return projectTypes.includes('mod') || projectTypes.includes('modpack')
}