FeedFabric docsGitHub

A formula editor

Build your own ranking editor on the API, as the admin panel's Feed studio does. Load the vocabulary, write a formula with completions, show errors where they are, preview the feed it gives a real member as you type, and save it as a new version.

The admin panel’s Feed studio (web/admin/src/views/StudioView.vue) is a complete editor for ranking profiles. This page builds the core of one, so you can put ranking controls in your own staff tools, or give a community’s moderators a say in their feed. Everything here is a public API call.

This is a staff tool. Everything under /ranking needs the ranking-admin role outside Development (Auth:Roles:RankingAdmin), because saving a profile changes the feed for everyone who uses it. Members can choose between profiles (/userfeed/foryou?profile=), but not edit them.

The endpoints

Endpoint What for
GET /ranking/vocabulary Everything a profile can use, in words: variables, functions, operators, examples, goals, presets, defaults
GET /ranking/profiles Presets, then saved profiles at their latest version
GET /ranking/profiles/{key} One profile’s settings
POST /ranking/preview?viewer=<externalUserId>&limit=30 The feed some settings would give a member, without saving or logging anything (limit up to 50)
PUT /ranking/profiles/{key} Create a profile, or save a new version
GET /ranking/profiles/{key}/versions, /versions/{n} History
DELETE /ranking/profiles/{key} Remove a saved profile

Types and calls

Add these to the API client. They match the admin panel’s web/admin/src/types.ts.

// src/types.ts (additions)
export type ScoringMode = 'model' | 'formula' | 'expression' | 'newest'
export interface Goals { open: number; like: number; comment: number; longRead: number; skip: number }
export interface SourceSetting { enabled: boolean; limit: number }
export interface BoostRule {
  match: 'tag' | 'author' | 'hasMedia' | 'following' | 'notFollowing' | 'favourite'
  value?: string | null
  multiplier: number
}

export interface RankingProfile {
  name: string
  description: string
  sources: { following: SourceSetting; topics: SourceSetting; similarReaders: SourceSetting; similarMeaning: SourceSetting
             topTopics: number; lookbackHours: number }
  scoring: { mode: ScoringMode; expression: string | null; goals: Goals }
  freshness: { halfLifeHours: number | null; maxAgeHours: number | null }
  variety: { authorCap: number; topicRun: number; outOfNetwork: number; seenPenalty: number }
  exploration: { slots: number; fromRank: number }
  boosts: BoostRule[]
  filters: { excludeTags: string[]; onlyTags: string[]; mediaOnly: boolean; minLikes: number }
}

export interface ProfileDetail { key: string; version: number; preset: boolean; label: string; isDefault: boolean; settings: RankingProfile }

// One entry in a profile.invalid error's "errors"; position is where in a formula the problem is
export interface ProfileError { field: string; message: string; position?: number | null }

export interface RankingVocabulary {
  variables: { name: string; description: string }[]
  functions: { name: string; signature: string; description: string }[]
  operators: string[]
  examples: { label: string; text: string }[]
  goals: { name: keyof Goals; description: string }[]
  presets: { key: string; settings: RankingProfile }[]
  defaults: RankingProfile
  defaultProfile: string
}
// src/api/ranking.ts
import { request } from './request'
import type { ForYouFeed, ProfileDetail, RankingProfile, RankingVocabulary } from '../types'

const id = encodeURIComponent

export const ranking = {
  vocabulary: () => request<RankingVocabulary>('/ranking/vocabulary'),
  profile: (key: string) => request<ProfileDetail>(`/ranking/profiles/${id(key)}`),
  save: (key: string, settings: RankingProfile) => request<ProfileDetail>(`/ranking/profiles/${id(key)}`, { method: 'PUT', body: settings }),
  preview: (settings: RankingProfile, viewer: string, limit = 30) =>
    request<ForYouFeed>(`/ranking/preview?viewer=${id(viewer)}&limit=${limit}`, { method: 'POST', body: settings }),
}

Add /ranking to your dev server’s proxy, next to /userfeed.

Start from something that works

A profile only needs the parts it changes, but an editor is easier to build on a complete one. The vocabulary carries every preset’s full settings and the defaults, so start a new profile from one of them:

const vocabulary = await ranking.vocabulary()
const base = vocabulary.presets.find(p => p.key === 'balanced')!.settings
const draft: RankingProfile = structuredClone({
  ...base,
  name: 'My feed',
  scoring: { ...base.scoring, mode: 'expression', expression: vocabulary.examples[0].text },
})

