@anthusai/papyrus 1.3.0 → 1.4.0-next.10
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/amplify/data/resource.js +134 -3
- package/amplify/data/resource.ts +145 -3
- package/amplify/data/schema.js +131 -0
- package/amplify/data/schema.ts +142 -0
- package/amplify/functions/category-action/handler.ts +272 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +115 -0
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
- package/amplify/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/archive/page.tsx +3 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +8 -1
- package/app/newsroom/articles/page.tsx +10 -0
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/administration-delegations-panel.tsx +156 -0
- package/components/curation-delegation-client.ts +104 -0
- package/components/item-stream-view.tsx +63 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +154 -240
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-article-editor.tsx +138 -40
- package/components/newsroom-assignment-detail.tsx +79 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/newsroom-board-view.tsx +153 -43
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-newsprint-board.css +5 -46
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +36 -13
- package/components/newsroom-organizer.css +269 -13
- package/components/newsroom-references-view.tsx +118 -23
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +131 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +280 -679
- package/components/ui/sheet.tsx +10 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/category-repository.ts +1 -1
- package/lib/chrome-text.ts +504 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -0
- package/lib/content-item-lifecycle.ts +34 -5
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +69 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/front-mosaic.ts +1133 -0
- package/lib/furniture.ts +385 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/item-stream.ts +47 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +40 -11
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/local-preview-seed.ts +286 -0
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-assignments.ts +14 -1
- package/lib/newsroom-board.ts +99 -47
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +9 -16
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/pretext-layout.ts +22 -0
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-account-client.ts +90 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/reader-static-params.ts +2 -1
- package/lib/relevance-decisions.ts +29 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +28 -1
- package/lib/site-env.ts +5 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/lib/site-stack.ts +1 -1
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/text-columns.ts +251 -0
- package/lib/wiki.ts +56 -0
- package/package.json +46 -3
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +34 -15
- package/app/newsroom/work/page.tsx +0 -10
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { MINIMUM_COMPARISONS_FOR_AGREEMENT, SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
|
|
3
|
+
|
|
4
|
+
export const SITE_DECISION_RELATION = "site_decision_is";
|
|
5
|
+
export const SECTION_SELECTOR_CLASSIFIER_PREFIX = "section-selector-";
|
|
6
|
+
export const SECTION_FIT_CLASSIFIER_PREFIX = "section-fit-";
|
|
7
|
+
|
|
8
|
+
/** The site cyclotron's suggested Section for one reference. Advisory only. */
|
|
9
|
+
export type SectionSuggestion = {
|
|
10
|
+
decisionRelationId: string;
|
|
11
|
+
cyclotronId: string | null;
|
|
12
|
+
sectionId: string;
|
|
13
|
+
confidence: number | null;
|
|
14
|
+
/** Section id to probability, highest first. */
|
|
15
|
+
probabilities: Array<{ sectionId: string; probability: number }>;
|
|
16
|
+
decidedAt: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** How often the suggestion of one Section matched what editors later decided. */
|
|
20
|
+
export type SectionAgreement = {
|
|
21
|
+
sectionId: string;
|
|
22
|
+
comparisons: number;
|
|
23
|
+
agreement: number | null;
|
|
24
|
+
precision: number | null;
|
|
25
|
+
recall: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SiteSectionAgreement = {
|
|
29
|
+
computedAt: string | null;
|
|
30
|
+
selectorAccuracy: number | null;
|
|
31
|
+
selectorComparisons: number;
|
|
32
|
+
sections: SectionAgreement[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function parseMetadata(value: unknown): Record<string, unknown> {
|
|
36
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
37
|
+
if (typeof value !== "string") return {};
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(value);
|
|
40
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function numberOrNull(value: unknown): number | null {
|
|
47
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The part of `<cyclotronId>/<classifier>` after the last slash. */
|
|
51
|
+
export function siteClassifierName(classifierKey: string): string {
|
|
52
|
+
return classifierKey.slice(classifierKey.lastIndexOf("/") + 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Current suggested Sections keyed by reference lineage, from the relations the
|
|
57
|
+
* Newsroom already loads. Only the section-selector classifier names a Section.
|
|
58
|
+
*/
|
|
59
|
+
export function currentSectionSuggestions(relations: SemanticRelationRecord[]): Map<string, SectionSuggestion> {
|
|
60
|
+
const suggestions = new Map<string, SectionSuggestion>();
|
|
61
|
+
for (const relation of relations) {
|
|
62
|
+
if (relation.predicate !== SITE_DECISION_RELATION || relation.relationState !== "current") continue;
|
|
63
|
+
if (!siteClassifierName(relation.classifierId ?? "").startsWith(SECTION_SELECTOR_CLASSIFIER_PREFIX)) continue;
|
|
64
|
+
const metadata = parseMetadata(relation.metadata);
|
|
65
|
+
const sectionId = typeof metadata.label === "string" ? metadata.label : "";
|
|
66
|
+
if (!sectionId) continue;
|
|
67
|
+
const rawProbabilities = parseMetadata(metadata.probabilities);
|
|
68
|
+
const probabilities = Object.entries(rawProbabilities)
|
|
69
|
+
.flatMap(([candidate, probability]) => (numberOrNull(probability) === null ? [] : [{ sectionId: candidate, probability: probability as number }]))
|
|
70
|
+
.sort((left, right) => right.probability - left.probability);
|
|
71
|
+
suggestions.set(relation.subjectLineageId, {
|
|
72
|
+
decisionRelationId: relation.id,
|
|
73
|
+
cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
|
|
74
|
+
sectionId,
|
|
75
|
+
confidence: numberOrNull(relation.confidence) ?? probabilities.find((entry) => entry.sectionId === sectionId)?.probability ?? null,
|
|
76
|
+
probabilities,
|
|
77
|
+
decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return suggestions;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The site cyclotron's id, from any current Section suggestion. */
|
|
84
|
+
export function siteCyclotronIdOf(suggestions: Map<string, SectionSuggestion>): string | null {
|
|
85
|
+
for (const suggestion of suggestions.values()) if (suggestion.cyclotronId) return suggestion.cyclotronId;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The per-Section part of a cyclotron-shadow-agreement/v1 payload the site sweep wrote, or null for any other JSON. */
|
|
90
|
+
export function parseSiteSectionAgreement(json: unknown): SiteSectionAgreement | null {
|
|
91
|
+
if (!json || typeof json !== "object") return null;
|
|
92
|
+
const record = json as Record<string, unknown>;
|
|
93
|
+
if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
|
|
94
|
+
const classifiers = record.classifiers;
|
|
95
|
+
if (!classifiers || typeof classifiers !== "object") return null;
|
|
96
|
+
const sections: SectionAgreement[] = [];
|
|
97
|
+
for (const [classifier, value] of Object.entries(classifiers as Record<string, unknown>)) {
|
|
98
|
+
if (!classifier.startsWith(SECTION_FIT_CLASSIFIER_PREFIX) || !value || typeof value !== "object") continue;
|
|
99
|
+
const metrics = value as Record<string, unknown>;
|
|
100
|
+
sections.push({
|
|
101
|
+
sectionId: classifier.slice(SECTION_FIT_CLASSIFIER_PREFIX.length),
|
|
102
|
+
comparisons: numberOrNull(metrics.comparisons) ?? 0,
|
|
103
|
+
agreement: numberOrNull(metrics.agreement),
|
|
104
|
+
precision: numberOrNull(metrics.precision),
|
|
105
|
+
recall: numberOrNull(metrics.recall),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!sections.length && numberOrNull(record.selectorAccuracy) === null) return null;
|
|
109
|
+
return {
|
|
110
|
+
computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
|
|
111
|
+
selectorAccuracy: numberOrNull(record.selectorAccuracy),
|
|
112
|
+
selectorComparisons: numberOrNull(record.comparisons) ?? 0,
|
|
113
|
+
sections,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Agreement figures are worth showing only once enough editor decisions followed a suggestion. */
|
|
118
|
+
export function sectionAgreementIsMeasured(agreement: SectionAgreement | undefined): boolean {
|
|
119
|
+
return Boolean(agreement) && (agreement as SectionAgreement).comparisons >= MINIMUM_COMPARISONS_FOR_AGREEMENT && (agreement as SectionAgreement).agreement !== null;
|
|
120
|
+
}
|
package/lib/site-stack.ts
CHANGED
|
@@ -149,7 +149,7 @@ export const PILOBOL_US_THEME_PACK_TOKENS: ThemePackTokens = {
|
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us`
|
|
152
|
+
* Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us-legacy-Gatsby`
|
|
153
153
|
* (`src/styles/variables.scss` `:root`). Colors only — Shadcn owns typography.
|
|
154
154
|
* Mapping from that file's names to the pack's slots:
|
|
155
155
|
*
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the reporting process recorded about one story, gathered by the story's lineage id:
|
|
3
|
+
* the draft versions of the copy, the research, reporting and copywriting assignments with their events,
|
|
4
|
+
* the notes and packets in the threads anchored to the story or its assignments, the references it draws on,
|
|
5
|
+
* and attached files. See docs/content-item-lifecycle.md for how these records join.
|
|
6
|
+
*/
|
|
7
|
+
export type TrailVersion = {
|
|
8
|
+
id: string;
|
|
9
|
+
versionNumber: number;
|
|
10
|
+
versionState: string | null;
|
|
11
|
+
status: string | null;
|
|
12
|
+
stage: string | null;
|
|
13
|
+
headline: string | null;
|
|
14
|
+
changeReason: string | null;
|
|
15
|
+
versionCreatedBy: string | null;
|
|
16
|
+
versionCreatedAt: string | null;
|
|
17
|
+
updatedAt: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TrailAssignmentEvent = {
|
|
21
|
+
id: string;
|
|
22
|
+
eventType: string;
|
|
23
|
+
fromStatus: string | null;
|
|
24
|
+
toStatus: string | null;
|
|
25
|
+
actorLabel: string | null;
|
|
26
|
+
note: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TrailAssignment = {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
assignmentTypeKey: string;
|
|
34
|
+
status: string;
|
|
35
|
+
assigneeKey: string | null;
|
|
36
|
+
summary: string | null;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
events: TrailAssignmentEvent[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TrailMessage = {
|
|
42
|
+
id: string;
|
|
43
|
+
sequenceNumber?: number | null;
|
|
44
|
+
messageKind: string | null;
|
|
45
|
+
authorLabel: string | null;
|
|
46
|
+
summary: string | null;
|
|
47
|
+
content: string | null;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type TrailThread = {
|
|
52
|
+
id: string;
|
|
53
|
+
title: string | null;
|
|
54
|
+
threadKind: string | null;
|
|
55
|
+
summary: string | null;
|
|
56
|
+
messages: TrailMessage[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type TrailReference = {
|
|
60
|
+
relationId: string;
|
|
61
|
+
predicate: string;
|
|
62
|
+
referenceId: string;
|
|
63
|
+
referenceLineageId: string | null;
|
|
64
|
+
title: string | null;
|
|
65
|
+
sourceUri: string | null;
|
|
66
|
+
/** consulted: read during reporting; cited: a citation in the article. */
|
|
67
|
+
role: "consulted" | "cited";
|
|
68
|
+
note: string | null;
|
|
69
|
+
citationKey: string | null;
|
|
70
|
+
addedBy: string | null;
|
|
71
|
+
addedAt: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type TrailAttachment = {
|
|
75
|
+
id: string;
|
|
76
|
+
role: string | null;
|
|
77
|
+
filename: string | null;
|
|
78
|
+
mediaType: string | null;
|
|
79
|
+
byteSize: number | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type StoryReportingTrail = {
|
|
83
|
+
versions: TrailVersion[];
|
|
84
|
+
assignments: TrailAssignment[];
|
|
85
|
+
threads: TrailThread[];
|
|
86
|
+
references: TrailReference[];
|
|
87
|
+
attachments: TrailAttachment[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const EMPTY_STORY_REPORTING_TRAIL: StoryReportingTrail = { versions: [], assignments: [], threads: [], references: [], attachments: [] };
|
|
91
|
+
|
|
92
|
+
export function storyReportingTrailIsEmpty(trail: StoryReportingTrail): boolean {
|
|
93
|
+
return trail.assignments.length === 0 && trail.threads.length === 0 && trail.references.length === 0 && trail.attachments.length === 0 && trail.versions.length <= 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type StoryFeedEntry =
|
|
97
|
+
| ({ entryKind: "message"; threadTitle: string | null } & TrailMessage)
|
|
98
|
+
| { entryKind: "reference"; id: string; createdAt: string; reference: TrailReference };
|
|
99
|
+
|
|
100
|
+
/** Every message of the story's threads and every reference found for it in one chronological feed, newest first. */
|
|
101
|
+
export function storyMessageFeed(trail: StoryReportingTrail): StoryFeedEntry[] {
|
|
102
|
+
const messages: StoryFeedEntry[] = trail.threads.flatMap(thread => thread.messages.map(message => ({ ...message, entryKind: "message" as const, threadTitle: thread.title })));
|
|
103
|
+
const references: StoryFeedEntry[] = trail.references.filter(reference => reference.addedAt).map(reference => ({ entryKind: "reference" as const, id: `reference-${reference.relationId}`, createdAt: reference.addedAt as string, reference }));
|
|
104
|
+
const sequenceOf = (entry: StoryFeedEntry) => (entry.entryKind === "message" ? entry.sequenceNumber ?? 0 : 0);
|
|
105
|
+
return [...messages, ...references].sort((left, right) => right.createdAt.localeCompare(left.createdAt) || sequenceOf(right) - sequenceOf(left));
|
|
106
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const TENANT_ID_ENVIRONMENT_VARIABLE = "PAPYRUS_TENANT_ID";
|
|
2
|
+
const TENANT_KEY_SEPARATOR = "#";
|
|
3
|
+
const TENANT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
4
|
+
|
|
5
|
+
export function validateTenantId(tenantId: string): string {
|
|
6
|
+
if (!TENANT_ID_PATTERN.test(tenantId)) {
|
|
7
|
+
throw new Error(`Tenant id "${tenantId}" must be lowercase letters, digits and hyphens, starting with a letter or digit.`);
|
|
8
|
+
}
|
|
9
|
+
return tenantId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function tenantIdFromEnvironment(environment: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
13
|
+
const configured = environment[TENANT_ID_ENVIRONMENT_VARIABLE]?.trim();
|
|
14
|
+
return configured ? validateTenantId(configured) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tenantScopedValue(tenantId: string | undefined, value: string): string {
|
|
18
|
+
if (!tenantId) return value;
|
|
19
|
+
const prefix = `${tenantId}${TENANT_KEY_SEPARATOR}`;
|
|
20
|
+
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function rowBelongsToTenant(tenantId: string | undefined, row: { tenantId?: string | null } | null | undefined): boolean {
|
|
24
|
+
if (!tenantId || !row) return true;
|
|
25
|
+
return row.tenantId === tenantId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tenant a Prospectus publishing account maps to: its id as lowercase letters, digits and hyphens. */
|
|
29
|
+
export function tenantIdForProspectusAccount(accountId: string | null | undefined): string | undefined {
|
|
30
|
+
const normalized = (accountId ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
31
|
+
return normalized ? validateTenantId(normalized) : undefined;
|
|
32
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { type ArticlePublicationItem, getPublicationItemText } from "./publication-items";
|
|
2
|
+
import {
|
|
3
|
+
type LayoutCursor,
|
|
4
|
+
layoutTextLines,
|
|
5
|
+
type PreparedTextWithSegments,
|
|
6
|
+
prepareWithSegmentsCached,
|
|
7
|
+
type TextLine,
|
|
8
|
+
type TextObstacle,
|
|
9
|
+
} from "./pretext-layout";
|
|
10
|
+
import { reserveRhythmRows, type VerticalRhythm } from "./blog-rhythm";
|
|
11
|
+
import {
|
|
12
|
+
type FurnitureSufficiencyReport,
|
|
13
|
+
type LayoutConfig,
|
|
14
|
+
type SolvedChromeBox,
|
|
15
|
+
type SolvedFurniture,
|
|
16
|
+
} from "./layout-types";
|
|
17
|
+
import { SERIF_TEXT_FONT, clamp, getSpanWidth } from "./layout-config";
|
|
18
|
+
import { rectsOverlap } from "./furniture";
|
|
19
|
+
|
|
20
|
+
export function layoutTextColumns({
|
|
21
|
+
item,
|
|
22
|
+
cursor,
|
|
23
|
+
columnCount,
|
|
24
|
+
textHeight,
|
|
25
|
+
maximumTextHeightByColumn = [],
|
|
26
|
+
localConfig,
|
|
27
|
+
furniture,
|
|
28
|
+
chromeBoxes = [],
|
|
29
|
+
furnitureObstacleOffsetY = 0,
|
|
30
|
+
furnitureObstacleMarginBottom = 0,
|
|
31
|
+
minimumLineStartYByColumn = [],
|
|
32
|
+
}: {
|
|
33
|
+
item: ArticlePublicationItem;
|
|
34
|
+
cursor: LayoutCursor;
|
|
35
|
+
columnCount: number;
|
|
36
|
+
textHeight: number;
|
|
37
|
+
maximumTextHeightByColumn?: number[];
|
|
38
|
+
localConfig: LayoutConfig;
|
|
39
|
+
furniture: SolvedFurniture[];
|
|
40
|
+
chromeBoxes?: SolvedChromeBox[];
|
|
41
|
+
furnitureObstacleOffsetY?: number;
|
|
42
|
+
furnitureObstacleMarginBottom?: number;
|
|
43
|
+
minimumLineStartYByColumn?: number[];
|
|
44
|
+
}): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
|
|
45
|
+
const columns: TextLine[][] = [];
|
|
46
|
+
const preparedText = getPrepared(item, localConfig.bodyFont);
|
|
47
|
+
const columnWidth = getSpanWidth(localConfig, 1);
|
|
48
|
+
let current = { ...cursor };
|
|
49
|
+
let hasMore = true;
|
|
50
|
+
|
|
51
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
52
|
+
const result = layoutTextLines({
|
|
53
|
+
prepared: preparedText,
|
|
54
|
+
cursor: current,
|
|
55
|
+
maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
|
|
56
|
+
maxWidth: columnWidth,
|
|
57
|
+
lineHeight: localConfig.lineHeight,
|
|
58
|
+
linePaintHeight: localConfig.linePaintHeight,
|
|
59
|
+
fontSize: localConfig.bodyFontSize,
|
|
60
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
61
|
+
obstacles: getColumnTextObstacles(
|
|
62
|
+
furniture,
|
|
63
|
+
columnIndex,
|
|
64
|
+
chromeBoxes,
|
|
65
|
+
furnitureObstacleOffsetY,
|
|
66
|
+
furnitureObstacleMarginBottom,
|
|
67
|
+
minimumLineStartYByColumn[columnIndex] ?? 0,
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
columns.push(result.lines);
|
|
71
|
+
current = result.cursor;
|
|
72
|
+
hasMore = result.hasMore;
|
|
73
|
+
if (!hasMore) {
|
|
74
|
+
while (columns.length < columnCount) columns.push([]);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { columns, cursor: current, hasMore };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getPrepared(article: ArticlePublicationItem, font: string): PreparedTextWithSegments {
|
|
83
|
+
return prepareWithSegmentsCached(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
|
|
87
|
+
if (maxLines <= 0) return 0;
|
|
88
|
+
return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getColumnTextObstacles(
|
|
92
|
+
furniture: SolvedFurniture[],
|
|
93
|
+
columnIndex: number,
|
|
94
|
+
chromeBoxes: SolvedChromeBox[] = [],
|
|
95
|
+
furnitureOffsetY = 0,
|
|
96
|
+
furnitureMarginBottom = 0,
|
|
97
|
+
minimumLineStartY = 0,
|
|
98
|
+
): TextObstacle[] {
|
|
99
|
+
const topObstacle: TextObstacle[] = minimumLineStartY > 0
|
|
100
|
+
? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
|
|
101
|
+
: [];
|
|
102
|
+
const furnitureObstacles = furniture
|
|
103
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
104
|
+
.map((item) => ({
|
|
105
|
+
x: 0,
|
|
106
|
+
y: item.y + furnitureOffsetY,
|
|
107
|
+
width: Number.POSITIVE_INFINITY,
|
|
108
|
+
height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
|
|
109
|
+
}));
|
|
110
|
+
const imageClearanceObstacles = furniture
|
|
111
|
+
.filter((item) => (
|
|
112
|
+
item.kind === "image" &&
|
|
113
|
+
item.wrapsText &&
|
|
114
|
+
furnitureMarginBottom > 0 &&
|
|
115
|
+
columnIndex >= item.columnStart &&
|
|
116
|
+
columnIndex < item.columnStart + item.columnSpan
|
|
117
|
+
))
|
|
118
|
+
.map((item) => ({
|
|
119
|
+
x: 0,
|
|
120
|
+
y: item.y + furnitureOffsetY + item.height,
|
|
121
|
+
width: Number.POSITIVE_INFINITY,
|
|
122
|
+
height: furnitureMarginBottom,
|
|
123
|
+
}));
|
|
124
|
+
const chromeObstacles = chromeBoxes
|
|
125
|
+
.filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
|
|
126
|
+
.map((box) => ({
|
|
127
|
+
x: 0,
|
|
128
|
+
y: box.y,
|
|
129
|
+
width: Number.POSITIVE_INFINITY,
|
|
130
|
+
height: box.height,
|
|
131
|
+
}));
|
|
132
|
+
return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
|
|
136
|
+
return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
140
|
+
return columns.reduce(
|
|
141
|
+
(total, column, columnIndex) =>
|
|
142
|
+
total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
|
|
143
|
+
0,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getFurnitureSufficiencyReport({
|
|
148
|
+
columns,
|
|
149
|
+
textHeight,
|
|
150
|
+
furniture,
|
|
151
|
+
hasMore,
|
|
152
|
+
config,
|
|
153
|
+
}: {
|
|
154
|
+
columns: TextLine[][];
|
|
155
|
+
textHeight: number;
|
|
156
|
+
furniture: SolvedFurniture[];
|
|
157
|
+
hasMore: boolean;
|
|
158
|
+
config: LayoutConfig;
|
|
159
|
+
}): FurnitureSufficiencyReport {
|
|
160
|
+
const visibleRows = columns.reduce((total, column) => total + column.length, 0);
|
|
161
|
+
const furnitureRows = furniture.reduce(
|
|
162
|
+
(total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
|
|
163
|
+
0,
|
|
164
|
+
);
|
|
165
|
+
const accepted = (reason?: string): FurnitureSufficiencyReport => ({
|
|
166
|
+
accepted: !reason,
|
|
167
|
+
reason,
|
|
168
|
+
visibleRows,
|
|
169
|
+
furnitureRows,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (furniture.length === 0 || furnitureRows === 0) return accepted();
|
|
173
|
+
const overlapReason = getFurnitureOverlapReason(furniture);
|
|
174
|
+
if (overlapReason) return accepted(overlapReason);
|
|
175
|
+
const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
|
|
176
|
+
if (visibleRows * 4 < furnitureRows) {
|
|
177
|
+
return accepted("visible-copy-far-below-furniture-burden");
|
|
178
|
+
}
|
|
179
|
+
if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
|
|
180
|
+
return accepted("visible-copy-below-furniture-burden");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
|
|
184
|
+
column.length > 0 ? columnIndex : lastIndex
|
|
185
|
+
), -1);
|
|
186
|
+
const exhausted = !hasMore;
|
|
187
|
+
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
|
188
|
+
const visibleColumnRows = columns[columnIndex].length;
|
|
189
|
+
const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
|
|
190
|
+
const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
|
|
191
|
+
const hasSubstantialFurniture = obstacleRows >= 6;
|
|
192
|
+
const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
|
|
193
|
+
if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
|
|
194
|
+
return accepted("open-column-below-minimum-copy");
|
|
195
|
+
}
|
|
196
|
+
if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
|
|
197
|
+
return accepted("non-final-column-single-copy-row");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return accepted();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
|
|
205
|
+
if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
|
|
206
|
+
return Math.ceil(item.height / rowHeight) * item.columnSpan;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
|
|
210
|
+
for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
|
|
211
|
+
for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
|
|
212
|
+
if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
|
|
219
|
+
const intervals = furniture
|
|
220
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
221
|
+
.map((item) => ({
|
|
222
|
+
start: clamp(item.y, 0, textHeight),
|
|
223
|
+
end: clamp(item.y + item.height, 0, textHeight),
|
|
224
|
+
}))
|
|
225
|
+
.filter((interval) => interval.end > interval.start)
|
|
226
|
+
.sort((a, b) => a.start - b.start);
|
|
227
|
+
|
|
228
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
229
|
+
for (const interval of intervals) {
|
|
230
|
+
const previous = merged[merged.length - 1];
|
|
231
|
+
if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
|
|
232
|
+
else merged.push({ ...interval });
|
|
233
|
+
}
|
|
234
|
+
return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
238
|
+
return columns.reduce((count, column, columnIndex) => {
|
|
239
|
+
if (column.length > 0) return count;
|
|
240
|
+
const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
|
|
241
|
+
return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
|
|
242
|
+
}, 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getLinesHeight(lines: TextLine[]): number {
|
|
246
|
+
return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getFurnitureBottom(furniture: SolvedFurniture[]): number {
|
|
250
|
+
return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
|
|
251
|
+
}
|
package/lib/wiki.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SemanticNodeRecord } from "./category-repository";
|
|
2
|
+
|
|
3
|
+
/** The wiki's front page is an ordinary content item with this slug; its Markus body holds the dynamic components. */
|
|
4
|
+
export const WIKI_INDEX_SLUG = "wiki-index";
|
|
5
|
+
export const WIKI_PAGE_ITEM_TYPE = "wiki_page";
|
|
6
|
+
export const WIKI_CONCEPT_LINK_SCHEME = "concept:";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_WIKI_INDEX_MARKUS = `# Concept wiki
|
|
9
|
+
|
|
10
|
+
Every page in this wiki is a concept: an entity in the knowledge graph that the newsroom has reported on, researched or cited. Open one to see what it is, what it connects to, and the references behind it.
|
|
11
|
+
|
|
12
|
+
## Recently edited
|
|
13
|
+
|
|
14
|
+
::recent-concepts{limit=10}
|
|
15
|
+
|
|
16
|
+
## Browse all concepts
|
|
17
|
+
|
|
18
|
+
::concept-list
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export function conceptEditedAt(node: SemanticNodeRecord): string {
|
|
22
|
+
return node.updatedAt ?? node.versionCreatedAt ?? node.createdAt ?? "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function conceptTitle(node: SemanticNodeRecord): string {
|
|
26
|
+
return node.displayName?.trim() || node.nodeKey;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function conceptId(node: SemanticNodeRecord): string {
|
|
30
|
+
return node.lineageId ?? node.id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recentlyEditedConcepts(nodes: readonly SemanticNodeRecord[], options: { limit?: number; kind?: string | null } = {}): SemanticNodeRecord[] {
|
|
34
|
+
const { limit = 10, kind = null } = options;
|
|
35
|
+
return nodes
|
|
36
|
+
.filter(node => !kind || node.nodeKind === kind)
|
|
37
|
+
.slice()
|
|
38
|
+
.sort((left, right) => conceptEditedAt(right).localeCompare(conceptEditedAt(left)) || conceptTitle(left).localeCompare(conceptTitle(right)))
|
|
39
|
+
.slice(0, limit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function conceptsByTitle(nodes: readonly SemanticNodeRecord[], options: { kind?: string | null; limit?: number | null } = {}): SemanticNodeRecord[] {
|
|
43
|
+
const filtered = nodes.filter(node => !options.kind || node.nodeKind === options.kind).slice().sort((left, right) => conceptTitle(left).localeCompare(conceptTitle(right)));
|
|
44
|
+
return options.limit ? filtered.slice(0, options.limit) : filtered;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Finds the concept a `concept:<key>` wiki link names, by lineage id, node key or title. */
|
|
48
|
+
export function resolveConceptLink(href: string, nodes: readonly SemanticNodeRecord[]): SemanticNodeRecord | null {
|
|
49
|
+
if (!href.startsWith(WIKI_CONCEPT_LINK_SCHEME)) return null;
|
|
50
|
+
const reference = decodeURIComponent(href.slice(WIKI_CONCEPT_LINK_SCHEME.length)).trim().toLowerCase();
|
|
51
|
+
return nodes.find(node => conceptId(node).toLowerCase() === reference || node.nodeKey.toLowerCase() === reference || conceptTitle(node).toLowerCase() === reference) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function conceptWikiPath(node: SemanticNodeRecord): string {
|
|
55
|
+
return `/newsroom/concepts/${encodeURIComponent(conceptId(node))}`;
|
|
56
|
+
}
|