// The area plugins on the site: Compose+, Reader+, … are built into wayBetter for X (feature // modules compiled into the app, docs/AREA-PLUGINS.md), so they are never in the signed catalog and // have nothing to install. Each still gets a page in the Store (/store/areas//) from one folder: // // store//areas//item.json { "category", "tags", "license", "credits", cover/screenshots/video } // // The folder name is the area's id in the app's feature table; the name, description and feature // list come from the table and the app's own words, so the page and the app cannot disagree. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; export const AREA_CATEGORIES = ['reading', 'writing', 'filters', 'focus', 'tools']; const FIELDS = ['category', 'tags', 'license', 'credits', 'cover', 'screenshots', 'video']; const text = (value, max) => typeof value === 'string' && [...value].length <= max && !/[<>]|[\u0000-\u001f\u007f]/.test(value); /** * Reads store//areas/. `ids`: the areas of the app's feature table, in its order. Returns one * entry per area ({ id, category, tags, license, credits, dir }) and the problems (a missing or * extra folder, unknown fields, bad values). */ export function readAreaSource({ root, app, ids }) { const base = join(root, 'store', app, 'areas'); const problems = []; const entries = []; const folders = existsSync(base) ? readdirSync(base).filter(name => statSync(join(base, name)).isDirectory()).sort() : []; for (const extra of folders.filter(name => !ids.includes(name))) problems.push(`store/${app}/areas/${extra}: no area of the app has this id (${ids.join(', ')})`); for (const id of ids) { const where = `store/${app}/areas/${id}`; const path = join(base, id, 'item.json'); if (!existsSync(path)) { problems.push(`${where}: item.json is missing (every area has a page)`); continue; } let info; try { info = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { problems.push(`${where}/item.json: not valid JSON (${error.message})`); continue; } if (!info || typeof info !== 'object' || Array.isArray(info)) { problems.push(`${where}/item.json: must be an object`); continue; } for (const key of Object.keys(info)) if (!FIELDS.includes(key)) problems.push(`${where}/item.json: unknown field "${key}" (allowed: ${FIELDS.join(', ')})`); if (!AREA_CATEGORIES.includes(info.category)) problems.push(`${where}/item.json: "category" must be one of ${AREA_CATEGORIES.join(', ')}`); const tags = Array.isArray(info.tags) ? info.tags : []; if (!Array.isArray(info.tags) || tags.length > 12 || tags.some(tag => typeof tag !== 'string' || !tag.trim() || [...tag].length > 32)) problems.push(`${where}/item.json: tags: a list of at most 12 words, each 1 to 32 characters`); if (info.license !== undefined && (!text(info.license, 60) || !info.license.trim())) problems.push(`${where}/item.json: "license" is 1 to 60 plain characters`); if (info.credits !== undefined && !text(info.credits, 600)) problems.push(`${where}/item.json: "credits" is at most 600 plain characters`); entries.push({ id, category: info.category, tags: tags.filter(tag => typeof tag === 'string'), license: info.license, credits: info.credits, dir: where }); } return { entries, problems }; }