Previewing as you type

The server is the only judge of whether a formula is valid, and POST /ranking/preview both checks it and ranks with it, so one call per pause in typing gives you errors and results. Debounce it, and cancel a preview that a newer one has overtaken.

// src/core/livePreview.ts
import { ApiError } from '../api/errors'
import { ranking } from '../api/ranking'
import type { ForYouFeed, ProfileError, RankingProfile } from '../types'

export type PreviewResult =
  | { kind: 'feed'; feed: ForYouFeed }
  | { kind: 'invalid'; errors: ProfileError[] }
  | { kind: 'failed'; error: unknown }

export function livePreview(onResult: (result: PreviewResult) => void, waitMs = 400) {
  let timer: ReturnType<typeof setTimeout> | undefined
  let latest = 0

  return (settings: RankingProfile, viewer: string) => {
    clearTimeout(timer)
    timer = setTimeout(async () => {
      const mine = ++latest
      try {
        const feed = await ranking.preview(settings, viewer)
        if (mine === latest) onResult({ kind: 'feed', feed })
      } catch (e) {
        if (mine !== latest) return
        if (e instanceof ApiError && e.code === 'profile.invalid') {
          onResult({ kind: 'invalid', errors: e.errors as unknown as ProfileError[] })
        } else {
          onResult({ kind: 'failed', error: e })
        }
      }
    }, waitMs)
  }
}

A preview ranks a few hundred posts for real, so keep the debounce at a few hundred milliseconds and don’t preview on every keystroke.

Showing an error where it is

A formula error is the entry with field scoring.expression. Its position is a 0-based character offset into the formula as sent; put the cursor there and underline from there to the end of the word.

// src/core/livePreview.ts (continued)
export function formulaError(errors: ProfileError[]) {
  return errors.find(e => e.field === 'scoring.expression') ?? null
}

// Select the problem in a <textarea> so the member sees it and can type over it
export function showAt(textarea: HTMLTextAreaElement, position: number) {
  const text = textarea.value
  let end = position
  while (end < text.length && /[\w.]/.test(text[end])) end++
  textarea.focus()
  textarea.setSelectionRange(position, Math.max(end, position + 1))
}

Other entries in errors belong to the rest of the profile (variety.authorCap, boosts[2].value), with the setting’s camelCase path as field, so show them next to that control. The messages are English and may change: key your own UI off field, and show message as it is or replace it with your own.

Completions

The vocabulary has everything a completion list needs: names, a signature for each function, and a sentence for each.

// src/core/completions.ts
import type { RankingVocabulary } from '../types'

export interface Completion { label: string; insert: string; detail: string }

export function completions(vocabulary: RankingVocabulary): Completion[] {
  return [
    ...vocabulary.variables.map(v => ({ label: v.name, insert: v.name, detail: v.description })),
    ...vocabulary.functions.map(f => ({ label: f.signature, insert: `${f.name}(`, detail: f.description })),
  ]
}

// The word being typed just before the cursor, to filter the list with
export function wordBefore(text: string, cursor: number) {
  const match = /[A-Za-z_][\w]*$/.exec(text.slice(0, cursor))
  return match ? { word: match[0], start: cursor - match[0].length } : null
}

Show the list when wordBefore returns at least two characters, filter by prefix, and on Enter or Tab replace from start to the cursor with insert. Libraries such as CodeMirror’s autocomplete take exactly this list if you’d rather not build the popup yourself. For syntax highlighting, the grammar is small enough to tokenise with one regular expression for numbers, names and operators.

The editor

Put together in Vue (the React version follows the same shape with useState and useEffect):

<!-- src/components/FormulaEditor.vue -->
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { ranking } from '../api/ranking'
import { describe } from '../api/errors'
import { formulaError, livePreview, showAt, type PreviewResult } from '../core/livePreview'
import type { RankingProfile, RankingVocabulary } from '../types'

const props = defineProps<{ profileKey: string; viewer: string }>()

const vocabulary = shallowRef<RankingVocabulary>()
const draft = ref<RankingProfile>()
const result = shallowRef<PreviewResult>()
const textarea = ref<HTMLTextAreaElement>()
const saving = ref(false)
const saved = ref<string | null>(null)

const preview = livePreview(r => (result.value = r))
const error = computed(() => (result.value?.kind === 'invalid' ? formulaError(result.value.errors) : null))

