@anthusai/papyrus 4.0.0 → 5.1.0

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.
@@ -607,6 +607,12 @@ const schema = a.schema({
607
607
  .returns(a.json())
608
608
  .authorization((allow) => [allow.groups(contentWriteGroups)])
609
609
  .handler(a.handler.function(contentActions)),
610
+ moveStoryStage: a
611
+ .mutation()
612
+ .arguments({ input: a.json().required() })
613
+ .returns(a.json())
614
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
615
+ .handler(a.handler.function(contentActions)),
610
616
  unpublishItem: a
611
617
  .mutation()
612
618
  .arguments({ input: a.json().required() })
@@ -662,6 +662,13 @@ const schema = a.schema({
662
662
  .authorization((allow) => [allow.groups(contentWriteGroups)])
663
663
  .handler(a.handler.function(contentActions)),
664
664
 
665
+ moveStoryStage: a
666
+ .mutation()
667
+ .arguments({ input: a.json().required() })
668
+ .returns(a.json())
669
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
670
+ .handler(a.handler.function(contentActions)),
671
+
665
672
  unpublishItem: a
666
673
  .mutation()
667
674
  .arguments({ input: a.json().required() })
@@ -515,6 +515,12 @@ const baseDefinitions = {
515
515
  .returns(a.json())
516
516
  .authorization((allow) => [allow.groups(contentWriteGroups)])
517
517
  .handler(a.handler.function(contentActions)),
518
+ moveStoryStage: a
519
+ .mutation()
520
+ .arguments({ input: a.json().required() })
521
+ .returns(a.json())
522
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
523
+ .handler(a.handler.function(contentActions)),
518
524
  unpublishItem: a
519
525
  .mutation()
520
526
  .arguments({ input: a.json().required() })
@@ -564,6 +564,13 @@ const baseDefinitions = {
564
564
  .authorization((allow) => [allow.groups(contentWriteGroups)])
565
565
  .handler(a.handler.function(contentActions)),
566
566
 
567
+ moveStoryStage: a
568
+ .mutation()
569
+ .arguments({ input: a.json().required() })
570
+ .returns(a.json())
571
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
572
+ .handler(a.handler.function(contentActions)),
573
+
567
574
  unpublishItem: a
568
575
  .mutation()
569
576
  .arguments({ input: a.json().required() })
@@ -23,6 +23,7 @@ from papyrus_content.rebuild_trigger import (
23
23
  trigger_staging_build,
24
24
  )
25
25
  from papyrus_content.story_reporting import save_story_version
26
+ from papyrus_content.publication import ensure_publication_from_environment
26
27
  from papyrus_content.reference_intake import IntakeError, intake_references
27
28
  from papyrus_content.publishing import (
28
29
  ItemFields,
@@ -151,6 +152,7 @@ def schedule_unpurged_invalidation(keys: list[str]) -> None:
151
152
 
152
153
  def run_deploy_render_task(client) -> dict:
153
154
  """Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
155
+ ensure_publication_from_environment(client)
154
156
  context = projection_context(client)
155
157
  report = deploy_render(context)
156
158
  print(drift_metric_line(report))
@@ -284,6 +286,17 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
284
286
  return response
285
287
 
286
288
 
289
+ def move_story_stage_action(arguments: dict, actor: str) -> dict:
290
+ from papyrus_content.story_reporting import move_story_stage
291
+
292
+ client, _claims = create_authoring_client()
293
+ try:
294
+ result = move_story_stage(client, arguments["lineageId"], arguments["stage"], actor=actor, reason=arguments.get("reason"))
295
+ except ValueError as error:
296
+ return {"ok": False, "errors": [{"code": "invalid-stage", "message": str(error), "line": None}]}
297
+ return {"ok": True, **result}
298
+
299
+
287
300
  def request_staging_build_action() -> dict:
288
301
  cms_app_id = (os.environ.get("PAPYRUS_CMS_AMPLIFY_APP_ID") or "").strip()
289
302
  if not cms_app_id:
@@ -312,6 +325,8 @@ def handler(event, context):
312
325
  return save_item_draft_action(arguments, actor)
313
326
  if field == "publishItem":
314
327
  return publish_item_action(arguments, actor)
328
+ if field == "moveStoryStage":
329
+ return move_story_stage_action(arguments, actor)
315
330
  if field == "unpublishItem":
316
331
  return unpublish_item_action(arguments, actor)
317
332
  if field == "requestStagingBuild":
@@ -1 +1 @@
1
- limatus[jev]>=0.32,<0.33
1
+ humanus[jev]>=0.33,<0.34
@@ -6,10 +6,10 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
9
- /** AppSync gives a resolver 30 seconds; Limatus's classifier deadline (settings, default 20 s) keeps a check inside this. */
9
+ /** AppSync gives a resolver 30 seconds; Humanus's classifier deadline (settings, default 20 s) keeps a check inside this. */
10
10
  export const EDITORIAL_CHECKS_TIMEOUT_SECONDS = 28;
11
- /** Limatus with the Jev classifier lane; papyrus_content.editorial_checks does the work. */
12
- export const editorialChecksRequirements = ["limatus[jev]>=0.32,<0.33"];
11
+ /** Humanus with the Jev classifier lane; papyrus_content.editorial_checks does the work. */
12
+ export const editorialChecksRequirements = ["humanus[jev]>=0.33,<0.34"];
13
13
  function installRequirements(outputDir) {
14
14
  execFileSync("python3", [
15
15
  "-m", "pip", "install", "-r", path.join(functionDirectory, "requirements.txt"), "-t", outputDir, "--no-cache-dir",
@@ -9,11 +9,11 @@ import { pythonLambdaCode } from "../shared/python-bundle";
9
9
 
10
10
  const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
11
11
 
12
- /** AppSync gives a resolver 30 seconds; Limatus's classifier deadline (settings, default 20 s) keeps a check inside this. */
12
+ /** AppSync gives a resolver 30 seconds; Humanus's classifier deadline (settings, default 20 s) keeps a check inside this. */
13
13
  export const EDITORIAL_CHECKS_TIMEOUT_SECONDS = 28;
14
14
 
15
- /** Limatus with the Jev classifier lane; papyrus_content.editorial_checks does the work. */
16
- export const editorialChecksRequirements = ["limatus[jev]>=0.32,<0.33"];
15
+ /** Humanus with the Jev classifier lane; papyrus_content.editorial_checks does the work. */
16
+ export const editorialChecksRequirements = ["humanus[jev]>=0.33,<0.34"];
17
17
 
18
18
  function installRequirements(outputDir: string): void {
19
19
  execFileSync(
@@ -2,9 +2,9 @@ import { a } from "@aws-amplify/backend";
2
2
  import { editorialChecks } from "./resource.js";
3
3
  const editors = ["editor", "admin"];
4
4
  /**
5
- * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Limatus checks
5
+ * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Humanus checks
6
6
  * content against them. Editors read policies and record decisions; admins
7
- * manage policies and settings. Only the function (IAM) calls Limatus.
7
+ * manage policies and settings. Only the function (IAM) calls Humanus.
8
8
  */
9
9
  export const editorialChecksSchemaDefinitions = {
10
10
  EditorialPolicy: a
@@ -4,9 +4,9 @@ import { editorialChecks } from "./resource";
4
4
  const editors = ["editor", "admin"];
5
5
 
6
6
  /**
7
- * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Limatus checks
7
+ * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Humanus checks
8
8
  * content against them. Editors read policies and record decisions; admins
9
- * manage policies and settings. Only the function (IAM) calls Limatus.
9
+ * manage policies and settings. Only the function (IAM) calls Humanus.
10
10
  */
11
11
  export const editorialChecksSchemaDefinitions = {
12
12
  EditorialPolicy: a
@@ -22,6 +22,29 @@ function rewriteToIndexDocument(uri) {
22
22
  return uri;
23
23
  }
24
24
 
25
+ // Pattern redirects: data from the site's projection.json, spliced between the markers at deploy
26
+ // time by redirect-rules.ts. Each rule is {p: regex, t: target with $1..$9, s: status, x: excluded paths}.
27
+ const patternRules = /*PAPYRUS_PATTERN_RULES*/[]/*END_PAPYRUS_PATTERN_RULES*/;
28
+
29
+ function lookUpPatternRedirect(uri) {
30
+ for (let index = 0; index < patternRules.length; index += 1) {
31
+ const rule = patternRules[index];
32
+ if (rule.x.indexOf(uri) !== -1) {
33
+ continue;
34
+ }
35
+ const match = new RegExp(rule.p).exec(uri);
36
+ if (match !== null) {
37
+ return {
38
+ status: rule.s,
39
+ target: rule.t.replace(/\$([0-9])/g, function (whole, group) {
40
+ return match[Number(group)] || "";
41
+ }),
42
+ };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
25
48
  async function handler(event) {
26
49
  const request = event.request;
27
50
  const redirectTarget = await lookUpRedirectTarget(request.uri);
@@ -32,6 +55,14 @@ async function handler(event) {
32
55
  headers: { location: { value: redirectTarget } },
33
56
  };
34
57
  }
58
+ const patternRedirect = lookUpPatternRedirect(request.uri);
59
+ if (patternRedirect !== null) {
60
+ return {
61
+ statusCode: patternRedirect.status,
62
+ statusDescription: "Moved",
63
+ headers: { location: { value: patternRedirect.target } },
64
+ };
65
+ }
35
66
  request.uri = rewriteToIndexDocument(request.uri);
36
67
  return request;
37
68
  }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
4
+ const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
5
+ /**
6
+ * The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
7
+ * `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
8
+ */
9
+ export function edgeRulesFromSettings(settings) {
10
+ const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
11
+ p: rule.pattern,
12
+ t: rule.to,
13
+ s: rule.status ?? 301,
14
+ x: rule.except ?? [],
15
+ }));
16
+ if (settings.urls?.style === "directory") {
17
+ rules.push({ p: "^/(.+)\\.html$", t: "/$1/", s: 301, x: ["/404.html"] });
18
+ }
19
+ return rules;
20
+ }
21
+ export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
22
+ if (staticDir === undefined)
23
+ return [];
24
+ const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
25
+ if (!existsSync(settingsPath))
26
+ return [];
27
+ return edgeRulesFromSettings(JSON.parse(readFileSync(settingsPath, "utf8")));
28
+ }
29
+ /** The CloudFront Function source with the site's pattern rules spliced in; the source unchanged when there are none. */
30
+ export function compileRedirectFunctionSource(source, rules) {
31
+ const start = source.indexOf(START_MARKER);
32
+ const end = source.indexOf(END_MARKER);
33
+ if (start === -1 || end === -1 || end < start) {
34
+ throw new Error("redirect function source has no pattern-rule markers");
35
+ }
36
+ if (rules.length === 0)
37
+ return source;
38
+ const literal = JSON.stringify(rules);
39
+ return source.slice(0, start + START_MARKER.length) + literal + source.slice(end);
40
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /** One pattern redirect as the edge function reads it: regex, target with $1..$9, status, excluded paths. */
5
+ export type EdgeRedirectRule = { p: string; t: string; s: number; x: string[] };
6
+
7
+ const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
8
+ const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
9
+
10
+ type ProjectionRedirects = { patterns?: { pattern: string; to: string; status?: number; except?: string[] }[] };
11
+ type ProjectionSettings = { urls?: { style?: string }; redirects?: ProjectionRedirects };
12
+
13
+ /**
14
+ * The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
15
+ * `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
16
+ */
17
+ export function edgeRulesFromSettings(settings: ProjectionSettings): EdgeRedirectRule[] {
18
+ const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
19
+ p: rule.pattern,
20
+ t: rule.to,
21
+ s: rule.status ?? 301,
22
+ x: rule.except ?? [],
23
+ }));
24
+ if (settings.urls?.style === "directory") {
25
+ rules.push({ p: "^/(.+)\\.html$", t: "/$1/", s: 301, x: ["/404.html"] });
26
+ }
27
+ return rules;
28
+ }
29
+
30
+ export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
31
+ if (staticDir === undefined) return [];
32
+ const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
33
+ if (!existsSync(settingsPath)) return [];
34
+ return edgeRulesFromSettings(JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings);
35
+ }
36
+
37
+ /** The CloudFront Function source with the site's pattern rules spliced in; the source unchanged when there are none. */
38
+ export function compileRedirectFunctionSource(source: string, rules: EdgeRedirectRule[]): string {
39
+ const start = source.indexOf(START_MARKER);
40
+ const end = source.indexOf(END_MARKER);
41
+ if (start === -1 || end === -1 || end < start) {
42
+ throw new Error("redirect function source has no pattern-rule markers");
43
+ }
44
+ if (rules.length === 0) return source;
45
+ const literal = JSON.stringify(rules);
46
+ return source.slice(0, start + START_MARKER.length) + literal + source.slice(end);
47
+ }
@@ -7,11 +7,13 @@ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
7
7
  import * as route53 from "aws-cdk-lib/aws-route53";
8
8
  import * as s3 from "aws-cdk-lib/aws-s3";
9
9
  import * as sqs from "aws-cdk-lib/aws-sqs";
10
+ import { readFileSync } from "node:fs";
10
11
  import { dirname, resolve } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { assertValidPublicationConfig } from "../site-backend-config.js";
13
14
  import { addRenditionsFunction } from "./renditions-function.js";
14
15
  import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
16
+ import { compileRedirectFunctionSource, readEdgeRules } from "./redirect-rules.js";
15
17
  import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
16
18
  import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
17
19
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
@@ -70,7 +72,7 @@ export function addS3ProjectionPublication(scope, props, media) {
70
72
  const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
71
73
  const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
72
74
  runtime: cloudfront.FunctionRuntime.JS_2_0,
73
- code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
75
+ code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeRules(props.staticDir))),
74
76
  keyValueStore,
75
77
  });
76
78
  const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
@@ -10,11 +10,13 @@ import * as route53 from "aws-cdk-lib/aws-route53";
10
10
  import * as s3 from "aws-cdk-lib/aws-s3";
11
11
  import * as sqs from "aws-cdk-lib/aws-sqs";
12
12
  import type { Construct } from "constructs";
13
+ import { readFileSync } from "node:fs";
13
14
  import { dirname, resolve } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
16
17
  import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
17
18
  import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
19
+ import { compileRedirectFunctionSource, readEdgeRules } from "./redirect-rules";
18
20
  import { addInvalidationPathAlarm } from "./invalidation-alarm";
19
21
  import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
20
22
 
@@ -112,7 +114,9 @@ export function addS3ProjectionPublication(
112
114
  const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
113
115
  const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
114
116
  runtime: cloudfront.FunctionRuntime.JS_2_0,
115
- code: cloudfront.FunctionCode.fromFile({ filePath: redirectFunctionSourcePath }),
117
+ code: cloudfront.FunctionCode.fromInline(
118
+ compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeRules(props.staticDir)),
119
+ ),
116
120
  keyValueStore,
117
121
  });
118
122
  const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
@@ -313,6 +313,11 @@ export function defineSiteBackend(papyrusSite) {
313
313
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
314
314
  const contentActionsLambda = backend.contentActions.resources.lambda;
315
315
  grantNewsroomReadWriteDelete(contentActionsLambda);
316
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
317
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
318
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
319
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
320
+ }
316
321
  // AppSync validates the token; pending intake additionally binds its actor
317
322
  // and publication to this deployment's pool and existing API endpoint.
318
323
  contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
@@ -399,6 +399,11 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
399
399
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
400
400
  const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
401
401
  grantNewsroomReadWriteDelete(contentActionsLambda);
402
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
403
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
404
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
405
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
406
+ }
402
407
  // AppSync validates the token; pending intake additionally binds its actor
403
408
  // and publication to this deployment's pool and existing API endpoint.
404
409
  contentActionsLambda.addEnvironment(
@@ -67,8 +67,8 @@ function lines(text: string): string[] {
67
67
  return text.split("\n").map((line) => line.trim()).filter(Boolean);
68
68
  }
69
69
 
70
- /** The policy as Limatus sees it (its key is the policy id). Throws on invalid detector JSON. */
71
- function limatusPolicy(draft: Draft): Record<string, unknown> {
70
+ /** The policy as Humanus sees it (its key is the policy id). Throws on invalid detector JSON. */
71
+ function humanusPolicy(draft: Draft): Record<string, unknown> {
72
72
  const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
73
73
  if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
74
74
  const violates = lines(draft.violatesText);
@@ -133,7 +133,7 @@ export function EditorialChecksAdminPanel() {
133
133
  const key = draft.record.policyKey.trim();
134
134
  if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
135
135
  if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
136
- const policy = limatusPolicy(draft);
136
+ const policy = humanusPolicy(draft);
137
137
  const record: EditorialPolicyRecord = {
138
138
  ...draft.record,
139
139
  id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
@@ -252,7 +252,7 @@ export function EditorialChecksAdminPanel() {
252
252
  <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
253
253
  <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
254
254
  <label>
255
- Detectors (JSON; see <code>limatus schema policy-pack-v1</code>)
255
+ Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
256
256
  <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
257
257
  </label>
258
258
  <div className="flex flex-wrap gap-2">
@@ -293,7 +293,7 @@ export function EditorialChecksAdminPanel() {
293
293
  <Button
294
294
  disabled={working !== null || !tryText.trim()}
295
295
  onClick={() => run("try", async () => {
296
- setTryResult(await tryPolicy(limatusPolicy(draft), tryText));
296
+ setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
297
297
  })}
298
298
  size="sm"
299
299
  type="button"
@@ -200,7 +200,6 @@ export type ProcedureRunRecord = {
200
200
  newsroomFeedKey?: string | null;
201
201
  };
202
202
 
203
- const DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-newsroom-sections.yml");
204
203
  const NEWSROOM_SECTION_TYPES = new Set<NewsroomSectionType>(["canonical", "floating", "rotating"]);
205
204
  type NewsroomSectionSeedRow = Omit<NewsroomSectionRecord, "sortOrder" | "enabled" | "enabledStatus" | "createdAt" | "updatedAt"> & {
206
205
  enabled: boolean;
@@ -690,10 +689,10 @@ export type CategorySteeringDashboard = {
690
689
  loadError?: string | null;
691
690
  };
692
691
 
693
- const DEFAULT_ANALYSIS_PROFILES_PATH = path.join(process.cwd(), "corpora", "papyrus-analysis-profiles.yml");
694
- const DEFAULT_STEERING_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-steering.yml");
695
692
 
696
- export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_PROFILES_PATH): Promise<AnalysisProfileSummary[]> {
693
+ /** Analysis profiles the site keeps in a file; a site that configures none (data-driven sites) has none. No default file is looked up. */
694
+ export async function loadAnalysisProfileSummaries(filepath?: string): Promise<AnalysisProfileSummary[]> {
695
+ if (!filepath) return [];
697
696
  try {
698
697
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
699
698
  schemaVersion?: number;
@@ -722,7 +721,9 @@ export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_P
722
721
  }
723
722
  }
724
723
 
725
- export async function loadConfiguredCorpusSummaries(filepath = DEFAULT_STEERING_CONFIG_PATH): Promise<CategorySteeringCorpus[]> {
724
+ /** Corpora a site lists in a steering file; no configured file means none. No default file is looked up. */
725
+ export async function loadConfiguredCorpusSummaries(filepath?: string): Promise<CategorySteeringCorpus[]> {
726
+ if (!filepath) return [];
726
727
  try {
727
728
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
728
729
  corpora?: Array<Record<string, unknown>>;
@@ -818,7 +819,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
818
819
  });
819
820
  }
820
821
 
821
- function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
822
+ function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
+ if (!sectionsConfigPath) return [];
822
824
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
823
825
  ...row,
824
826
  enabledStatus: "enabled",
@@ -828,7 +830,7 @@ function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAUL
828
830
  }));
829
831
  }
830
832
 
831
- function loadNewsroomSectionSeedRows(sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH) {
833
+ function loadNewsroomSectionSeedRows(sectionsConfigPath: string) {
832
834
  const cached = newsroomSectionSeedRowsCache.get(sectionsConfigPath);
833
835
  if (cached) return cached;
834
836
  const parsed = YAML.parse(fs.readFileSync(sectionsConfigPath, "utf8")) as {
@@ -883,7 +885,7 @@ function normalizeNewsroomSectionSeedRow(
883
885
 
884
886
  export function createDemoCategorySteeringDashboard(
885
887
  brandId: SiteBrandId = resolveSiteBrandId(),
886
- newsroomSectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH,
888
+ newsroomSectionsConfigPath?: string,
887
889
  ): CategorySteeringDashboard {
888
890
  const importedAt = "2026-05-16T12:00:00.000Z";
889
891
  const profile = getNewsroomDemoProfile(brandId);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
3
- * The shapes match Limatus results so the editor, the preview marks and the
3
+ * The shapes match Humanus results so the editor, the preview marks and the
4
4
  * rewrite flow behave as they do against the real backend.
5
5
  */
6
6
  import type { EditorialCheckResult, EditorialFinding, EditorialSegment, RewriteResult, Span } from "./editorial-checks";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Editorial checks: the Limatus result shapes the newsroom uses, and pure helpers
3
- * over them. Limatus counts offsets in UTF-16 code units, which is exactly how
2
+ * Editorial checks: the Humanus result shapes the newsroom uses, and pure helpers
3
+ * over them. Humanus counts offsets in UTF-16 code units, which is exactly how
4
4
  * JavaScript indexes strings, so spans apply to the body text as they are.
5
5
  */
6
6
 
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Places Limatus findings on the rendered Markus preview.
2
+ * Places Humanus findings on the rendered Markus preview.
3
3
  *
4
- * Limatus reports spans in the markup; the preview renders an IR tree with no
4
+ * Humanus reports spans in the markup; the preview renders an IR tree with no
5
5
  * character offsets. Both sides agree on the words a reader sees, so findings
6
- * are placed by text: each Limatus paragraph is matched, in document order, to
6
+ * are placed by text: each Humanus paragraph is matched, in document order, to
7
7
  * the rendered prose block with the same words, and each finding's plain
8
8
  * excerpt is located inside that block. Whitespace is ignored while matching,
9
- * because rendering and Limatus collapse it differently (citations, breaks).
9
+ * because rendering and Humanus collapse it differently (citations, breaks).
10
10
  *
11
11
  * "Rendered text" is defined by `inlineLeaves`, which the preview renderer also
12
12
  * uses, so offsets computed here are offsets the renderer can split on.
@@ -131,7 +131,7 @@ function blockKey(text: string): string {
131
131
  return compact(text).value.replace(/[*_`~]/g, "");
132
132
  }
133
133
 
134
- /** Match every Limatus paragraph to a rendered block and place its findings and sentences there. */
134
+ /** Match every Humanus paragraph to a rendered block and place its findings and sentences there. */
135
135
  export function planPreview(
136
136
  document: MarkusDocument,
137
137
  result: EditorialCheckResult,
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
9
9
  return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
10
10
  }
11
11
 
12
- export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
13
- return resolveBrandConfigPath(brand.steeringConfigPath);
12
+ /** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
13
+ function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
14
+ return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
14
15
  }
15
16
 
16
- export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
17
- return resolveBrandConfigPath(brand.newsroomSectionsConfigPath);
17
+ export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
18
+ return optionalBrandConfigPath(brand.steeringConfigPath);
18
19
  }
19
20
 
20
- export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
21
- return resolveBrandConfigPath(brand.analysisProfilesPath);
21
+ export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
22
+ return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
23
+ }
24
+
25
+ export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
26
+ return optionalBrandConfigPath(brand.analysisProfilesPath);
22
27
  }
package/lib/site-brand.ts CHANGED
@@ -142,9 +142,12 @@ export type SiteBrand = {
142
142
  readerBasePath?: string;
143
143
  opsChrome: OpsChrome;
144
144
  corpusKey: string;
145
- steeringConfigPath: string;
146
- newsroomSectionsConfigPath: string;
147
- analysisProfilesPath: string;
145
+ /** Optional repo file for topic steering. Editorial and steering configuration is backend data; a data-driven site sets none and nothing is looked up. */
146
+ steeringConfigPath?: string;
147
+ /** Optional repo file for the local-preview demo sections only. Sections live in the backend. */
148
+ newsroomSectionsConfigPath?: string;
149
+ /** Optional repo file listing analysis profiles. */
150
+ analysisProfilesPath?: string;
148
151
  publicationName: string;
149
152
  components?: SiteBrandComponents;
150
153
  /**
package/package.json CHANGED
@@ -395,7 +395,7 @@
395
395
  "optional": true
396
396
  }
397
397
  },
398
- "version": "4.0.0",
398
+ "version": "5.1.0",
399
399
  "dependencies": {
400
400
  "@aws-amplify/adapter-nextjs": "^1.7.3",
401
401
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.0.0",
2
+ "version": "5.1.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",