@prenta/core 5.3.0 → 5.4.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/ai/content-writing-generate.d.ts.map +1 -1
  3. package/dist/ai/content-writing-generate.js +3 -0
  4. package/dist/ai/content-writing-generate.js.map +1 -1
  5. package/dist/ai/feature-defaults.d.ts +1 -1
  6. package/dist/ai/feature-defaults.d.ts.map +1 -1
  7. package/dist/ai/feature-defaults.js +3 -13
  8. package/dist/ai/feature-defaults.js.map +1 -1
  9. package/dist/ai/governance.d.ts +31 -0
  10. package/dist/ai/governance.d.ts.map +1 -0
  11. package/dist/ai/governance.js +56 -0
  12. package/dist/ai/governance.js.map +1 -0
  13. package/dist/ai/index.d.ts +2 -1
  14. package/dist/ai/index.d.ts.map +1 -1
  15. package/dist/ai/index.js +2 -1
  16. package/dist/ai/index.js.map +1 -1
  17. package/dist/ai/providers/custom.d.ts +2 -1
  18. package/dist/ai/providers/custom.d.ts.map +1 -1
  19. package/dist/ai/providers/custom.js +7 -1
  20. package/dist/ai/providers/custom.js.map +1 -1
  21. package/dist/ai/providers/http.d.ts +13 -0
  22. package/dist/ai/providers/http.d.ts.map +1 -1
  23. package/dist/ai/providers/http.js +33 -0
  24. package/dist/ai/providers/http.js.map +1 -1
  25. package/dist/ai/providers/index.d.ts +5 -2
  26. package/dist/ai/providers/index.d.ts.map +1 -1
  27. package/dist/ai/providers/index.js +10 -0
  28. package/dist/ai/providers/index.js.map +1 -1
  29. package/dist/ai/providers/openai.d.ts +2 -1
  30. package/dist/ai/providers/openai.d.ts.map +1 -1
  31. package/dist/ai/providers/openai.js +4 -1
  32. package/dist/ai/providers/openai.js.map +1 -1
  33. package/dist/ai/runtime.d.ts +28 -1
  34. package/dist/ai/runtime.d.ts.map +1 -1
  35. package/dist/ai/runtime.js +73 -2
  36. package/dist/ai/runtime.js.map +1 -1
  37. package/dist/ai/usage.d.ts +6 -0
  38. package/dist/ai/usage.d.ts.map +1 -1
  39. package/dist/ai/usage.js +29 -0
  40. package/dist/ai/usage.js.map +1 -1
  41. package/dist/api/route-helpers.d.ts +2 -0
  42. package/dist/api/route-helpers.d.ts.map +1 -1
  43. package/dist/api/route-helpers.js +14 -0
  44. package/dist/api/route-helpers.js.map +1 -1
  45. package/dist/api/routes/ai-authoring.js +1 -1
  46. package/dist/api/routes/ai-authoring.js.map +1 -1
  47. package/dist/api/routes/ai-seo.d.ts.map +1 -1
  48. package/dist/api/routes/ai-seo.js +13 -5
  49. package/dist/api/routes/ai-seo.js.map +1 -1
  50. package/dist/api/routes/ai-settings.d.ts.map +1 -1
  51. package/dist/api/routes/ai-settings.js +39 -1
  52. package/dist/api/routes/ai-settings.js.map +1 -1
  53. package/dist/api/routes/redirects.d.ts.map +1 -1
  54. package/dist/api/routes/redirects.js +3 -2
  55. package/dist/api/routes/redirects.js.map +1 -1
  56. package/dist/api/routes/scheduling.d.ts.map +1 -1
  57. package/dist/api/routes/scheduling.js +17 -0
  58. package/dist/api/routes/scheduling.js.map +1 -1
  59. package/dist/api/routes/sections.d.ts.map +1 -1
  60. package/dist/api/routes/sections.js +9 -40
  61. package/dist/api/routes/sections.js.map +1 -1
  62. package/dist/api/routes/seo-public.d.ts.map +1 -1
  63. package/dist/api/routes/seo-public.js +205 -1
  64. package/dist/api/routes/seo-public.js.map +1 -1
  65. package/dist/api/routes/seo.d.ts.map +1 -1
  66. package/dist/api/routes/seo.js +216 -16
  67. package/dist/api/routes/seo.js.map +1 -1
  68. package/dist/db/model-types.d.ts +32 -0
  69. package/dist/db/model-types.d.ts.map +1 -1
  70. package/dist/generated/browser.d.ts +17 -0
  71. package/dist/generated/client.d.ts +17 -0
  72. package/dist/generated/commonInputTypes.d.ts +30 -30
  73. package/dist/generated/internal/class.d.ts +22 -0
  74. package/dist/generated/internal/class.js +4 -4
  75. package/dist/generated/internal/prismaNamespace.d.ts +198 -1
  76. package/dist/generated/internal/prismaNamespace.js +43 -0
  77. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +47 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +43 -0
  79. package/dist/generated/models/ApiRequestMetric.d.ts +0 -7
  80. package/dist/generated/models/SearchPageInsight.d.ts +1460 -0
  81. package/dist/generated/models/SearchPageInsight.js +1 -0
  82. package/dist/generated/models/SearchPerfDaily.d.ts +1167 -0
  83. package/dist/generated/models/SearchPerfDaily.js +1 -0
  84. package/dist/generated/models.d.ts +2 -0
  85. package/dist/redirects/recovery.d.ts +12 -0
  86. package/dist/redirects/recovery.d.ts.map +1 -1
  87. package/dist/redirects/recovery.js +42 -2
  88. package/dist/redirects/recovery.js.map +1 -1
  89. package/dist/redirects/rerank.d.ts +14 -1
  90. package/dist/redirects/rerank.d.ts.map +1 -1
  91. package/dist/redirects/rerank.js +66 -37
  92. package/dist/redirects/rerank.js.map +1 -1
  93. package/dist/seo/audit-engine.d.ts +21 -3
  94. package/dist/seo/audit-engine.d.ts.map +1 -1
  95. package/dist/seo/audit-engine.js +51 -6
  96. package/dist/seo/audit-engine.js.map +1 -1
  97. package/dist/seo/audit-runner.d.ts.map +1 -1
  98. package/dist/seo/audit-runner.js +18 -0
  99. package/dist/seo/audit-runner.js.map +1 -1
  100. package/dist/seo/config-store.d.ts +13 -0
  101. package/dist/seo/config-store.d.ts.map +1 -1
  102. package/dist/seo/config-store.js +6 -0
  103. package/dist/seo/config-store.js.map +1 -1
  104. package/dist/seo/crux.d.ts +58 -0
  105. package/dist/seo/crux.d.ts.map +1 -0
  106. package/dist/seo/crux.js +101 -0
  107. package/dist/seo/crux.js.map +1 -0
  108. package/dist/seo/issue-content-brief.d.ts.map +1 -1
  109. package/dist/seo/issue-content-brief.js +11 -0
  110. package/dist/seo/issue-content-brief.js.map +1 -1
  111. package/dist/seo/issue-content-research.d.ts.map +1 -1
  112. package/dist/seo/issue-content-research.js +3 -0
  113. package/dist/seo/issue-content-research.js.map +1 -1
  114. package/dist/seo/issue-fix-ai.d.ts.map +1 -1
  115. package/dist/seo/issue-fix-ai.js +3 -0
  116. package/dist/seo/issue-fix-ai.js.map +1 -1
  117. package/dist/seo/issue-fix-redirect.d.ts.map +1 -1
  118. package/dist/seo/issue-fix-redirect.js +1 -0
  119. package/dist/seo/issue-fix-redirect.js.map +1 -1
  120. package/dist/seo/search-console/client.d.ts +39 -0
  121. package/dist/seo/search-console/client.d.ts.map +1 -0
  122. package/dist/seo/search-console/client.js +226 -0
  123. package/dist/seo/search-console/client.js.map +1 -0
  124. package/dist/seo/search-console/prompt-context.d.ts +21 -0
  125. package/dist/seo/search-console/prompt-context.d.ts.map +1 -0
  126. package/dist/seo/search-console/prompt-context.js +93 -0
  127. package/dist/seo/search-console/prompt-context.js.map +1 -0
  128. package/dist/seo/search-console/sync.d.ts +67 -0
  129. package/dist/seo/search-console/sync.d.ts.map +1 -0
  130. package/dist/seo/search-console/sync.js +469 -0
  131. package/dist/seo/search-console/sync.js.map +1 -0
  132. package/dist/seo/search-console/types.d.ts +75 -0
  133. package/dist/seo/search-console/types.d.ts.map +1 -0
  134. package/dist/seo/search-console/types.js +16 -0
  135. package/dist/seo/search-console/types.js.map +1 -0
  136. package/dist/seo/search-insight-signals.d.ts +29 -0
  137. package/dist/seo/search-insight-signals.d.ts.map +1 -0
  138. package/dist/seo/search-insight-signals.js +103 -0
  139. package/dist/seo/search-insight-signals.js.map +1 -0
  140. package/package.json +1 -1
  141. package/prisma/migrations/0016_search_console_tables/migration.sql +56 -0
  142. package/prisma/schema.prisma +49 -0
@@ -637,10 +637,47 @@ export function registerSeoRoutes(router) {
637
637
  };
638
638
  }
639
639
  // Demo/seed search-performance rows live in a hidden collection so they can
640
- // be inserted by the dev seed script and never ship to npm. In production
641
- // with a connected Search Console these would come from the GSC API.
640
+ // be inserted by the dev seed script and never ship to npm. With a connected
641
+ // Search Console the synced `searchPageInsight` table takes precedence.
642
642
  const SEO_PERF_COLLECTION = '__seo_perf__';
643
- async function loadSearchPerformance(limit = 10) {
643
+ /** Synced GSC data (trailing 28 days). Returns null when there is none yet. */
644
+ async function loadRealSearchPerformance(limit) {
645
+ if (!hasModel(db(), 'searchPageInsight'))
646
+ return null;
647
+ try {
648
+ const rows = await db().searchPageInsight.findMany({
649
+ where: { impressions28d: { gt: 0 } },
650
+ orderBy: { impressions28d: 'desc' },
651
+ take: limit,
652
+ });
653
+ if (rows.length === 0)
654
+ return null;
655
+ const ids = rows.map((r) => r.entityId).filter(Boolean);
656
+ const titles = new Map();
657
+ if (ids.length > 0) {
658
+ const docs = await db().document.findMany({
659
+ where: { id: { in: ids } },
660
+ select: { id: true, title: true },
661
+ });
662
+ for (const d of docs) {
663
+ if (d.title)
664
+ titles.set(d.id, d.title);
665
+ }
666
+ }
667
+ return rows.map((r) => ({
668
+ url: r.path,
669
+ title: (r.entityId && titles.get(r.entityId)) || r.path,
670
+ impressions: r.impressions28d,
671
+ clicks: r.clicks28d,
672
+ ctr: r.impressions28d > 0 ? r.clicks28d / r.impressions28d : 0,
673
+ averagePosition: r.avgPosition28d ?? 0,
674
+ }));
675
+ }
676
+ catch {
677
+ return null;
678
+ }
679
+ }
680
+ async function loadDemoSearchPerformance(limit) {
644
681
  try {
645
682
  const docs = await db().document.findMany({
646
683
  where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
@@ -666,6 +703,65 @@ export function registerSeoRoutes(router) {
666
703
  return [];
667
704
  }
668
705
  }
706
+ /** Real synced data when connected; `__seo_perf__` demo rows otherwise. */
707
+ async function loadSearchPerformance(limit = 10, connected = false) {
708
+ if (connected) {
709
+ const real = await loadRealSearchPerformance(limit);
710
+ if (real)
711
+ return real;
712
+ }
713
+ return loadDemoSearchPerformance(limit);
714
+ }
715
+ /**
716
+ * Site-level 28d totals + prior-window deltas from the insight table for the
717
+ * overview metric cards. Position is impressions-weighted. Null when no
718
+ * synced data exists (metrics then fall back to the perf-row estimate).
719
+ */
720
+ async function loadSearchInsightStats() {
721
+ if (!hasModel(db(), 'searchPageInsight'))
722
+ return null;
723
+ try {
724
+ const rows = (await db().searchPageInsight.findMany({
725
+ where: { OR: [{ impressions28d: { gt: 0 } }, { impressionsPrev28d: { gt: 0 } }] },
726
+ select: {
727
+ impressions28d: true,
728
+ impressionsPrev28d: true,
729
+ avgPosition28d: true,
730
+ avgPositionPrev28d: true,
731
+ },
732
+ take: 2000,
733
+ }));
734
+ if (rows.length === 0)
735
+ return null;
736
+ let impressions28d = 0;
737
+ let impressionsPrev28d = 0;
738
+ let posWeight = 0;
739
+ let posWeightBase = 0;
740
+ let prevPosWeight = 0;
741
+ let prevPosWeightBase = 0;
742
+ for (const r of rows) {
743
+ impressions28d += r.impressions28d;
744
+ impressionsPrev28d += r.impressionsPrev28d;
745
+ if (r.avgPosition28d !== null && r.impressions28d > 0) {
746
+ posWeight += r.avgPosition28d * r.impressions28d;
747
+ posWeightBase += r.impressions28d;
748
+ }
749
+ if (r.avgPositionPrev28d !== null && r.impressionsPrev28d > 0) {
750
+ prevPosWeight += r.avgPositionPrev28d * r.impressionsPrev28d;
751
+ prevPosWeightBase += r.impressionsPrev28d;
752
+ }
753
+ }
754
+ return {
755
+ impressions28d,
756
+ impressionsPrev28d,
757
+ avgPosition: posWeightBase > 0 ? posWeight / posWeightBase : null,
758
+ avgPositionPrev: prevPosWeightBase > 0 ? prevPosWeight / prevPosWeightBase : null,
759
+ };
760
+ }
761
+ catch {
762
+ return null;
763
+ }
764
+ }
669
765
  router.post('/seo/audit', async (request) => {
670
766
  try {
671
767
  const auth = await requireAuth(request);
@@ -1794,7 +1890,10 @@ export function registerSeoRoutes(router) {
1794
1890
  ? meta.indexableCount
1795
1891
  : total;
1796
1892
  const auditTruncated = meta.truncated === true;
1797
- const perf = await loadSearchPerformance(10);
1893
+ const perf = await loadSearchPerformance(10, moduleSettings.searchConsoleConnected);
1894
+ const searchStats = moduleSettings.searchConsoleConnected
1895
+ ? await loadSearchInsightStats()
1896
+ : null;
1798
1897
  const summary = {
1799
1898
  total: openIssues.length,
1800
1899
  critical: openIssues.filter((i) => i.severity === 'critical').length,
@@ -1804,10 +1903,30 @@ export function registerSeoRoutes(router) {
1804
1903
  const scoreAfter = lastRun?.siteScoreAfter ?? null;
1805
1904
  const scoreBefore = lastRun?.siteScoreBefore ?? null;
1806
1905
  const breakdown = lastRun?.metadata?.breakdown ?? [];
1807
- const avgPosition = perf.length > 0
1808
- ? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
1809
- : null;
1810
- const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
1906
+ // Real site-level stats (weighted by impressions, with prior-window
1907
+ // trends) when synced data exists; the perf-row estimate otherwise.
1908
+ const avgPosition = searchStats?.avgPosition ??
1909
+ (perf.length > 0
1910
+ ? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
1911
+ : null);
1912
+ const impressions28d = searchStats?.impressions28d ?? perf.reduce((s, r) => s + r.impressions, 0);
1913
+ // Position: lower is better, so an "up" trend means the number dropped.
1914
+ let positionTrend = 'flat';
1915
+ if (searchStats?.avgPosition != null && searchStats.avgPositionPrev != null) {
1916
+ const delta = searchStats.avgPosition - searchStats.avgPositionPrev;
1917
+ if (delta <= -0.5)
1918
+ positionTrend = 'up';
1919
+ else if (delta >= 0.5)
1920
+ positionTrend = 'down';
1921
+ }
1922
+ let impressionsTrend = 'flat';
1923
+ if (searchStats && searchStats.impressionsPrev28d > 0) {
1924
+ const ratio = searchStats.impressions28d / searchStats.impressionsPrev28d;
1925
+ if (ratio >= 1.05)
1926
+ impressionsTrend = 'up';
1927
+ else if (ratio <= 0.95)
1928
+ impressionsTrend = 'down';
1929
+ }
1811
1930
  const metrics = [
1812
1931
  {
1813
1932
  label: 'Pages Indexed',
@@ -1824,16 +1943,42 @@ export function registerSeoRoutes(router) {
1824
1943
  {
1825
1944
  label: 'Avg. Position',
1826
1945
  value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
1827
- helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
1828
- trend: 'flat',
1946
+ helper: moduleSettings.searchConsoleConnected
1947
+ ? searchStats?.avgPositionPrev != null
1948
+ ? `was ${searchStats.avgPositionPrev.toFixed(1)} the prior 28 days`
1949
+ : 'last 28 days'
1950
+ : 'Connect Search Console',
1951
+ trend: positionTrend,
1829
1952
  },
1830
1953
  {
1831
- label: 'Weekly Impressions',
1832
- value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
1833
- helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
1834
- trend: 'flat',
1954
+ label: 'Impressions',
1955
+ value: impressions28d > 0 ? formatCompactNumber(impressions28d) : '—',
1956
+ helper: moduleSettings.searchConsoleConnected
1957
+ ? searchStats && searchStats.impressionsPrev28d > 0
1958
+ ? `was ${formatCompactNumber(searchStats.impressionsPrev28d)} the prior 28 days`
1959
+ : 'last 28 days'
1960
+ : 'Connect Search Console',
1961
+ trend: impressionsTrend,
1835
1962
  },
1836
1963
  ];
1964
+ // Weight quick wins by real traffic when synced search data exists.
1965
+ const impressionsByEntityId = {};
1966
+ if (moduleSettings.searchConsoleConnected && hasModel(db(), 'searchPageInsight')) {
1967
+ try {
1968
+ const impressionRows = (await db().searchPageInsight.findMany({
1969
+ where: { entityId: { not: null }, impressions28d: { gt: 0 } },
1970
+ select: { entityId: true, impressions28d: true },
1971
+ take: 2000,
1972
+ }));
1973
+ for (const r of impressionRows) {
1974
+ if (r.entityId)
1975
+ impressionsByEntityId[r.entityId] = r.impressions28d;
1976
+ }
1977
+ }
1978
+ catch {
1979
+ /* weighting is optional */
1980
+ }
1981
+ }
1837
1982
  const quickWins = buildQuickWins(openIssues.map((i) => ({
1838
1983
  issueKey: i.issueKey,
1839
1984
  entityType: i.entityType,
@@ -1850,7 +1995,7 @@ export function registerSeoRoutes(router) {
1850
1995
  fixable: i.fixable ?? false,
1851
1996
  fixActionType: i.fixActionType ?? null,
1852
1997
  relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
1853
- }))).map((w) => ({
1998
+ })), { impressionsByEntityId }).map((w) => ({
1854
1999
  id: w.id,
1855
2000
  title: w.title,
1856
2001
  explanation: w.explanation,
@@ -1888,13 +2033,68 @@ export function registerSeoRoutes(router) {
1888
2033
  return auth.error;
1889
2034
  const url = new URL(request.url);
1890
2035
  const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
1891
- const rows = await loadSearchPerformance(limit);
2036
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
2037
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
2038
+ const rows = await loadSearchPerformance(limit, settings.searchConsoleConnected);
1892
2039
  return json({ data: { rows } });
1893
2040
  }
1894
2041
  catch (err) {
1895
2042
  return internalError(err, 'seo/search-performance');
1896
2043
  }
1897
2044
  });
2045
+ // Per-document search insight (synced GSC + CrUX) for the editor pane's
2046
+ // read-only stats block. `insight: null` when not synced or no data.
2047
+ router.get('/seo/search-insights/:entityId', async (request, params) => {
2048
+ try {
2049
+ const auth = await requireAuth(request);
2050
+ if (auth.error)
2051
+ return auth.error;
2052
+ const entityId = params.entityId;
2053
+ if (!entityId)
2054
+ return errorResponse('entityId is required', 400);
2055
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
2056
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
2057
+ const connected = settings.searchConsoleConnected;
2058
+ if (!connected || !hasModel(db(), 'searchPageInsight')) {
2059
+ return json({ data: { connected, insight: null } });
2060
+ }
2061
+ const row = await db().searchPageInsight.findFirst({
2062
+ where: { entityId },
2063
+ select: {
2064
+ path: true,
2065
+ clicks28d: true,
2066
+ impressions28d: true,
2067
+ avgPosition28d: true,
2068
+ clicksPrev28d: true,
2069
+ impressionsPrev28d: true,
2070
+ avgPositionPrev28d: true,
2071
+ topQueries: true,
2072
+ lcpMs: true,
2073
+ inpMs: true,
2074
+ cls: true,
2075
+ cwvStatus: true,
2076
+ lastSyncedAt: true,
2077
+ },
2078
+ });
2079
+ return json({
2080
+ data: {
2081
+ connected,
2082
+ insight: row
2083
+ ? {
2084
+ ...row,
2085
+ topQueries: Array.isArray(row.topQueries) ? row.topQueries : [],
2086
+ lastSyncedAt: row.lastSyncedAt instanceof Date
2087
+ ? row.lastSyncedAt.toISOString()
2088
+ : row.lastSyncedAt,
2089
+ }
2090
+ : null,
2091
+ },
2092
+ });
2093
+ }
2094
+ catch (err) {
2095
+ return internalError(err, 'seo/search-insights');
2096
+ }
2097
+ });
1898
2098
  router.get('/seo/content', async (request) => {
1899
2099
  try {
1900
2100
  const auth = await requireAuth(request);