onMounted(async () => {
  vocabulary.value = await ranking.vocabulary()
  // An existing profile, or a new one from the balanced preset
  const existing = await ranking.profile(props.profileKey).catch(() => null)
  const base = existing?.settings ?? vocabulary.value.presets.find(p => p.key === 'balanced')!.settings
  draft.value = structuredClone({ ...base, scoring: { ...base.scoring, mode: 'expression', expression: base.scoring.expression ?? 'formula' } })
})

watch([draft, () => props.viewer], () => draft.value && preview(draft.value, props.viewer), { deep: true })

async function save() {
  saving.value = true
  try {
    const detail = await ranking.save(props.profileKey, draft.value!)
    saved.value = `Saved as ${detail.key}@${detail.version}. Feeds use it from their next first page.`
  } catch (e) {
    saved.value = describe(e)   // profile.read_only for a preset key, profile.invalid_key, profile.invalid
  } finally {
    saving.value = false
  }
}
</script>

<template>
  <div v-if="draft && vocabulary" class="editor">
    <label for="formula">Formula</label>
    <textarea id="formula" ref="textarea" v-model="draft.scoring.expression" rows="3" spellcheck="false"
      :aria-invalid="!!error" aria-describedby="formula-help formula-error" />
    <p id="formula-help">Variables, functions and operators are listed below. Only the order of scores matters.</p>
    <p v-if="error" id="formula-error" role="alert">
      {{ error.message }}
      <button v-if="error.position != null" type="button" @click="showAt(textarea!, error.position)">Show me</button>
    </p>

    <p>
      Examples:
      <button v-for="x in vocabulary.examples" :key="x.label" type="button" @click="draft.scoring.expression = x.text">{{ x.label }}</button>
    </p>

    <section v-if="result?.kind === 'feed'" aria-label="Preview">
      <p>{{ result.feed.candidates }} candidates, ranked by <code>{{ result.feed.ranker }}</code></p>
      <ol>
        <li v-for="post in result.feed.posts" :key="post.id">
          <strong>{{ post.ranking!.score.toFixed(2) }}</strong> @{{ post.authorUsername }}: {{ post.content.slice(0, 80) }}
          <small v-if="post.ranking!.explored">explored</small>
        </li>
      </ol>
    </section>
    <p v-else-if="result?.kind === 'failed'" role="alert">{{ describe(result.error) }}</p>

    <button type="button" :disabled="saving || !!error" @click="save">Save as a new version</button>
    <p v-if="saved" role="status">{{ saved }}</p>

    <details>
      <summary>Variables and functions</summary>
      <dl>
        <template v-for="v in vocabulary.variables" :key="v.name"><dt><code>{{ v.name }}</code></dt><dd>{{ v.description }}</dd></template>
        <template v-for="f in vocabulary.functions" :key="f.name"><dt><code>{{ f.signature }}</code></dt><dd>{{ f.description }}</dd></template>
      </dl>
    </details>
  </div>
</template>

Comparing with what’s live

A preview on its own says what the feed would be, not how it differs. The Feed studio previews both the draft and the saved version for the same member and shows how far each post moved; you can do the same with two calls:

const [before, after] = await Promise.all([
  ranking.preview(savedSettings, viewer),
  ranking.preview(draftSettings, viewer),
])
const was = new Map(before.posts.map((p, i) => [p.id, i]))
const moves = after.posts.map((p, i) => ({ post: p, moved: was.has(p.id) ? was.get(p.id)! - i : null }))  // null: new to the top 30

Summaries that help people judge a change: the share of posts from accounts the member follows, how many different authors, the median age (ranking.features.age_minutes), and the share with photos. Preview for a few different members (new, heavy readers, people who follow many) before saving, because one member’s feed can mislead.

After saving

  • The profile is saved as a new version; feeds pick it up from their next first page, and anyone mid-scroll finishes their session on the old one.
  • Every page served is logged with key@version, and the dashboard compares versions on real engagement.
  • Preset keys (balanced, fresh…) can’t be saved over: 409 profile.read_only. Save under a new key and point Ranking:DefaultProfile at it to make it everyone’s default.

Edit this page on GitHub

FeedFabric is MIT licensed and open source on GitHub. These pages are built fromweb/docs; the repository's own docs/ folder has the full configuration, data model and error lists. Found something wrong? Fix it oropen an issue.