@liustack/mdpress 0.1.0 → 0.2.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,7 @@
1
+ async function copyToClipboard(html) {
2
+ const { setHtml } = await import("@crosscopy/clipboard");
3
+ await setHtml(html);
4
+ }
5
+ export {
6
+ copyToClipboard
7
+ };
package/dist/main.js CHANGED
@@ -294,6 +294,8 @@ const rehypeSanitizeTags = () => {
294
294
  };
295
295
  };
296
296
  const MAX_SIZE = 2 * 1024 * 1024;
297
+ const MIN_FILE_SIZE = 1 * 1024;
298
+ const MIN_DIMENSION = 120;
297
299
  function classifySource(src) {
298
300
  if (src.startsWith("data:")) return "data-uri";
299
301
  if (src.startsWith("http://") || src.startsWith("https://")) return "remote";
@@ -314,17 +316,31 @@ function detectFormat(ext) {
314
316
  return map[ext] || "image/png";
315
317
  }
316
318
  async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
319
+ if (buffer.length < MIN_FILE_SIZE) {
320
+ throw new Error(
321
+ `Image file too small: ${sourcePath} (${buffer.length} bytes, minimum ${MIN_FILE_SIZE} bytes)`
322
+ );
323
+ }
324
+ const meta = await sharp(buffer).metadata();
325
+ const width = meta.width ?? 0;
326
+ const height = meta.height ?? 0;
327
+ if (width < MIN_DIMENSION || height < MIN_DIMENSION) {
328
+ throw new Error(
329
+ `Image too small: ${sourcePath} (${width}x${height}px, minimum ${MIN_DIMENSION}x${MIN_DIMENSION}px)`
330
+ );
331
+ }
317
332
  if (mime === "image/svg+xml") {
318
- return `data:${mime};base64,${buffer.toString("base64")}`;
333
+ const pngBuffer = await sharp(buffer).png().toBuffer();
334
+ return `data:image/png;base64,${pngBuffer.toString("base64")}`;
319
335
  }
320
336
  if (mime === "image/gif") {
321
337
  let result2 = buffer;
322
338
  if (result2.length > maxSize) {
323
- let width2 = 1080;
324
- while (width2 >= 100) {
325
- result2 = await sharp(buffer, { animated: true }).resize({ width: width2, withoutEnlargement: true }).gif().toBuffer();
339
+ let gifWidth = 1080;
340
+ while (gifWidth >= 100) {
341
+ result2 = await sharp(buffer, { animated: true }).resize({ width: gifWidth, withoutEnlargement: true }).gif().toBuffer();
326
342
  if (result2.length <= maxSize) break;
327
- width2 = Math.floor(width2 * 0.7);
343
+ gifWidth = Math.floor(gifWidth * 0.7);
328
344
  }
329
345
  }
330
346
  if (result2.length > maxSize) {
@@ -334,14 +350,13 @@ async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
334
350
  }
335
351
  return `data:image/gif;base64,${result2.toString("base64")}`;
336
352
  }
337
- const metadata = await sharp(buffer).metadata();
338
- const width = metadata.width || 1920;
353
+ const origWidth = width || 1920;
339
354
  let result;
340
355
  result = await sharp(buffer).png({ compressionLevel: 6 }).toBuffer();
341
356
  if (result.length <= maxSize) {
342
357
  return `data:image/png;base64,${result.toString("base64")}`;
343
358
  }
344
- let currentWidth = width;
359
+ let currentWidth = origWidth;
345
360
  while (currentWidth >= 100) {
346
361
  currentWidth = Math.floor(currentWidth * 0.8);
347
362
  result = await sharp(buffer).resize({ width: currentWidth, withoutEnlargement: true }).png({ compressionLevel: 6 }).toBuffer();
@@ -349,7 +364,7 @@ async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
349
364
  return `data:image/png;base64,${result.toString("base64")}`;
350
365
  }
351
366
  }
352
- for (let jpegWidth = width; jpegWidth >= 100; jpegWidth = Math.floor(jpegWidth * 0.8)) {
367
+ for (let jpegWidth = origWidth; jpegWidth >= 100; jpegWidth = Math.floor(jpegWidth * 0.8)) {
353
368
  for (const quality of [85, 70, 50]) {
354
369
  result = await sharp(buffer).flatten({ background: { r: 255, g: 255, b: 255 } }).resize({ width: jpegWidth, withoutEnlargement: true }).jpeg({ quality }).toBuffer();
355
370
  if (result.length <= maxSize) {
@@ -405,7 +420,7 @@ function protectWhitespace(children) {
405
420
  }
406
421
  let text = parts[i];
407
422
  text = text.replace(/\t/g, "  ");
408
- text = text.replace(/^( +)/, (match) => " ".repeat(match.length));
423
+ text = text.replace(/ /g, " ");
409
424
  if (text) {
410
425
  result.push({ type: "text", value: text });
411
426
  }
@@ -435,22 +450,22 @@ const rehypeCodeHighlight = () => {
435
450
  });
436
451
  };
437
452
  };
438
- function isInternalLink(href) {
453
+ function classifyLink(href) {
439
454
  if (href.startsWith("//")) {
440
- return false;
455
+ return "external";
441
456
  }
442
457
  if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) {
443
- return true;
458
+ return "anchor";
444
459
  }
445
460
  try {
446
461
  const url = new URL(href);
447
462
  if (url.hostname === "mp.weixin.qq.com" || url.hostname.endsWith(".mp.weixin.qq.com")) {
448
- return true;
463
+ return "wechat";
449
464
  }
450
465
  } catch {
451
- return true;
466
+ return "anchor";
452
467
  }
453
- return false;
468
+ return "external";
454
469
  }
455
470
  const rehypeFootnoteLinks = () => {
456
471
  return (tree) => {
@@ -461,7 +476,12 @@ const rehypeFootnoteLinks = () => {
461
476
  if (node.tagName !== "a" || index === void 0 || !parent) return;
462
477
  const href = node.properties?.href;
463
478
  if (typeof href !== "string") return;
464
- if (isInternalLink(href)) return;
479
+ const linkType = classifyLink(href);
480
+ if (linkType === "wechat") return;
481
+ if (linkType === "anchor") {
482
+ parent.children.splice(index, 1, ...node.children);
483
+ return [SKIP, index + node.children.length];
484
+ }
465
485
  let num = urlMap.get(href);
466
486
  if (num === void 0) {
467
487
  counter++;
@@ -529,35 +549,39 @@ function extractText(node) {
529
549
  }
530
550
  return text;
531
551
  }
552
+ const FONT_BODY = '"mp-quote",PingFang SC,system-ui,-apple-system,BlinkMacSystemFont,Helvetica Neue,Hiragino Sans GB,Microsoft YaHei UI,Microsoft YaHei,Arial,sans-serif';
553
+ const FONT_MONO = 'Menlo,Consolas,Monaco,"Courier New",monospace';
554
+ const F = `font-family: ${FONT_BODY};`;
555
+ const FM = `font-family: ${FONT_MONO};`;
532
556
  const defaultStyles = {
533
557
  // Headings — Apple style: 600 weight, tight letter-spacing
534
- h1: "font-size: 24px; font-weight: 600; margin: 0 0 16px; line-height: 1.2; letter-spacing: -0.02em; color: #1d1d1f;",
535
- h2: "font-size: 20px; font-weight: 600; margin: 32px 0 12px; line-height: 1.25; color: #1d1d1f;",
536
- h3: "font-size: 17px; font-weight: 600; margin: 24px 0 8px; line-height: 1.3; color: #1d1d1f;",
537
- h4: "font-size: 15px; font-weight: 600; margin: 20px 0 6px; line-height: 1.4; color: #6e6e73;",
538
- h5: "font-size: 14px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #6e6e73;",
539
- h6: "font-size: 13px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #86868b;",
540
- // Paragraphs — 15px body, 1.75 line-height (comfortable for Chinese)
541
- p: "font-size: 15px; line-height: 1.75; margin: 0 0 1.25em; color: #1d1d1f;",
558
+ h1: `${F} font-size: 24px; font-weight: 600; margin: 0 0 16px; line-height: 1.2; letter-spacing: -0.02em; color: #1d1d1f;`,
559
+ h2: `${F} font-size: 20px; font-weight: 600; margin: 32px 0 12px; line-height: 1.25; color: #1d1d1f;`,
560
+ h3: `${F} font-size: 17px; font-weight: 600; margin: 24px 0 8px; line-height: 1.3; color: #1d1d1f;`,
561
+ h4: `${F} font-size: 15px; font-weight: 600; margin: 20px 0 6px; line-height: 1.4; color: #6e6e73;`,
562
+ h5: `${F} font-size: 14px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #6e6e73;`,
563
+ h6: `${F} font-size: 13px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #86868b;`,
564
+ // Paragraphs — 16px body, 1.75 line-height (comfortable for Chinese)
565
+ p: `${F} font-size: 16px; line-height: 1.75; margin: 0 0 1.25em; color: #1d1d1f;`,
542
566
  // Blockquote — restrained: left border + italic only, no background
543
- blockquote: "margin: 1.5em 0; padding: 0 0 0 1em; border-left: 3px solid #d2d2d7; color: #6e6e73; font-style: italic;",
544
- // Inline code — subtle gray background
545
- code: 'font-size: 0.875em; font-family: "SF Mono", Menlo, Consolas, monospace; background: #f5f5f7; color: #1d1d1f; padding: 0.15em 0.4em; border-radius: 4px;',
546
- // Code block — GitHub-style light gray bg, no border
547
- "pre code": 'display: block; font-size: 13px; font-family: "SF Mono", Menlo, Consolas, monospace; background: #f6f8fa; color: #1d1d1f; padding: 20px 24px; border-radius: 8px; overflow-x: auto; line-height: 1.6;',
567
+ blockquote: `${F} margin: 1.5em 0; padding: 0 0 0 1em; border-left: 3px solid #d2d2d7; color: #6e6e73; font-style: italic;`,
568
+ // Inline code — subtle gray background, monospace
569
+ code: `${FM} font-size: 0.875em; background: #f5f5f7; color: #1d1d1f; padding: 0.15em 0.4em; border-radius: 4px;`,
570
+ // Code block — GitHub-style light gray bg, no border, monospace
571
+ "pre code": `${FM} display: block; font-size: 13px; background: #f6f8fa; color: #1d1d1f; padding: 20px 24px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; line-height: 1.6; text-align: left;`,
548
572
  pre: "margin: 1.5em 0;",
549
573
  // Lists
550
- ul: "margin: 1em 0; padding-left: 1.5em; font-size: 15px; line-height: 1.75; color: #1d1d1f;",
551
- ol: "margin: 1em 0; padding-left: 1.5em; font-size: 15px; line-height: 1.75; color: #1d1d1f;",
552
- li: "margin: 0.4em 0;",
553
- // Table — clean: bottom borders only, no vertical lines
554
- table: "width: 100%; margin: 1.5em 0; font-size: 14px; border-collapse: collapse;",
555
- th: "padding: 12px 16px; text-align: left; font-weight: 600; color: #1d1d1f; font-size: 14px; border-bottom: 1px solid #d2d2d7;",
556
- td: "padding: 12px 16px; border-bottom: 1px solid #e5e5ea; color: #1d1d1f;",
574
+ ul: `${F} margin: 1em 0; padding-left: 1.5em; font-size: 16px; line-height: 1.75; color: #1d1d1f;`,
575
+ ol: `${F} margin: 1em 0; padding-left: 1.5em; font-size: 16px; line-height: 1.75; color: #1d1d1f;`,
576
+ li: `${F} margin: 0.4em 0; color: #1d1d1f;`,
557
577
  // Links — WeChat blue
558
578
  a: "color: #576b95; text-decoration: none;",
559
579
  // Images
560
- img: "max-width: 100%; height: auto; display: block; margin: 1.5em auto; border-radius: 8px;",
580
+ img: "max-width: 100%; height: auto; display: block; margin: 1.5em auto; border-radius: 10px;",
581
+ // Tables — minimalist: collapse borders, thin lines
582
+ table: `${F} width: 100%; border-collapse: collapse; margin: 1.5em 0; font-size: 16px; border-radius: 2px;`,
583
+ th: `${F} padding: 10px 12px; border-bottom: 1px solid #d2d2d7; font-weight: 600; text-align: left; color: #1d1d1f;`,
584
+ td: `${F} padding: 10px 12px; border-bottom: 1px solid #e5e5ea; color: #1d1d1f;`,
561
585
  // Horizontal rule
562
586
  hr: "border: none; height: 1px; background: #d2d2d7; margin: 2em 0;",
563
587
  // Inline formatting
@@ -569,8 +593,8 @@ const defaultStyles = {
569
593
  sup: "font-size: 0.75em; vertical-align: super; color: #576b95;",
570
594
  sub: "font-size: 0.75em; vertical-align: sub;",
571
595
  // Section / figure
572
- section: "margin: 0.5em 0;",
573
- figcaption: "font-size: 13px; color: #86868b; text-align: center; margin-top: 8px;",
596
+ section: `${F} margin: 0.5em 0;`,
597
+ figcaption: `${F} font-size: 13px; color: #86868b; text-align: center; margin-top: 8px;`,
574
598
  figure: "margin: 1.5em 0; text-align: center;",
575
599
  // Mark
576
600
  mark: "background: #fff3b0; padding: 0.1em 0.3em; border-radius: 2px;",
@@ -867,18 +891,26 @@ async function render(options) {
867
891
  const outputPath = path.resolve(output);
868
892
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
869
893
  fs.writeFileSync(outputPath, html, "utf-8");
894
+ let copied = false;
895
+ if (options.copy) {
896
+ const { copyToClipboard } = await import("./clipboard-pLkenrrh.js");
897
+ await copyToClipboard(html);
898
+ copied = true;
899
+ }
870
900
  return {
871
901
  input: inputPath,
872
902
  output: outputPath,
873
- size: Buffer.byteLength(html, "utf-8")
903
+ size: Buffer.byteLength(html, "utf-8"),
904
+ ...copied ? { copied } : {}
874
905
  };
875
906
  }
876
907
  const program = new Command();
877
- program.name("mdpress").description("Convert Markdown into WeChat MP-ready HTML").version("0.1.0").requiredOption("-i, --input <path>", "Input Markdown file path").requiredOption("-o, --output <path>", "Output HTML file path").action(async (options) => {
908
+ program.name("mdpress").description("Convert Markdown into WeChat MP-ready HTML").version("0.2.0").requiredOption("-i, --input <path>", "Input Markdown file path").requiredOption("-o, --output <path>", "Output HTML file path").option("-c, --copy", "Copy rendered HTML to clipboard via Playwright").action(async (options) => {
878
909
  try {
879
910
  const result = await render({
880
911
  input: options.input,
881
- output: options.output
912
+ output: options.output,
913
+ copy: options.copy
882
914
  });
883
915
  console.log(JSON.stringify(result, null, 2));
884
916
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/mdpress",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for AI agents to convert Markdown into WeChat MP-ready HTML with inline styles, base64 images, and tag sanitization",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "node": ">=18"
33
33
  },
34
34
  "dependencies": {
35
+ "@crosscopy/clipboard": "^0.3.6",
35
36
  "@types/hast": "^3.0.4",
36
37
  "commander": "^13.1.0",
37
38
  "rehype-highlight": "^7.0.2",