Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions docs-generation/scripts/sync_to_comfy_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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"
Expand Down
65 changes: 63 additions & 2 deletions docs-generation/tests/test_sync_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading