diff --git a/docs-generation/scripts/sync_to_comfy_docs.py b/docs-generation/scripts/sync_to_comfy_docs.py index 338184cdc..f64d27ad8 100644 --- a/docs-generation/scripts/sync_to_comfy_docs.py +++ b/docs-generation/scripts/sync_to_comfy_docs.py @@ -792,6 +792,71 @@ def _migrate_toplevel_groups_to_wrapper(pages: list[Any], wrapper_label: str) -> wrapper.append(orphan) +# Comfy-Org/docs AGENTS.md asks every page for a 40-160 character meta +# description; the generated frontmatter uses the same window. +MAX_DESCRIPTION_CHARS = 160 +_MIN_DESCRIPTION_CHARS = 40 +# Function words a shortened description may not end on, so the cut still +# reads as a whole phrase. +_DANGLING_WORDS = frozenset( + """a an the and or but nor for to of in on at by with from as such that this + these those it its their your our is are was were be been being""".split() +) + + +def _first_sentence(paragraph: str) -> str: + """Return the first sentence of ``paragraph`` (the whole text if it has none).""" + # English: ". " only ends a sentence when the next character is uppercase, + # so "e.g." / "i.e." abbreviations survive. Localized pages (Korean, …) + # start the next sentence with a non-ASCII letter, which the uppercase rule + # never matched: those pages fell through to the hard cut and lost their + # summary mid-sentence. Whichever rule matches first wins. + boundaries = [ + m.end() + for m in ( + re.search(r"\.\s+(?=[A-Z])", paragraph), + re.search(r"\.\s+(?=[^\x00-\x7f])", paragraph), + ) + if m + ] + if boundaries: + return paragraph[: min(boundaries)].strip() + idx = paragraph.find("。") + if idx != -1: + return paragraph[: idx + 1].strip() + return paragraph.strip() + + +def _trim_to_length(text: str, limit: int = MAX_DESCRIPTION_CHARS) -> str: + """Shorten ``text`` to ``limit`` characters without slicing through a word.""" + if len(text) <= limit: + return text.strip() + # Prefer the last complete sentence, then the last clause, then the last + # whole word — a hard character cut leaves search results ending in "or La". + # Endings are looked up in the whole text so the lookahead can see what + # follows the period: "ver2.5" is not a sentence ending even when the limit + # lands right after the period. + endings = [ + m.end() for m in re.finditer(r"(?:。|\.(?=\s|$))", text) if m.end() <= limit + ] + if endings and max(endings) > _MIN_DESCRIPTION_CHARS: + return text[: max(endings)].strip() + # The latest clause boundary wins, whichever separator it uses. + clause = max( + ( + text.rfind(ender, 0, limit) + for ender in (",", ",", ";", ";", "、") # noqa: RUF001 — CJK separators + ), + default=-1, + ) + if clause > _MIN_DESCRIPTION_CHARS: + return text[:clause].strip() + words = text[:limit].rsplit(" ", 1)[0].strip(" ,;:").split() + while words and words[-1].lower().strip(",;:") in _DANGLING_WORDS: + words.pop() + return " ".join(words) or text[:limit].strip() + + def get_description_from_content(content: str) -> str: """Extract the first sentence from the first content paragraph. @@ -820,21 +885,14 @@ def get_description_from_content(content: str) -> str: or "AI 生成" in line_stripped or "AI によって生成" in line_stripped or "AI에 의해 생성" in line_stripped + or "AI가 생성" in line_stripped ): continue first_para.append(line_stripped) paragraph = " ".join(first_para) if first_para else "" - # Return only the first sentence. For English, split on ". " only when the - # next char is uppercase (avoids cutting "e.g." / "i.e." abbreviations); - # CJK sentence ender "。" splits unconditionally. - m = re.search(r"\.\s+(?=[A-Z])", paragraph) - if m: - return paragraph[: m.end()] - idx = paragraph.find("。") - if idx != -1: - return paragraph[: idx + 1] - # Align fallback truncation with the 180-char frontmatter limit - return paragraph[:180] if paragraph else "" + if not paragraph: + return "" + return _trim_to_length(_first_sentence(paragraph)) def find_local_asset_refs(content: str, doc_dir: Path) -> list[tuple[str, Path]]: @@ -1010,9 +1068,10 @@ def build_frontmatter(node_name: str, description: str) -> str: seo_desc = (description or "").strip() if not seo_desc: seo_desc = f"Complete documentation for the {node_name} node in ComfyUI. Learn its inputs, outputs, parameters and usage." - # Keep meta descriptions reasonably short; strip markdown/backticks noise + # Keep meta descriptions within the docs-repo guideline (40-160 chars); + # strip markdown/backticks noise seo_desc = re.sub(r"[`*_#>]", "", seo_desc) - seo_desc = seo_desc[:180] + seo_desc = seo_desc[:MAX_DESCRIPTION_CHARS] seo_desc_escaped = _escape_frontmatter_description(seo_desc) return f"""--- title: "{node_name} - ComfyUI Built-in Node Documentation" diff --git a/docs-generation/tests/test_sync_helpers.py b/docs-generation/tests/test_sync_helpers.py index 880aa8f09..4c4a50d85 100644 --- a/docs-generation/tests/test_sync_helpers.py +++ b/docs-generation/tests/test_sync_helpers.py @@ -79,10 +79,71 @@ def test_cjk_sentence_break(self): desc = sc.get_description_from_content("这是第一句话。第二句话。") self.assertEqual(desc, "这是第一句话。") - def test_truncation_aligned_to_180(self): + def test_truncation_stays_within_the_160_char_window(self): long = "Word " * 100 desc = sc.get_description_from_content(long) - self.assertLessEqual(len(desc), 180) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertTrue(desc.endswith("Word")) + + def test_truncation_does_not_end_mid_word(self): + # A single long sentence: the cut must land on a word boundary instead + # of slicing "Latent Apply Operation or La". + sentence = ( + "This node creates a latent operation that blends a latent toward a reference latent, " + "and returns that operation so it can be plugged into the Latent Apply Operation node." + ) + desc = sc.get_description_from_content(sentence) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + # The cut must land between words/clauses, never inside one. + self.assertFalse(sentence[len(desc)].isalnum()) + + def test_localized_sentence_break_without_ascii_uppercase(self): + # Korean starts the next sentence with a non-ASCII letter, which the + # uppercase lookahead never matched: the page fell through to a hard + # 180-char cut and lost the summary mid-sentence. + content = ( + "이 노드는 레이턴트를 참조 레이턴트 쪽으로 블렌딩합니다. " + + "참조가 다른 공간 크기를 가질 경우 크기가 조정됩니다. " * 6 + ) + desc = sc.get_description_from_content(content) + self.assertEqual(desc, "이 노드는 레이턴트를 참조 레이턴트 쪽으로 블렌딩합니다.") + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + + def test_korean_disclaimer_is_skipped(self): + content = "> 이 문서는 AI가 생성했습니다.\n\n이 노드는 이미지의 밝기를 조정합니다." + self.assertEqual( + sc.get_description_from_content(content), "이 노드는 이미지의 밝기를 조정합니다." + ) + + def test_truncation_keeps_versions_intact(self): + # A period inside "2.5" is not a sentence ender, even when the text is + # long enough to be truncated. + content = ( + "It supports Seedance 2.5, 2.5 Draft, 2.0 and 2.1 with reference images " + * 4 + ).strip() + desc = sc.get_description_from_content(content) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertGreater(len(content), len(desc)) + self.assertFalse(desc.endswith("2.")) + + def test_latest_clause_separator_wins(self): + # A semicolon later than the last comma must win the clause cut. + content = "A, " + "B" * 100 + "; " + "C" * 60 + self.assertEqual(sc.get_description_from_content(content), "A, " + "B" * 100) + + def test_earliest_sentence_boundary_wins(self): + # A localized sentence boundary must not lose to a later English one. + content = "첫 번째 문장입니다. 두 번째 문장입니다. Third sentence follows." + self.assertEqual(sc.get_description_from_content(content), "첫 번째 문장입니다.") + + def test_limit_inside_a_version_is_not_a_sentence_end(self): + # The period of "ver2." sits exactly on the limit boundary; the + # lookahead has to see the following digit to reject it. + content = "x" * 154 + " ver2.5 next words here" + desc = sc.get_description_from_content(content) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertFalse(desc.endswith(".")) class PurgeNavTests(unittest.TestCase):