dsh-plugin-lookatstudy 0.25.0 → 0.25.1

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/index.mjs CHANGED
@@ -266,7 +266,6 @@ const ZH = {
266
266
  "chat.reasoning": "思考过程({n} 字)",
267
267
  "chat.think": "思考",
268
268
  "langview.original": "原文",
269
- "langview.bilingual": "对照",
270
269
  "langview.translation": "译文",
271
270
  "msg.speak": "朗读",
272
271
  "note.pin": "置顶笔记",
@@ -638,7 +637,6 @@ const EN = {
638
637
  "chat.reasoning": "Thinking ({n} chars)",
639
638
  "chat.think": "Think",
640
639
  "langview.original": "Original",
641
- "langview.bilingual": "Both",
642
640
  "langview.translation": "Translation",
643
641
  "msg.speak": "Speak",
644
642
  "note.pin": "Pin note",
@@ -838,21 +836,6 @@ function escapeHtml(text) {
838
836
  function inline(escaped) {
839
837
  return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+|\/lookatstudy\/api\/attachment\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
840
838
  }
841
- /** Interleave one body with its translation, paragraph by paragraph (upstream
842
- * translation-layout "microsoft" semantics: original paragraph then its
843
- * translation as a quote, tight pairing for bilingual reading; length
844
- * mismatches append the remainder in order). Pure. */
845
- function renderBilingual(bodyMd, translationMd) {
846
- const par = (md) => md.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== "");
847
- const a = par(bodyMd);
848
- const b = par(translationMd);
849
- const out = [];
850
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
851
- if (a[i] !== void 0) out.push(a[i]);
852
- if (b[i] !== void 0) out.push(`> ${b[i].split("\n").join("\n> ")}`);
853
- }
854
- return out.join("\n\n");
855
- }
856
839
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
857
840
  function isTableRow(line) {
858
841
  return line.trim().startsWith("|") && line.trim().endsWith("|") && line.includes("|", 1);
@@ -3138,7 +3121,6 @@ function workbenchState(state, now) {
3138
3121
  markdown: ref.lesson.body,
3139
3122
  speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
3140
3123
  ...ref.lesson.translation !== void 0 ? {
3141
- bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
3142
3124
  translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
3143
3125
  translationLang: ref.lesson.translationLang ?? ""
3144
3126
  } : {}
@@ -6599,6 +6581,50 @@ function validateDesign(design, validFiles) {
6599
6581
  droppedLessons
6600
6582
  };
6601
6583
  }
6584
+ /**
6585
+ * Heading similarity (word-level overlap) — the translation misalignment
6586
+ * guard. Ported verbatim from upstream import-pipeline.ts (their audit F16,
6587
+ * 2026-09-13): ordinal alignment assumes both heading lists share structure;
6588
+ * a machine-translated file with a dropped header shifts every ordinal, and
6589
+ * A's translation would silently land on B's node — overlap too low skips the
6590
+ * segment (宁缺毋错). Empty titles cannot be checked and pass conservatively.
6591
+ */
6592
+ function headingsSimilar(a, b) {
6593
+ const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean);
6594
+ const A = norm(a);
6595
+ const B = norm(b);
6596
+ if (A.length === 0 || B.length === 0) return true;
6597
+ if (A.join(" ") === B.join(" ")) return true;
6598
+ const setB = new Set(B);
6599
+ return A.filter((w) => setB.has(w)).length / A.length >= .3;
6600
+ }
6601
+ /**
6602
+ * Replace a translation body's image references BY POSITION with the
6603
+ * original lesson's images (upstream method, import-pipeline.ts): translated
6604
+ * figure i ← original figure i (the original's refs are already absolute
6605
+ * CDN URLs or inlined data URLs); extras beyond the original count drop
6606
+ * (machine translation adding figures is rare — dropping is cleanest);
6607
+ * HTML imgs convert to markdown. One unified regex pass so HTML→markdown
6608
+ * conversion is never re-scanned with a shared index.
6609
+ */
6610
+ function replaceImagesByPosition(content, originalImgs) {
6611
+ let imgIdx = 0;
6612
+ return content.replace(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi, (match, mdAlt, _mdSrc, htmlSrc) => {
6613
+ if (imgIdx >= originalImgs.length) return "";
6614
+ const replacement = originalImgs[imgIdx++];
6615
+ if (htmlSrc !== void 0) return `![${/alt=["']([^"']*)["']/i.exec(match)?.[1] ?? ""}](${replacement})`;
6616
+ return `![${mdAlt}](${replacement})`;
6617
+ });
6618
+ }
6619
+ /** The original lesson body's image refs in order (the position-mapping source). */
6620
+ function originalImagesOf(body) {
6621
+ const out = [];
6622
+ for (const m of body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)|<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) {
6623
+ const src = m[2] ?? m[3] ?? "";
6624
+ if (src !== "") out.push(src);
6625
+ }
6626
+ return out;
6627
+ }
6602
6628
  /** GitHub-style anchor slug for ParsedLesson/ParsedSection anchors. */
6603
6629
  function slugAnchor(title) {
6604
6630
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -6635,13 +6661,6 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6635
6661
  const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
6636
6662
  const isFirst = !firstLessonSeen.has(lesson.file);
6637
6663
  firstLessonSeen.add(lesson.file);
6638
- const pair = translations?.get(lesson.file);
6639
- let translation;
6640
- if (pair !== void 0) {
6641
- const tHeadings = extractHeadings(pair.content);
6642
- const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
6643
- translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
6644
- }
6645
6664
  const body = sliceLessonBody(content, headings, titleIndex, isFirst);
6646
6665
  if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
6647
6666
  title: lesson.title,
@@ -6649,6 +6668,16 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
6649
6668
  chars: body.length,
6650
6669
  splittable: splittableHeadingCount(headings, titleIndex)
6651
6670
  });
6671
+ const pair = translations?.get(lesson.file);
6672
+ let translation;
6673
+ if (pair !== void 0) {
6674
+ const tHeadings = extractHeadings(pair.content);
6675
+ let tIndex = titleIndex;
6676
+ let keep = true;
6677
+ if (lesson.anchor === null || titleIndex === -1) tIndex = -1;
6678
+ else if (titleIndex >= tHeadings.length || !headingsSimilar(lesson.title, tHeadings[titleIndex].title)) keep = false;
6679
+ if (keep) translation = replaceImagesByPosition(sliceLessonBody(pair.content, tHeadings, tIndex, isFirst), originalImagesOf(body));
6680
+ }
6652
6681
  return {
6653
6682
  title: lesson.title,
6654
6683
  anchor: slugAnchor(lesson.title),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "packageManager": "pnpm@11.7.0",
5
5
  "description": "Turn any markdown, local folder, or GitHub learning repo into a guided course inside DeepSeek Harness: gated skill-tree progression, BKT mastery tracking, SM-2 spaced repetition.",
6
6
  "type": "module",