@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.
@@ -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 (!properties) {
16
- return response.redirect(request.baseUrl);
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
- response.redirect(`${request.baseUrl}/${properties.uid}/update`);
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.34",
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",