@rmdes/indiekit-endpoint-posts 1.0.0-beta.34 → 1.0.0-beta.36
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.
- package/lib/controllers/edit.js +30 -5
- package/lib/utils.js +250 -0
- package/package.json +1 -1
package/lib/controllers/edit.js
CHANGED
|
@@ -1,23 +1,48 @@
|
|
|
1
|
-
import { getPostPropertiesByUrl } from "../utils.js";
|
|
1
|
+
import { getPostPropertiesByUrl, importPostFromFile } from "../utils.js";
|
|
2
2
|
|
|
3
3
|
export const editController = {
|
|
4
4
|
async get(request, response, next) {
|
|
5
5
|
try {
|
|
6
6
|
const { url } = request.query;
|
|
7
|
-
const { application } = request.app.locals;
|
|
7
|
+
const { application, publication } = request.app.locals;
|
|
8
|
+
|
|
9
|
+
console.log("[edit] url=%s", url);
|
|
8
10
|
|
|
9
11
|
if (!url) {
|
|
12
|
+
console.log("[edit] no url, redirecting to posts list");
|
|
10
13
|
return response.redirect(request.baseUrl);
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
// Try MongoDB lookup first (works for posts created via Micropub)
|
|
13
17
|
const properties = await getPostPropertiesByUrl(url, application);
|
|
14
18
|
|
|
15
|
-
if (
|
|
16
|
-
|
|
19
|
+
if (properties) {
|
|
20
|
+
const target = `${request.baseUrl}/${properties.uid}/update`;
|
|
21
|
+
console.log("[edit] found in DB, redirecting to %s", target);
|
|
22
|
+
return response.redirect(target);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log("[edit] not in DB, trying on-demand import");
|
|
26
|
+
console.log("[edit] publication.store=%o", !!publication.store);
|
|
27
|
+
console.log(
|
|
28
|
+
"[edit] store.options=%o",
|
|
29
|
+
publication.store?.options,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// On-demand import: post not in MongoDB, try reading from disk
|
|
33
|
+
const imported = await importPostFromFile(url, publication, application);
|
|
34
|
+
|
|
35
|
+
if (imported) {
|
|
36
|
+
const target = `${request.baseUrl}/${imported.uid}/update`;
|
|
37
|
+
console.log("[edit] imported, redirecting to %s", target);
|
|
38
|
+
return response.redirect(target);
|
|
17
39
|
}
|
|
18
40
|
|
|
19
|
-
|
|
41
|
+
// Neither in DB nor on disk — redirect to posts list
|
|
42
|
+
console.log("[edit] import failed, redirecting to posts list");
|
|
43
|
+
return response.redirect(request.baseUrl);
|
|
20
44
|
} catch (error) {
|
|
45
|
+
console.error("[edit] error:", error);
|
|
21
46
|
next(error);
|
|
22
47
|
}
|
|
23
48
|
},
|
package/lib/utils.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
|
|
3
5
|
import { sanitise, ISO_6709_RE } from "@indiekit/util";
|
|
4
6
|
import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
|
|
@@ -357,3 +359,251 @@ export const getSyndicateToItems = (publication, checkTargets = false) => {
|
|
|
357
359
|
}),
|
|
358
360
|
}));
|
|
359
361
|
};
|
|
362
|
+
|
|
363
|
+
/** Directory name → post-type mapping */
|
|
364
|
+
const dirToPostType = {
|
|
365
|
+
articles: "article",
|
|
366
|
+
notes: "note",
|
|
367
|
+
likes: "like",
|
|
368
|
+
bookmarks: "bookmark",
|
|
369
|
+
photos: "photo",
|
|
370
|
+
replies: "reply",
|
|
371
|
+
reposts: "repost",
|
|
372
|
+
pages: "page",
|
|
373
|
+
videos: "video",
|
|
374
|
+
audio: "audio",
|
|
375
|
+
jams: "jam",
|
|
376
|
+
rsvps: "rsvp",
|
|
377
|
+
events: "event",
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Derive file path from a post URL
|
|
382
|
+
* Supports both Eleventy format (/content/TYPE/YYYY-MM-DD-slug/)
|
|
383
|
+
* and clean Indiekit format (/TYPE/YYYY/MM/DD/slug)
|
|
384
|
+
* @param {string} url - Full URL or pathname
|
|
385
|
+
* @returns {string|false} Relative file path (e.g. "articles/2019-01-12-slug.md")
|
|
386
|
+
*/
|
|
387
|
+
export const deriveFilePathFromUrl = (url) => {
|
|
388
|
+
try {
|
|
389
|
+
let pathname = url;
|
|
390
|
+
|
|
391
|
+
// Extract pathname from full URL
|
|
392
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
393
|
+
pathname = new URL(url).pathname;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Strip trailing slash
|
|
397
|
+
pathname = pathname.replace(/\/$/, "");
|
|
398
|
+
|
|
399
|
+
// Format 1: /content/TYPE/YYYY-MM-DD-slug (Eleventy output URL)
|
|
400
|
+
if (pathname.startsWith("/content/")) {
|
|
401
|
+
const relative = pathname.replace(/^\/content\//, "");
|
|
402
|
+
return `${relative}.md`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Format 2: /TYPE/YYYY/MM/DD/slug (clean Indiekit URL)
|
|
406
|
+
const match = pathname.match(
|
|
407
|
+
/^\/([a-z]+)\/(\d{4})\/(\d{2})\/(\d{2})\/(.+)$/,
|
|
408
|
+
);
|
|
409
|
+
if (match) {
|
|
410
|
+
const [, type, year, month, day, slug] = match;
|
|
411
|
+
return `${type}/${year}-${month}-${day}-${slug}.md`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return false;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Parse YAML frontmatter and content from a Markdown file string
|
|
422
|
+
* @param {string} fileContent - Raw file content
|
|
423
|
+
* @returns {{frontmatter: object, content: string}}
|
|
424
|
+
*/
|
|
425
|
+
const parseFrontmatterAndContent = (fileContent) => {
|
|
426
|
+
const fmMatch = fileContent.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
427
|
+
if (!fmMatch) {
|
|
428
|
+
return { frontmatter: {}, content: fileContent };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Parse YAML manually (simple key-value and arrays)
|
|
432
|
+
const yamlStr = fmMatch[1];
|
|
433
|
+
const content = fmMatch[2] || "";
|
|
434
|
+
const frontmatter = {};
|
|
435
|
+
|
|
436
|
+
let currentKey = null;
|
|
437
|
+
let inArray = false;
|
|
438
|
+
|
|
439
|
+
for (const line of yamlStr.split("\n")) {
|
|
440
|
+
// Array item
|
|
441
|
+
if (inArray && line.startsWith(" - ")) {
|
|
442
|
+
if (!Array.isArray(frontmatter[currentKey])) {
|
|
443
|
+
frontmatter[currentKey] = [];
|
|
444
|
+
}
|
|
445
|
+
frontmatter[currentKey].push(line.replace(/^\s+- /, "").trim());
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Key-value pair
|
|
450
|
+
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
|
|
451
|
+
if (kvMatch) {
|
|
452
|
+
currentKey = kvMatch[1];
|
|
453
|
+
const value = kvMatch[2].trim();
|
|
454
|
+
inArray = false;
|
|
455
|
+
|
|
456
|
+
if (value === "") {
|
|
457
|
+
// Could be start of array or empty value
|
|
458
|
+
inArray = true;
|
|
459
|
+
frontmatter[currentKey] = [];
|
|
460
|
+
} else {
|
|
461
|
+
// Strip quotes
|
|
462
|
+
frontmatter[currentKey] = value.replace(/^["']|["']$/g, "");
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
inArray = false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { frontmatter, content };
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Clean up migrated post content
|
|
474
|
+
* Strips common cruft from old posts (date headers, webmention footers)
|
|
475
|
+
* @param {string} content - Raw markdown content
|
|
476
|
+
* @returns {string} Cleaned content
|
|
477
|
+
*/
|
|
478
|
+
const cleanMigratedContent = (content) => {
|
|
479
|
+
let cleaned = content;
|
|
480
|
+
|
|
481
|
+
// Strip leading date-link headers: [ January 12, 2019 ](https://...)
|
|
482
|
+
cleaned = cleaned.replace(
|
|
483
|
+
/^\s*\[\s*[A-Z][a-z]+ \d{1,2},? \d{4}\s*\]\s*\(https?:\/\/[^)]+\)\s*/,
|
|
484
|
+
"",
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Strip leading H2 that duplicates the title with a link
|
|
488
|
+
cleaned = cleaned.replace(/^\s*## \[[^\]]+\]\(https?:\/\/[^)]+\)\s*\n?/, "");
|
|
489
|
+
|
|
490
|
+
// Strip "X min read" after title headers
|
|
491
|
+
cleaned = cleaned.replace(/^\s*\d+ min read\s*\n?/, "");
|
|
492
|
+
|
|
493
|
+
// Strip trailing webmention reaction sections
|
|
494
|
+
// Common pattern: a horizontal rule followed by likes/reposts/mentions
|
|
495
|
+
cleaned = cleaned.replace(
|
|
496
|
+
/\n---+\s*\n[\s\S]*?(liked|reposted|replied|mentioned|bookmarked)\s+this\b[\s\S]*$/i,
|
|
497
|
+
"",
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
return cleaned.trim();
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Import a migrated post from disk into MongoDB (on-demand)
|
|
505
|
+
* Called when the edit controller can't find the post in the database.
|
|
506
|
+
* @param {string} url - The post's mpUrl (used to derive file path)
|
|
507
|
+
* @param {object} publication - Publication config (has store, me)
|
|
508
|
+
* @param {object} application - Application config (has collections)
|
|
509
|
+
* @returns {Promise<{uid: string}|false>} The new post's uid, or false on failure
|
|
510
|
+
*/
|
|
511
|
+
export const importPostFromFile = async (url, publication, application) => {
|
|
512
|
+
try {
|
|
513
|
+
const postsCollection = application?.collections?.get("posts");
|
|
514
|
+
if (!postsCollection) {
|
|
515
|
+
console.log("[import] FAIL: no posts collection");
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check if already imported (race condition guard)
|
|
520
|
+
const existing = await postsCollection.findOne({ "properties.url": url });
|
|
521
|
+
if (existing) {
|
|
522
|
+
console.log("[import] already in DB by url, uid=%s", existing._id);
|
|
523
|
+
return { uid: existing._id.toString() };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Derive file path from URL
|
|
527
|
+
const relativePath = deriveFilePathFromUrl(url);
|
|
528
|
+
if (!relativePath) {
|
|
529
|
+
console.log("[import] FAIL: could not derive path from url=%s", url);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
console.log("[import] relativePath=%s", relativePath);
|
|
533
|
+
|
|
534
|
+
// Get content directory from the store
|
|
535
|
+
const contentDir = publication.store?.options?.directory;
|
|
536
|
+
if (!contentDir) {
|
|
537
|
+
console.log(
|
|
538
|
+
"[import] FAIL: no contentDir. store=%o, store.options=%o",
|
|
539
|
+
typeof publication.store,
|
|
540
|
+
publication.store?.options,
|
|
541
|
+
);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const absolutePath = path.join(contentDir, relativePath);
|
|
546
|
+
console.log("[import] absolutePath=%s", absolutePath);
|
|
547
|
+
|
|
548
|
+
// Check if also already imported by path
|
|
549
|
+
const existingByPath = await postsCollection.findOne({
|
|
550
|
+
path: relativePath,
|
|
551
|
+
});
|
|
552
|
+
if (existingByPath) {
|
|
553
|
+
console.log("[import] already in DB by path, uid=%s", existingByPath._id);
|
|
554
|
+
return { uid: existingByPath._id.toString() };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Read the file
|
|
558
|
+
let fileContent;
|
|
559
|
+
try {
|
|
560
|
+
fileContent = await fs.readFile(absolutePath, "utf-8");
|
|
561
|
+
} catch (readError) {
|
|
562
|
+
console.log(
|
|
563
|
+
"[import] FAIL: could not read file %s: %s",
|
|
564
|
+
absolutePath,
|
|
565
|
+
readError.message,
|
|
566
|
+
);
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
console.log("[import] file read OK, length=%d", fileContent.length);
|
|
570
|
+
|
|
571
|
+
// Parse frontmatter and content
|
|
572
|
+
const { frontmatter, content } = parseFrontmatterAndContent(fileContent);
|
|
573
|
+
console.log("[import] frontmatter keys=%s", Object.keys(frontmatter));
|
|
574
|
+
|
|
575
|
+
// Derive post-type from directory name
|
|
576
|
+
const dirName = relativePath.split("/")[0];
|
|
577
|
+
const postType = dirToPostType[dirName] || "note";
|
|
578
|
+
|
|
579
|
+
// Clean the content
|
|
580
|
+
const cleanedContent = cleanMigratedContent(content);
|
|
581
|
+
|
|
582
|
+
// Build JF2 properties
|
|
583
|
+
const properties = {
|
|
584
|
+
url,
|
|
585
|
+
"post-type": postType,
|
|
586
|
+
...(frontmatter.date && { published: frontmatter.date }),
|
|
587
|
+
...(frontmatter.title && { name: frontmatter.title }),
|
|
588
|
+
...(cleanedContent && { content: { text: cleanedContent } }),
|
|
589
|
+
...(frontmatter.category && {
|
|
590
|
+
category: Array.isArray(frontmatter.category)
|
|
591
|
+
? frontmatter.category
|
|
592
|
+
: [frontmatter.category],
|
|
593
|
+
}),
|
|
594
|
+
...(frontmatter.visibility && { visibility: frontmatter.visibility }),
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Insert into MongoDB
|
|
598
|
+
const result = await postsCollection.insertOne({
|
|
599
|
+
path: relativePath,
|
|
600
|
+
properties,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
console.log("[import] SUCCESS: inserted uid=%s", result.insertedId);
|
|
604
|
+
return { uid: result.insertedId.toString() };
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error("[import] ERROR:", error.message, error.stack);
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-posts",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.36",
|
|
4
4
|
"description": "Post management endpoint for Indiekit with syndicate form fix. View posts published by your Micropub endpoint and publish new posts to it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|