nodebb-plugin-fab-cards 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ version: "3.8"
2
+
3
+ services:
4
+ nodebb:
5
+ image: ghcr.io/nodebb/nodebb:latest
6
+ restart: unless-stopped
7
+ ports:
8
+ - "4567:4567" # comment this out if you don't want to expose NodeBB to the host, or change the first number to any port you want
9
+ volumes:
10
+ - nodebb-build:/usr/src/app/build
11
+ - nodebb-uploads:/usr/src/app/public/uploads
12
+ - nodebb-config:/opt/config
13
+ - ./install/docker/setup.json:/usr/src/app/setup.json
14
+
15
+ redis:
16
+ image: redis:8.4.0-alpine
17
+ restart: unless-stopped
18
+ command: ["redis-server", "--appendonly", "yes", "--loglevel", "warning"]
19
+ # command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] # uncomment if you want to use snapshotting instead of AOF
20
+ volumes:
21
+ - redis-data:/data
22
+
23
+ volumes:
24
+ redis-data:
25
+ driver: local
26
+ driver_opts:
27
+ o: bind
28
+ type: none
29
+ device: ./.docker/database/redis
30
+
31
+ nodebb-build:
32
+ driver: local
33
+ driver_opts:
34
+ o: bind
35
+ type: none
36
+ device: ./.docker/build
37
+
38
+ nodebb-uploads:
39
+ driver: local
40
+ driver_opts:
41
+ o: bind
42
+ type: none
43
+ device: ./.docker/public/uploads
44
+
45
+ nodebb-config:
46
+ driver: local
47
+ driver_opts:
48
+ o: bind
49
+ type: none
50
+ device: ./.docker/config
package/library.js ADDED
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const cheerio = require('cheerio');
5
+
6
+ const CARD_INDEX_PATH = path.join(__dirname, 'data', 'cards-index.json');
7
+ const CARD_HOST = 'https://cards.fabtcg.com';
8
+
9
+ let cardData = null;
10
+ let cardMatcher = null;
11
+
12
+ function loadCardData() {
13
+ if (cardData) {
14
+ return cardData;
15
+ }
16
+
17
+ cardData = require(CARD_INDEX_PATH);
18
+ const cardNames = Object.keys(cardData.cards || {});
19
+
20
+ if (!cardNames.length) {
21
+ cardMatcher = null;
22
+ return cardData;
23
+ }
24
+
25
+ const escaped = cardNames
26
+ .sort((first, second) => second.length - first.length)
27
+ .map(escapeRegExp);
28
+
29
+ cardMatcher = new RegExp(`(^|[^\\p{L}\\p{N}])(${escaped.join('|')})(?=$|[^\\p{L}\\p{N}])`, 'giu');
30
+ return cardData;
31
+ }
32
+
33
+ function normalize(input) {
34
+ return String(input || '')
35
+ .normalize('NFKC')
36
+ .toLowerCase()
37
+ .replace(/\s+/g, ' ')
38
+ .trim();
39
+ }
40
+
41
+ function escapeRegExp(value) {
42
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+
45
+ function escapeHtml(value) {
46
+ return String(value)
47
+ .replace(/&/g, '&')
48
+ .replace(/"/g, '"')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;');
51
+ }
52
+
53
+ function shouldSkipNode(node) {
54
+ const parentTag = node.parent && node.parent.tagName ? node.parent.tagName.toLowerCase() : '';
55
+ return ['a', 'code', 'pre', 'script', 'style', 'textarea'].includes(parentTag);
56
+ }
57
+
58
+ export function linkifyText(text, cards) {
59
+ if (!cardMatcher || !text || !text.trim()) {
60
+ return text;
61
+ }
62
+
63
+ cardMatcher.lastIndex = 0;
64
+ return text.replace(cardMatcher, (fullMatch, prefix, cardName) => {
65
+ const normalized = normalize(cardName);
66
+ const card = cards[normalized];
67
+
68
+ if (!card) {
69
+ return fullMatch;
70
+ }
71
+
72
+ const href = `${CARD_HOST}${card.url}`;
73
+ const safeName = escapeHtml(card.name || cardName);
74
+ const safeImage = escapeHtml(card.image || '');
75
+ const safeHref = escapeHtml(href);
76
+
77
+ return `${prefix}<a class="fab-card-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" data-fab-card-name="${safeName}" data-fab-card-image="${safeImage}">${cardName}</a>`;
78
+ });
79
+ }
80
+
81
+ function processNode($, node, cards) {
82
+ if (!node) {
83
+ return;
84
+ }
85
+
86
+ if (node.type === 'text' && !shouldSkipNode(node)) {
87
+ const original = node.data || '';
88
+ const replaced = linkifyText(original, cards);
89
+ if (replaced !== original) {
90
+ $(node).replaceWith(replaced);
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (node.children && node.children.length) {
96
+ node.children.slice().forEach((child) => processNode($, child, cards));
97
+ }
98
+ }
99
+
100
+ const plugin = {};
101
+
102
+ plugin.parsePost = async function (payload) {
103
+ const data = payload || {};
104
+ const carrier = data.postData && typeof data.postData.content === 'string'
105
+ ? data.postData
106
+ : (typeof data.content === 'string' ? data : null);
107
+
108
+ if (!carrier || !carrier.content) {
109
+ return payload;
110
+ }
111
+
112
+ const type = data.type || data.postData?.type || 'default';
113
+ if (type !== 'default') {
114
+ return payload;
115
+ }
116
+
117
+ const loaded = loadCardData();
118
+ if (!loaded || !loaded.cards || !cardMatcher) {
119
+ return payload;
120
+ }
121
+
122
+ const $ = cheerio.load(carrier.content, {
123
+ decodeEntities: false,
124
+ }, false);
125
+
126
+ const root = $.root().get(0);
127
+ if (root && root.children) {
128
+ root.children.slice().forEach((child) => processNode($, child, loaded.cards));
129
+ }
130
+
131
+ carrier.content = $.root().html() || carrier.content;
132
+ return payload;
133
+ };
134
+
135
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "nodebb-plugin-fab-cards",
3
+ "version": "0.1.0",
4
+ "description": "Auto-link Flesh and Blood card names in NodeBB posts with hover/touch previews.",
5
+ "main": "library.js",
6
+ "scripts": {
7
+ "build:cards": "node scripts/build-card-index.js"
8
+ },
9
+ "keywords": [
10
+ "nodebb",
11
+ "nodebb-plugin",
12
+ "flesh-and-blood",
13
+ "fab"
14
+ ],
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "cheerio": "^1.0.0"
18
+ },
19
+ "nbbpm": {
20
+ "compatibility": "^4.0.0"
21
+ },
22
+ "devDependencies": {},
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/braaar/nodebb-plugin-fab-cards.git"
26
+ },
27
+ "author": "braaar",
28
+ "bugs": {
29
+ "url": "https://github.com/braaar/nodebb-plugin-fab-cards/issues"
30
+ },
31
+ "homepage": "https://github.com/braaar/nodebb-plugin-fab-cards#readme"
32
+ }
package/plugin.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "nodebb-plugin-fab-cards",
3
+ "name": "FAB Cards Auto-Link",
4
+ "description": "Automatically links Flesh and Blood card names and shows card previews.",
5
+ "url": "https://github.com/fabtcg/nodebb-plugin-fab-cards",
6
+ "library": "./library.js",
7
+ "hooks": [
8
+ {
9
+ "hook": "filter:parse.post",
10
+ "method": "parsePost"
11
+ }
12
+ ],
13
+ "staticDirs": {
14
+ "public": "public"
15
+ },
16
+ "scripts": ["public/lib/client.js"],
17
+ "css": ["public/css/style.css"]
18
+ }
@@ -0,0 +1,24 @@
1
+ .fab-card-link {
2
+ text-decoration: underline;
3
+ text-underline-offset: 2px;
4
+ }
5
+
6
+ .fab-card-preview {
7
+ position: absolute;
8
+ z-index: 9999;
9
+ width: min(80vw, 280px);
10
+ border-radius: 8px;
11
+ overflow: hidden;
12
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
13
+ background: #111;
14
+ }
15
+
16
+ .fab-card-preview.hidden {
17
+ display: none;
18
+ }
19
+
20
+ .fab-card-preview img {
21
+ display: block;
22
+ width: 100%;
23
+ height: auto;
24
+ }
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ (function fabCardPreview() {
4
+ const LINK_SELECTOR = 'a.fab-card-link[data-fab-card-image]';
5
+ let previewElement = null;
6
+ let activeTouchLink = null;
7
+
8
+ function ensurePreviewElement() {
9
+ if (previewElement) {
10
+ return previewElement;
11
+ }
12
+
13
+ previewElement = document.createElement('div');
14
+ previewElement.className = 'fab-card-preview hidden';
15
+ previewElement.innerHTML = '<img alt="Card preview" loading="lazy" />';
16
+ document.body.appendChild(previewElement);
17
+ return previewElement;
18
+ }
19
+
20
+ function hidePreview() {
21
+ const element = ensurePreviewElement();
22
+ element.classList.add('hidden');
23
+ activeTouchLink = null;
24
+ }
25
+
26
+ function positionPreview(target) {
27
+ const element = ensurePreviewElement();
28
+ const rect = target.getBoundingClientRect();
29
+ const margin = 12;
30
+
31
+ const width = element.offsetWidth || 280;
32
+ const height = element.offsetHeight || 390;
33
+
34
+ let top = rect.bottom + margin;
35
+ let left = rect.left;
36
+
37
+ if (left + width > window.innerWidth - margin) {
38
+ left = Math.max(margin, window.innerWidth - width - margin);
39
+ }
40
+
41
+ if (top + height > window.innerHeight - margin) {
42
+ top = Math.max(margin, rect.top - height - margin);
43
+ }
44
+
45
+ element.style.left = `${Math.round(left + window.scrollX)}px`;
46
+ element.style.top = `${Math.round(top + window.scrollY)}px`;
47
+ }
48
+
49
+ function showPreview(link) {
50
+ const imageUrl = link.getAttribute('data-fab-card-image');
51
+ if (!imageUrl) {
52
+ return;
53
+ }
54
+
55
+ const element = ensurePreviewElement();
56
+ const image = element.querySelector('img');
57
+
58
+ image.src = imageUrl;
59
+ image.alt = link.getAttribute('data-fab-card-name') || 'Card preview';
60
+
61
+ element.classList.remove('hidden');
62
+ positionPreview(link);
63
+ }
64
+
65
+ function bindLink(link) {
66
+ if (!link || link.dataset.fabPreviewBound === 'true') {
67
+ return;
68
+ }
69
+
70
+ link.dataset.fabPreviewBound = 'true';
71
+
72
+ link.addEventListener('mouseenter', () => showPreview(link));
73
+ link.addEventListener('focus', () => showPreview(link));
74
+ link.addEventListener('mouseleave', hidePreview);
75
+ link.addEventListener('blur', hidePreview);
76
+
77
+ link.addEventListener('touchstart', (event) => {
78
+ if (activeTouchLink === link) {
79
+ activeTouchLink = null;
80
+ return;
81
+ }
82
+
83
+ event.preventDefault();
84
+ activeTouchLink = link;
85
+ showPreview(link);
86
+ }, { passive: false });
87
+ }
88
+
89
+ function bindAllLinks() {
90
+ document.querySelectorAll(LINK_SELECTOR).forEach(bindLink);
91
+ }
92
+
93
+ function registerGlobalHandlers() {
94
+ document.addEventListener('click', (event) => {
95
+ const target = event.target;
96
+ if (!(target instanceof Element) || !target.closest(LINK_SELECTOR)) {
97
+ hidePreview();
98
+ }
99
+ });
100
+
101
+ window.addEventListener('resize', hidePreview);
102
+ window.addEventListener('scroll', hidePreview, true);
103
+ }
104
+
105
+ function init() {
106
+ ensurePreviewElement();
107
+ bindAllLinks();
108
+ }
109
+
110
+ if (document.readyState === 'loading') {
111
+ document.addEventListener('DOMContentLoaded', init);
112
+ } else {
113
+ init();
114
+ }
115
+
116
+ registerGlobalHandlers();
117
+
118
+ if (typeof window !== 'undefined' && window.jQuery) {
119
+ window.jQuery(window).on('action:ajaxify.end action:posts.loaded action:topic.loaded', bindAllLinks);
120
+ }
121
+ })();
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+
6
+ const API_ENDPOINT = 'https://cards.fabtcg.com/api/search/v1/cards/?limit=200';
7
+ const OUTPUT_FILE = path.join(__dirname, '..', 'data', 'cards-index.json');
8
+
9
+ function normalize(input) {
10
+ return String(input || '')
11
+ .normalize('NFKC')
12
+ .toLowerCase()
13
+ .replace(/\s+/g, ' ')
14
+ .trim();
15
+ }
16
+
17
+ async function fetchJson(url) {
18
+ const response = await fetch(url, {
19
+ headers: {
20
+ 'accept': 'application/json',
21
+ },
22
+ });
23
+
24
+ if (!response.ok) {
25
+ throw new Error(`Request failed (${response.status}): ${url}`);
26
+ }
27
+
28
+ return response.json();
29
+ }
30
+
31
+ function toAbsoluteUrl(url) {
32
+ if (!url) {
33
+ return null;
34
+ }
35
+
36
+ try {
37
+ return new URL(url, 'https://cards.fabtcg.com').toString();
38
+ } catch (error) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ async function run() {
44
+ const cards = {};
45
+ let pageUrl = API_ENDPOINT;
46
+ let pageCount = 0;
47
+ let recordCount = 0;
48
+
49
+ while (pageUrl) {
50
+ pageCount += 1;
51
+ const payload = await fetchJson(pageUrl);
52
+
53
+ const results = Array.isArray(payload.results) ? payload.results : [];
54
+ recordCount += results.length;
55
+
56
+ for (const card of results) {
57
+ const name = String(card.name || '').trim();
58
+ if (!name) {
59
+ continue;
60
+ }
61
+
62
+ const normalized = normalize(name);
63
+ if (!normalized || cards[normalized]) {
64
+ continue;
65
+ }
66
+
67
+ const relativeCardUrl = card.url || `/card/${card.card_id || ''}/`;
68
+ const cardUrl = toAbsoluteUrl(relativeCardUrl);
69
+ const image = card.image?.normal || card.image?.large || card.image?.small || '';
70
+
71
+ if (!cardUrl || !image) {
72
+ continue;
73
+ }
74
+
75
+ cards[normalized] = {
76
+ name,
77
+ url: new URL(cardUrl).pathname,
78
+ image,
79
+ };
80
+ }
81
+
82
+ pageUrl = payload.next || null;
83
+ process.stdout.write(`Fetched page ${pageCount} - indexed ${Object.keys(cards).length} unique card names\n`);
84
+ }
85
+
86
+ const output = {
87
+ generatedAt: new Date().toISOString(),
88
+ source: API_ENDPOINT,
89
+ fetchedRecords: recordCount,
90
+ uniqueCards: Object.keys(cards).length,
91
+ cards,
92
+ };
93
+
94
+ await fs.mkdir(path.dirname(OUTPUT_FILE), { recursive: true });
95
+ await fs.writeFile(OUTPUT_FILE, JSON.stringify(output, null, 2));
96
+
97
+ process.stdout.write(`\nSaved ${output.uniqueCards} cards to ${OUTPUT_FILE}\n`);
98
+ }
99
+
100
+ run().catch((error) => {
101
+ process.stderr.write(`${error.stack || error.message}\n`);
102
+ process.exitCode = 1;
103
+ });