SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Remix lineage (../remix-lineage.md) for kind 35858 maps and kind 35859 campaigns, in NIP-22
// style: uppercase tags point at the root of the remix tree, lowercase tags at the direct parent,
// and every author in the lineage is tagged with `p`. Dependency-free, so an editor can copy it.
//
// remixTags(parentEvent, { relay }) -> lineage tags for a new remix of a signed event
// parseLineage(event) -> { ok: true, lineage: {...} | null } | { ok: false, errors }
/** Kinds that can be remixed. A remix always has the same kind as its parent. */
export const REMIX_KINDS = [35858, 35859];
/** Most `p` tags a remix carries. The root author is always kept. */
export const MAX_LINEAGE_AUTHORS = 50;
const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
const HEX64 = /^[0-9a-f]{64}$/;
/**
* Tags that mark a new event as a remix of `parent`, a signed kind 35858 or 35859 event.
* `relay` is a relay hint where the parent was found. The root, its kind and its author are
* copied from the parent's own lineage, or are the parent itself when it isn't a remix.
*/
export function remixTags(parent, { relay } = {}) {
if (!parent || !REMIX_KINDS.includes(parent.kind)) throw new Error(`only kinds ${REMIX_KINDS.join(' and ')} can be remixed`);
if (!HEX64.test(parent.pubkey || '')) throw new Error('parent pubkey must be 64 lowercase hex chars');
if (!HEX64.test(parent.id || '')) throw new Error('parent must be a signed event with an id');
const parsed = parseLineage(parent);
if (!parsed.ok) throw new Error(`parent lineage is invalid:\n${parsed.errors.join('\n')}`);
const d = parent.tags.find((t) => t[0] === 'd')?.[1];
if (!ID.test(d || '')) throw new Error('parent d tag must be a valid id');
const hint = relayHint(relay);
const kind = String(parent.kind);
const address = `${kind}:${parent.pubkey}:${d}`;
const inherited = parsed.lineage;
const root = inherited
? inherited.root
: { address, eventId: parent.id, pubkey: parent.pubkey, relay: hint };
const authors = dedupe([parent.pubkey, ...(inherited?.authors || []), root.pubkey]);
if (authors.length > MAX_LINEAGE_AUTHORS) authors.splice(MAX_LINEAGE_AUTHORS - 1, Infinity, root.pubkey);
return [
withHint(['A', root.address], root.relay),
...(root.eventId ? [['E', root.eventId, root.relay || '', root.pubkey]] : []),
['K', kind],
['P', root.pubkey],
withHint(['a', address], hint),
['e', parent.id, hint || '', parent.pubkey],
['k', kind],
...authors.map((pubkey) => ['p', pubkey]),
];
}
/**
* Reads the lineage of a kind 35858 or 35859 event. Returns `lineage: null` for an original.
* Only `A`/`a` tags whose address has the event's own kind are lineage, so a campaign's `a`
* tags for its stage maps (kind 35858) are not mistaken for a parent.
*/
export function parseLineage(event) {
const kind = String(event.kind);
const tags = Array.isArray(event.tags) ? event.tags.filter((t) => Array.isArray(t) && typeof t[0] === 'string') : [];
const ofKind = (name) => tags.filter((t) => t[0] === name && typeof t[1] === 'string' && t[1].startsWith(`${kind}:`));
const roots = ofKind('A');
const parents = ofKind('a');
if (!roots.length && !parents.length) return { ok: true, lineage: null };
const errors = [];
if (roots.length !== 1 || parents.length !== 1) {
return { ok: false, errors: [`a remix needs exactly one A (root) and one a (parent) tag of kind ${kind}`] };
}
const root = readAddress(roots[0][1]);
const parent = readAddress(parents[0][1]);
if (!root) errors.push(`A tag must be ${kind}:<pubkey hex>:<id>`);
if (!parent) errors.push(`a tag must be ${kind}:<pubkey hex>:<id>`);
if (errors.length) return { ok: false, errors };
const own = `${kind}:${event.pubkey}:${tags.find((t) => t[0] === 'd')?.[1]}`;
if (parent.address === own || root.address === own) errors.push('an event cannot be a remix of itself');
for (const [name, value] of [['K', tags.find((t) => t[0] === 'K')?.[1]], ['k', tags.find((t) => t[0] === 'k')?.[1]]]) {
if (value !== undefined && value !== kind) errors.push(`${name} tag must be "${kind}"`);
}
const rootAuthor = tags.find((t) => t[0] === 'P')?.[1];
if (rootAuthor !== root.pubkey) errors.push('P tag must be the root author');
const authors = dedupe(tags.filter((t) => t[0] === 'p' && HEX64.test(t[1] || '')).map((t) => t[1]));
if (!authors.includes(parent.pubkey)) errors.push('p tags must include the parent author');
if (errors.length) return { ok: false, errors };
const eventId = (name, pubkey) => {
const t = tags.find((t) => t[0] === name && HEX64.test(t[1] || ''));
return t && (t[3] === undefined || t[3] === pubkey) ? t[1] : null;
};
return {
ok: true,
lineage: {
root: { ...root, eventId: eventId('E', root.pubkey), relay: relayHint(roots[0][2]) },
parent: { ...parent, eventId: eventId('e', parent.pubkey), relay: relayHint(parents[0][2]) },
authors,
},
};
}
function readAddress(address) {
const [kind, pubkey, id, ...rest] = address.split(':');
if (rest.length || !REMIX_KINDS.includes(Number(kind)) || !HEX64.test(pubkey || '') || !ID.test(id || '')) return null;
return { address, pubkey, id };
}
function relayHint(url) {
try {
return typeof url === 'string' && new URL(url).protocol === 'wss:' ? url : null;
} catch {
return null;
}
}
const withHint = (tag, hint) => (hint ? [...tag, hint] : tag);
const dedupe = (list) => [...new Set(list)];
