diff --git a/server/api/services/remixer-service.ts b/server/api/services/remixer-service.ts index 9d528ffb..1331b07b 100644 --- a/server/api/services/remixer-service.ts +++ b/server/api/services/remixer-service.ts @@ -22,6 +22,7 @@ import { shouldSkipPage, } from "../../util/remixerutils"; import * as cheerio from "cheerio"; +import { detectTranscludeStub } from "../../util/transclusion.js"; import { RemixerSubPage } from "../../types/Remixer"; import BookService from "./book-service"; const remixerLog = childLogger("remixer"); @@ -1015,25 +1016,18 @@ const renamePageToIntended = async ( } }; -// Matches CrossTransclude/Web template — captures Library (group 1) and PageID (group 2) -const CROSS_TRANSLUDE_SOURCE_RE = - /template\(\s*['"]CrossTransclude\/Web['"]\s*,\s*\{[\s\S]*?['"]Library['"]\s*:\s*['"]([^'"]+)['"][\s\S]*?['"]PageID['"]\s*:\s*(\d+)/i; - -// Matches rendered HTML form of content-reuse widget — captures data-page value (group 1) -const CONTENT_REUSE_WIDGET_RE = - /
-const WIKI_PAGE_REUSE_RE = /wiki\.page\s*\(\s*["'"]/i;
class RestackerService {
private pageTags: Map;
@@ -229,94 +223,86 @@ class RestackerService {
private async isTranscluded(
pageID: string,
library: string,
- ): Promise<{isTranscluded: boolean, sourceLicense: { label: string; raw: string; version: string } | undefined}> {
+ ): Promise<{
+ isTranscluded: boolean;
+ /**
+ * The page carries reuse markup of any kind, stub or embedded. Broader than
+ * `isTranscluded` and kept separate because the quotation rate treats any
+ * reused content as quoted, while tagging and license inheritance apply
+ * only to a page that is wholly someone else's.
+ */
+ reusesContent: boolean;
+ sourceLicense: { label: string; raw: string; version: string } | undefined;
+ }> {
+ const notTranscluded = (reusesContent = false) => ({
+ isTranscluded: false,
+ reusesContent,
+ sourceLicense: undefined,
+ });
+
try {
const bookService = new BookService({ bookID: `${library}-${pageID}` });
const rawContents = await bookService.getPageRawContent(pageID);
if (!rawContents) {
- return {isTranscluded: false, sourceLicense: undefined};
+ return notTranscluded();
+ }
+ // Only a page whose whole body is a pointer at another page is
+ // transcluded. A page that merely embeds content-reuse blocks owns its
+ // content: tagging it `transcluded:yes` or giving it the embedded
+ // block's license would both be wrong.
+ const stub = detectTranscludeStub(rawContents);
+ if (!stub) {
+ return notTranscluded(containsReuseMarkup(rawContents));
}
- // The API returns a JSON envelope; extract the HTML body for all further matching
- let content = rawContents;
- try {
- const parsed = JSON.parse(rawContents);
- if (typeof parsed?.body === "string") content = parsed.body;
- } catch {}
-
- const isCrossTranscluded =
- CROSS_TRANSLUDE_SOURCE_RE.test(content);
- const isContentReused =
- CONTENT_REUSE_WIDGET_RE.test(content) ||
- WIKI_PAGE_REUSE_RE.test(content);
-
- if(isContentReused||isCrossTranscluded){
- const tags = this.pageTags.get(this.pageTagsKey(library, pageID));
- // check if transcluded tag is set
- const transcludedTag = tags?.find((tag) => tag["@value"].startsWith("transcluded:"));
- if(!transcludedTag){
- // if not add it to the page on cxone
- const existingTags = tags?.map((tag) => tag["@value"]) ?? [];
- const newTags = existingTags.includes("transcluded:yes")
- ? existingTags
- : existingTags.concat("transcluded:yes");
- await bookService.updatePageDetails(pageID, undefined, newTags);
- }
- }
- if (isContentReused) {
- // Extract source page path — try data-page attribute first (rendered HTML),
- // then fall back to wiki.page() argument (" is the HTML-entity form of " in the body)
- const dataPageMatch = content.match(/data-page=["']([^"']+)["']/i);
- const wikiPageMatch = content.match(/wiki\.page\s*\(\s*(?:["']|")([^"'&]+)/i);
- const rawSourcePath = dataPageMatch?.[1] ?? wikiPageMatch?.[1];
-
- if (rawSourcePath) {
- const sourcePath = decodeURIComponent(rawSourcePath);
- const tags = await this.getCachedPageTags(
- library,
- sourcePath,
- `${library}-${pageID}`,
- );
- const licenseTag = tags?.find((tag) => tag["@value"].startsWith("license:"));
- const licenseVersionTag = tags?.find((tag) =>
- tag["@value"].startsWith("licenseversion:"),
- );
- if (licenseTag) {
- return {
- isTranscluded: true,
- sourceLicense: {
- label: licenseTag["@value"],
- raw: licenseVersionTag?.["@value"] ?? "",
- version: licenseVersionTag?.["@value"] ?? "",
- },
- };
- }
- }
+ const tags = this.pageTags.get(this.pageTagsKey(library, pageID));
+ // check if transcluded tag is set
+ const transcludedTag = tags?.find((tag) =>
+ tag["@value"].startsWith("transcluded:"),
+ );
+ if (!transcludedTag) {
+ // if not add it to the page on cxone
+ const existingTags = tags?.map((tag) => tag["@value"]) ?? [];
+ const newTags = existingTags.includes("transcluded:yes")
+ ? existingTags
+ : existingTags.concat("transcluded:yes");
+ await bookService.updatePageDetails(pageID, undefined, newTags);
}
- if(isCrossTranscluded){
-
- // extract the library and pageID
- const crossLibrary = content.match(/Library':['"]([^'"]+)['"]/i)?.[1];
- const crossPageID = content.match(/PageID':(\d+)/i)?.[1];
- if (crossLibrary && crossPageID) {
- const tags = await this.getCachedPageTags(
- crossLibrary,
- crossPageID,
- `${crossLibrary}-${crossPageID}`,
- );
- const licenseTag = tags?.find((tag) => tag["@value"].startsWith("license:"));
- const licenseVersionTag = tags?.find((tag) =>
- tag["@value"].startsWith("licenseversion:"),
+
+ const sourceTags =
+ stub.kind === "cross-library"
+ ? await this.getCachedPageTags(
+ stub.subdomain,
+ String(stub.pageID),
+ `${stub.subdomain}-${stub.pageID}`,
+ )
+ : await this.getCachedPageTags(
+ library,
+ stub.path,
+ `${library}-${pageID}`,
);
- if(licenseTag ){
- return {isTranscluded: true, sourceLicense: {label: licenseTag["@value"], raw: licenseVersionTag?.["@value"] || "", version: licenseVersionTag?.["@value"] || ""}};
- }
- }
- }
-
- return {isTranscluded: isCrossTranscluded || isContentReused, sourceLicense: undefined};
+
+ const licenseTag = sourceTags?.find((tag) =>
+ tag["@value"].startsWith("license:"),
+ );
+ const licenseVersionTag = sourceTags?.find((tag) =>
+ tag["@value"].startsWith("licenseversion:"),
+ );
+ if (licenseTag) {
+ return {
+ isTranscluded: true,
+ reusesContent: true,
+ sourceLicense: {
+ label: licenseTag["@value"],
+ raw: licenseVersionTag?.["@value"] ?? "",
+ version: licenseVersionTag?.["@value"] ?? "",
+ },
+ };
+ }
+
+ return { isTranscluded: true, reusesContent: true, sourceLicense: undefined };
} catch (error) {
- return {isTranscluded: false, sourceLicense: undefined};
+ return notTranscluded();
}
}
@@ -384,7 +370,12 @@ class RestackerService {
}
}
const transcludedInfo = await this.isTranscluded(pageID, library);
- const quotationRate = transcludedInfo.isTranscluded?1:this.getQuotationRate(page);
+ // A page built entirely from someone else's content is 100% quotation.
+ // getQuotationRate only counts lt-- classed text nodes, so it
+ // reads reuse widgets as 0 — hence the pin for any page carrying them.
+ const quotationRate = transcludedInfo.reusesContent
+ ? 1
+ : this.getQuotationRate(page);
const licenseMap = new Map();
for (const license of licenses) {
diff --git a/server/util/transclusion.ts b/server/util/transclusion.ts
new file mode 100644
index 00000000..d2324d15
--- /dev/null
+++ b/server/util/transclusion.ts
@@ -0,0 +1,231 @@
+import * as cheerio from "cheerio";
+
+/**
+ * Detection for MindTouch transclusion "stub" pages — pages whose entire body is
+ * a pointer at another page, as produced by `RemixerTemplates.POST_Transclude*`.
+ *
+ * The distinction that matters: a page may *contain* content-reuse blocks
+ * (an authored page pulling in a shared definition, a prerequisites list, etc.)
+ * without *being* a transclusion of anything. Treating the two the same makes a
+ * copy of the page resolve to whatever its first embedded block happened to
+ * point at, discarding the page's real content.
+ */
+
+/** Matches CrossTransclude/Web — captures Library (group 1) and PageID (group 2). */
+export const CROSS_TRANSCLUDE_SOURCE_RE =
+ /template\(\s*['"]CrossTransclude\/Web['"]\s*,\s*\{[\s\S]*?['"]Library['"]\s*:\s*['"]([^'"]+)['"][\s\S]*?['"]PageID['"]\s*:\s*(\d+)/i;
+
+/** Matches the rendered content-reuse widget — captures the data-page value (group 1). */
+export const CONTENT_REUSE_WIDGET_RE =
+ /]+class=["'][^"']*mt-contentreuse-widget[^"']*["'][^>]+data-page=["']([^"']+)["']/i;
+
+/** Matches a raw wikitext `wiki.page("path", ...)` call — captures the path (group 1). */
+export const WIKI_PAGE_REUSE_RE =
+ /wiki\.page\s*\(\s*(?:["']|")([^"'&]+)/i;
+
+/**
+ * Whole-page form only: the second argument is NULL. A string second argument
+ * names a section, which means the call pulls a fragment into a larger page.
+ */
+const WIKI_PAGE_WHOLE_RE =
+ /wiki\.page\s*\(\s*(?:["']|")([^"'&]+)(?:["']|")\s*,\s*NULL\s*\)?/i;
+
+/**
+ * MindTouch stores page paths only partially encoded — the fixtures carry raw
+ * `:` in `data-page` — so a title containing a bare `%` reaches us as an
+ * invalid escape and `decodeURIComponent` throws. A path we cannot decode is
+ * still usable as-is, and is far better than an exception unwinding into the
+ * remixer publish job.
+ */
+const safeDecodePath = (value: string): string => {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+};
+
+export type TranscludeStub =
+ | { kind: "cross-library"; subdomain: string; pageID: number }
+ | { kind: "same-library"; path: string };
+
+/**
+ * The page-contents API sometimes hands back a JSON envelope rather than bare
+ * HTML. Both callers need the HTML body, so unwrap once here.
+ */
+export const unwrapPageBody = (rawContents: string): string => {
+ try {
+ const parsed = JSON.parse(rawContents);
+ const body = parsed?.body;
+ if (typeof body === "string") return body;
+ if (Array.isArray(body) && typeof body[0] === "string") return body[0];
+ } catch {
+ // Not JSON — already raw HTML.
+ }
+ return rawContents;
+};
+
+/**
+ * True when the body carries any transclusion or content-reuse markup at all,
+ * whether the page is a stub or an authored page embedding reused blocks.
+ */
+export const containsReuseMarkup = (rawContents: string): boolean => {
+ const html = unwrapPageBody(rawContents);
+ return (
+ CROSS_TRANSCLUDE_SOURCE_RE.test(html) ||
+ CONTENT_REUSE_WIDGET_RE.test(html) ||
+ WIKI_PAGE_REUSE_RE.test(html)
+ );
+};
+
+const countOccurrences = (haystack: string, needle: string): number => {
+ let count = 0;
+ let index = haystack.indexOf(needle);
+ while (index !== -1) {
+ count += 1;
+ index = haystack.indexOf(needle, index + needle.length);
+ }
+ return count;
+};
+
+/** Scaffolding a generated stub is allowed to carry alongside its pointer. */
+const STUB_SCAFFOLD_SELECTORS = [
+ // Attribute form, not an escaped-colon class selector — css-select rejects
+ // `.template\:tag-insert`.
+ 'p[class~="template:tag-insert"]',
+ "p.mt-script-comment",
+ "div.comment",
+ "script",
+ "style",
+];
+
+/**
+ * Layout wrappers a WYSIWYG editor leaves behind. Only ignorable when
+ * completely empty \u2014 no text, no attributes, no children. Anything else,
+ * including an `
` or a ``, is authored content.
+ */
+const EMPTY_LAYOUT_TAGS = new Set(["p", "div", "span", "br"]);
+
+/**
+ * True when anything survives after the pointer and its scaffolding are
+ * removed. Enumerating "content" element types is a losing game \u2014 `