Initial commit: design-018 Phases 0-5 (stomping.me)
Auth BFF (OIDC + prompt=none silent SSO), Mongo data layer, admin CRUD (folders/tags/stories/chapters with TipTap), public reader with tag filtering. Built and verified same-session per design-018-stories.md.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// folderTree.js — re-parent cycle detection for admin folder CRUD
|
||||
// (design-018 §6 Phase 3: "incl. re-parent — must reject cycles").
|
||||
'use strict';
|
||||
|
||||
// Would setting `folderId`'s parent to `newParentId` create a cycle? Walks up
|
||||
// from the proposed new parent; if folderId's own id is ever reached, the
|
||||
// move would make folderId an ancestor of itself.
|
||||
async function wouldCreateCycle(db, folderId, newParentId) {
|
||||
if (!newParentId) return false;
|
||||
|
||||
const folderKey = String(folderId);
|
||||
if (String(newParentId) === folderKey) return true;
|
||||
|
||||
const folders = db.collection('folders');
|
||||
const seen = new Set();
|
||||
let current = newParentId;
|
||||
|
||||
while (current) {
|
||||
const key = String(current);
|
||||
if (key === folderKey) return true;
|
||||
if (seen.has(key)) break; // defensive — stop on a pre-existing corrupt loop
|
||||
seen.add(key);
|
||||
|
||||
const doc = await folders.findOne({ _id: current }, { projection: { parent_id: 1 } });
|
||||
if (!doc) break;
|
||||
current = doc.parent_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Flattens a folder list into depth-first tree order, annotating each with
|
||||
// `depth` (0 = root) for indentation. Orphaned folders (parent_id points at
|
||||
// something no longer present) are appended at depth 0 rather than dropped.
|
||||
function buildFolderTree(folders) {
|
||||
const byParent = new Map();
|
||||
const ids = new Set(folders.map(f => String(f._id)));
|
||||
for (const folder of folders) {
|
||||
const parentKey = folder.parent_id && ids.has(String(folder.parent_id))
|
||||
? String(folder.parent_id)
|
||||
: 'root';
|
||||
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
|
||||
byParent.get(parentKey).push(folder);
|
||||
}
|
||||
for (const list of byParent.values()) list.sort((a, b) => a.display_order - b.display_order);
|
||||
|
||||
const out = [];
|
||||
function walk(parentKey, depth) {
|
||||
for (const folder of byParent.get(parentKey) || []) {
|
||||
out.push({ ...folder, depth });
|
||||
walk(String(folder._id), depth + 1);
|
||||
}
|
||||
}
|
||||
walk('root', 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { wouldCreateCycle, buildFolderTree };
|
||||
@@ -0,0 +1,127 @@
|
||||
// normalize.js — shared strip-empty-to-null normalizer (design-018 §2, same
|
||||
// pattern as design-015's castNormalizer.js). Every write passes through one
|
||||
// of these before hitting Mongo, so render-if-present is enforced at the
|
||||
// write path, not scattered across templates.
|
||||
//
|
||||
// created_at/updated_at are set by the caller at insert/update time, not
|
||||
// here — matches the established convention (see castRoutes.js).
|
||||
'use strict';
|
||||
|
||||
const { renderBodyHtml } = require('./renderTiptap');
|
||||
const { sanitizeBodyHtml } = require('./sanitizeHtml');
|
||||
const { countWords } = require('./wordCount');
|
||||
|
||||
const STORY_TYPES = ['one_shot', 'serial', 'bite_size'];
|
||||
const CONTENT_RATINGS = ['general', 'teen', 'mature', 'adult'];
|
||||
const SERIAL_STATUSES = ['ongoing', 'completed', 'hiatus'];
|
||||
const ENTITY_STATUSES = ['draft', 'published'];
|
||||
const TAG_KINDS = ['explicit', 'general'];
|
||||
|
||||
function stripEmpty(value) {
|
||||
if (typeof value === 'string') return value.trim() === '' ? null : value;
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return normalizeObject(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeObject(obj) {
|
||||
const out = {};
|
||||
for (const key of Object.keys(obj)) out[key] = stripEmpty(obj[key]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeSlugArray(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map(v => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(v => v !== '');
|
||||
}
|
||||
|
||||
// ── folders (§2.1) ────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeFolder(raw) {
|
||||
return {
|
||||
slug: String(raw.slug || '').trim(),
|
||||
name: String(raw.name || '').trim(),
|
||||
parent_id: raw.parent_id ?? null,
|
||||
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── stories (§2.2) ────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeStory(raw) {
|
||||
return {
|
||||
slug: String(raw.slug || '').trim(),
|
||||
folder_id: raw.folder_id ?? null,
|
||||
title: String(raw.title || '').trim(),
|
||||
subtitle: stripEmpty(raw.subtitle ?? null),
|
||||
badges: normalizeSlugArray(raw.badges),
|
||||
summary: stripEmpty(raw.summary ?? null),
|
||||
cover_url: stripEmpty(raw.cover_url ?? null),
|
||||
story_type: STORY_TYPES.includes(raw.story_type) ? raw.story_type : 'one_shot',
|
||||
content_rating: CONTENT_RATINGS.includes(raw.content_rating) ? raw.content_rating : 'general',
|
||||
explicit_tags: normalizeSlugArray(raw.explicit_tags),
|
||||
general_tags: normalizeSlugArray(raw.general_tags),
|
||||
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
|
||||
serial_status: SERIAL_STATUSES.includes(raw.serial_status) ? raw.serial_status : null,
|
||||
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
|
||||
chronological_order: raw.chronological_order != null && raw.chronological_order !== ''
|
||||
? Number(raw.chronological_order) : null,
|
||||
published_at: raw.published_at ?? null,
|
||||
word_count: 0,
|
||||
rating_avg: null,
|
||||
rating_count: 0,
|
||||
author_user_id: Number(raw.author_user_id),
|
||||
};
|
||||
}
|
||||
|
||||
// ── chapters (§2.3) ───────────────────────────────────────────────────────
|
||||
// body_html/word_count are always derived from body_json here — never
|
||||
// accepted from the caller — so JSON stays the one source of truth.
|
||||
|
||||
function normalizeChapter(raw) {
|
||||
const bodyJson = raw.body_json && typeof raw.body_json === 'object'
|
||||
? raw.body_json
|
||||
: { type: 'doc', content: [] };
|
||||
|
||||
return {
|
||||
story_id: raw.story_id,
|
||||
slug: String(raw.slug || '').trim(),
|
||||
title: String(raw.title || '').trim(),
|
||||
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
|
||||
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
|
||||
body_json: bodyJson,
|
||||
body_html: sanitizeBodyHtml(renderBodyHtml(bodyJson)),
|
||||
note_top: stripEmpty(raw.note_top ?? null),
|
||||
note_bottom: stripEmpty(raw.note_bottom ?? null),
|
||||
word_count: countWords(bodyJson),
|
||||
published_at: raw.published_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── tags (§2.4) ───────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeTag(raw) {
|
||||
return {
|
||||
slug: String(raw.slug || '').trim().toLowerCase(),
|
||||
label: String(raw.label || '').trim(),
|
||||
kind: TAG_KINDS.includes(raw.kind) ? raw.kind : 'general',
|
||||
description: stripEmpty(raw.description ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stripEmpty,
|
||||
normalizeObject,
|
||||
normalizeFolder,
|
||||
normalizeStory,
|
||||
normalizeChapter,
|
||||
normalizeTag,
|
||||
STORY_TYPES,
|
||||
CONTENT_RATINGS,
|
||||
SERIAL_STATUSES,
|
||||
ENTITY_STATUSES,
|
||||
TAG_KINDS,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
// renderTiptap.js — TipTap JSON -> HTML, server-side. JSON is the canonical
|
||||
// source (design-018 §2.3); HTML is re-rendered from it on every save, never
|
||||
// hand-edited, never trusted from the client directly.
|
||||
'use strict';
|
||||
|
||||
const { generateHTML } = require('@tiptap/html/server');
|
||||
const extensions = require('./tiptapExtensions');
|
||||
|
||||
function renderBodyHtml(bodyJson) {
|
||||
if (!bodyJson || typeof bodyJson !== 'object') return '';
|
||||
return generateHTML(bodyJson, extensions);
|
||||
}
|
||||
|
||||
module.exports = { renderBodyHtml };
|
||||
@@ -0,0 +1,32 @@
|
||||
// sanitizeHtml.js — whitelist sanitizer for chapter body_html. TipTap's own
|
||||
// output is structured, but this is sanitized anyway (design-018 §2.3) since
|
||||
// it's a write path that will eventually be shared with other content
|
||||
// (comments, someday). Whitelist matches exactly the tags/marks the v1
|
||||
// editor extensions (StarterKit + Image, see tiptapExtensions.js) can emit —
|
||||
// nothing else is expected here, so nothing else is allowed through.
|
||||
'use strict';
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
|
||||
const OPTIONS = {
|
||||
allowedTags: [
|
||||
'p', 'br', 'hr',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'em', 's',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote',
|
||||
'a', 'img',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target', 'rel'],
|
||||
img: ['src', 'alt'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
};
|
||||
|
||||
function sanitizeBodyHtml(html) {
|
||||
return sanitizeHtml(html || '', OPTIONS);
|
||||
}
|
||||
|
||||
module.exports = { sanitizeBodyHtml };
|
||||
@@ -0,0 +1,21 @@
|
||||
// storyAggregate.js — story.word_count is the sum of its published chapters'
|
||||
// word counts (design-018 §2.2). Recomputed on every chapter save/publish-
|
||||
// toggle/delete — full recount each time, matching this project's established
|
||||
// "cheap at this scale, full recount is correct" convention (design-018 §2.6
|
||||
// documents the same tradeoff for rating aggregates).
|
||||
'use strict';
|
||||
|
||||
async function recomputeStoryWordCount(db, storyId) {
|
||||
const chapters = await db.collection('chapters')
|
||||
.find({ story_id: storyId, status: 'published' }, { projection: { word_count: 1 } })
|
||||
.toArray();
|
||||
const wordCount = chapters.reduce((sum, c) => sum + (c.word_count || 0), 0);
|
||||
|
||||
await db.collection('stories').updateOne(
|
||||
{ _id: storyId },
|
||||
{ $set: { word_count: wordCount, updated_at: new Date() } }
|
||||
);
|
||||
return wordCount;
|
||||
}
|
||||
|
||||
module.exports = { recomputeStoryWordCount };
|
||||
@@ -0,0 +1,11 @@
|
||||
// tiptapExtensions.js — shared extension set for server-side rendering
|
||||
// (renderTiptap.js) and, later, the admin editor bundle (Phase 3). One list,
|
||||
// one source of truth, per design-018 §4: StarterKit + image-by-URL. Link is
|
||||
// bundled inside StarterKit v3 by default — do not add @tiptap/extension-link
|
||||
// separately, it registers a duplicate 'link' extension name.
|
||||
'use strict';
|
||||
|
||||
const { StarterKit } = require('@tiptap/starter-kit');
|
||||
const { Image } = require('@tiptap/extension-image');
|
||||
|
||||
module.exports = [StarterKit, Image];
|
||||
@@ -0,0 +1,26 @@
|
||||
// wordCount.js — word count from TipTap JSON, computed on every chapter
|
||||
// save (design-018 §2.3). Walks the document tree collecting text node
|
||||
// content, joining across node boundaries with whitespace so words never
|
||||
// merge across a paragraph/heading/list-item break.
|
||||
'use strict';
|
||||
|
||||
function collectText(node, parts) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (typeof node.text === 'string') {
|
||||
parts.push(node.text);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) collectText(child, parts);
|
||||
}
|
||||
}
|
||||
|
||||
function countWords(bodyJson) {
|
||||
if (!bodyJson || typeof bodyJson !== 'object') return 0;
|
||||
const parts = [];
|
||||
collectText(bodyJson, parts);
|
||||
const words = parts.join(' ').trim().split(/\s+/).filter(Boolean);
|
||||
return words.length;
|
||||
}
|
||||
|
||||
module.exports = { countWords };
|
||||
Reference in New Issue
Block a user