emdash 0.2.0 → 0.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.
- package/dist/{adapters-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
- package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
- package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
- package/dist/apply-Cma_PiF6.mjs.map +1 -0
- package/dist/astro/index.d.mts +25 -11
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +38 -25
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.mjs +2 -2
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +20 -8
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.mjs +12 -2
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +52 -45
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +9 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
- package/dist/byline-WuOq9MFJ.mjs.map +1 -0
- package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
- package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
- package/dist/cache-E3Dts-yT.mjs +56 -0
- package/dist/cache-E3Dts-yT.mjs.map +1 -0
- package/dist/cli/index.mjs +13 -13
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
- package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
- package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
- package/dist/content-BsBoyj8G.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +2 -2
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
- package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
- package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
- package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
- package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
- package/dist/{index-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
- package/dist/index-CRg3PWfZ.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +20 -20
- package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
- package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
- package/dist/{loader-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
- package/dist/loader-BYzwzORf.mjs.map +1 -0
- package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
- package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
- package/dist/{mode-CYeM2rPt.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
- package/dist/page/index.d.mts +1 -1
- package/dist/patterns-CrCYkMBb.mjs +93 -0
- package/dist/patterns-CrCYkMBb.mjs.map +1 -0
- package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
- package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
- package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
- package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
- package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
- package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
- package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
- package/dist/registry-BgnP3ysR.mjs.map +1 -0
- package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
- package/dist/runner-Cd-_WyDo.mjs.map +1 -0
- package/dist/{runner-DTqkzOzc.d.mts → runner-DYv3rX8P.d.mts} +10 -3
- package/dist/runner-DYv3rX8P.d.mts.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +2 -2
- package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
- package/dist/search-B5p9D36n.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +11 -3
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +76 -15
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
- package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
- package/dist/transaction-Cn2rjY78.mjs +28 -0
- package/dist/transaction-Cn2rjY78.mjs.map +1 -0
- package/dist/{transport-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
- package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
- package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
- package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
- package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
- package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
- package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
- package/dist/types-BYWYxLcp.d.mts.map +1 -0
- package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
- package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
- package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
- package/dist/types-DNZpaCBk.d.mts.map +1 -0
- package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
- package/dist/types-Dz9_WMS6.mjs.map +1 -0
- package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
- package/dist/{types-CcreFIIH.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
- package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
- package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
- package/dist/{validate-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
- package/dist/{validate-B7KP7VLM.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
- package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
- package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
- package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
- package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
- package/dist/version-DlTDRdpv.mjs +7 -0
- package/dist/version-DlTDRdpv.mjs.map +1 -0
- package/package.json +7 -5
- package/src/api/handlers/content.ts +36 -25
- package/src/api/handlers/menus.ts +19 -16
- package/src/api/handlers/redirects.ts +95 -3
- package/src/api/schemas/redirects.ts +1 -0
- package/src/astro/integration/index.ts +2 -3
- package/src/astro/integration/runtime.ts +8 -14
- package/src/astro/integration/vite-config.ts +14 -4
- package/src/astro/middleware/redirect.ts +30 -15
- package/src/astro/middleware.ts +11 -19
- package/src/astro/routes/admin.astro +2 -2
- package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
- package/src/astro/routes/api/admin/bylines/index.ts +2 -0
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
- package/src/astro/routes/api/manifest.ts +3 -1
- package/src/astro/routes/api/redirects/[id].ts +3 -0
- package/src/astro/routes/api/redirects/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
- package/src/astro/routes/api/schema/collections/index.ts +1 -0
- package/src/astro/storage/adapters.ts +19 -5
- package/src/astro/storage/types.ts +12 -4
- package/src/astro/types.ts +1 -0
- package/src/bylines/index.ts +50 -2
- package/src/cleanup.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +5 -5
- package/src/database/dialect-helpers.ts +3 -0
- package/src/database/migrations/011_sections.ts +2 -2
- package/src/database/migrations/runner.ts +23 -2
- package/src/database/repositories/byline.ts +2 -1
- package/src/database/repositories/content.ts +5 -0
- package/src/database/repositories/redirect.ts +13 -0
- package/src/database/validate.ts +10 -10
- package/src/emdash-runtime.ts +23 -9
- package/src/index.ts +3 -0
- package/src/loader.ts +2 -0
- package/src/mcp/server.ts +40 -67
- package/src/menus/index.ts +4 -0
- package/src/plugins/context.ts +28 -4
- package/src/plugins/cron.ts +29 -4
- package/src/plugins/hooks.ts +22 -10
- package/src/plugins/index.ts +1 -0
- package/src/plugins/manager.ts +6 -2
- package/src/plugins/marketplace.ts +33 -3
- package/src/plugins/routes.ts +3 -3
- package/src/plugins/types.ts +7 -0
- package/src/query.ts +37 -14
- package/src/redirects/cache.ts +68 -0
- package/src/redirects/loops.ts +318 -0
- package/src/schema/registry.ts +3 -0
- package/src/search/fts-manager.ts +24 -11
- package/src/search/query.ts +8 -9
- package/src/seed/apply.ts +49 -28
- package/src/storage/s3.ts +94 -25
- package/src/storage/types.ts +13 -5
- package/src/utils/slugify.ts +11 -0
- package/src/version.ts +12 -0
- package/src/visual-editing/toolbar.ts +11 -1
- package/dist/apply-wmVEOSbR.mjs.map +0 -1
- package/dist/byline-1WQPlISL.mjs.map +0 -1
- package/dist/bylines-BYdTYmia.mjs.map +0 -1
- package/dist/content-BmXndhdi.mjs.map +0 -1
- package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
- package/dist/index-UHEVQMus.d.mts.map +0 -1
- package/dist/loader-CHb2v0jm.mjs.map +0 -1
- package/dist/redirect-DIfIni3r.mjs.map +0 -1
- package/dist/registry-1EvbAfsC.mjs.map +0 -1
- package/dist/runner-BoN0-FPi.mjs.map +0 -1
- package/dist/runner-DTqkzOzc.d.mts.map +0 -1
- package/dist/search-BsYMed12.mjs.map +0 -1
- package/dist/types-6dqxBqsH.d.mts.map +0 -1
- package/dist/types-Bec-r_3_.mjs.map +0 -1
- package/dist/types-BljtYPSd.d.mts.map +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect loop and chain detection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Builds a directed graph from redirect rules and detects:
|
|
5
|
+
* - Cycles (loops): /a → /b → /c → /a
|
|
6
|
+
* - Long chains: /a → /b → /c → /d → /e (exceeding a warning threshold)
|
|
7
|
+
*
|
|
8
|
+
* Handles both exact and pattern redirects. When the walker encounters
|
|
9
|
+
* a path with no exact source match, it tests against compiled pattern
|
|
10
|
+
* sources and resolves the destination using captured parameters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
compilePattern,
|
|
15
|
+
matchPattern,
|
|
16
|
+
interpolateDestination,
|
|
17
|
+
type CompiledPattern,
|
|
18
|
+
} from "./patterns.js";
|
|
19
|
+
|
|
20
|
+
export interface RedirectEdge {
|
|
21
|
+
id: string;
|
|
22
|
+
source: string;
|
|
23
|
+
destination: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
isPattern: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CompiledPatternRedirect {
|
|
29
|
+
id: string;
|
|
30
|
+
compiled: CompiledPattern;
|
|
31
|
+
destination: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile all enabled pattern redirects for matching during graph walks.
|
|
36
|
+
*/
|
|
37
|
+
function compilePatterns(edges: RedirectEdge[]): CompiledPatternRedirect[] {
|
|
38
|
+
const result: CompiledPatternRedirect[] = [];
|
|
39
|
+
for (const edge of edges) {
|
|
40
|
+
if (edge.enabled && edge.isPattern) {
|
|
41
|
+
result.push({
|
|
42
|
+
id: edge.id,
|
|
43
|
+
compiled: compilePattern(edge.source),
|
|
44
|
+
destination: edge.destination,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Single-segment dummy value for representative path generation */
|
|
52
|
+
const DUMMY_SEGMENT = "__p__";
|
|
53
|
+
|
|
54
|
+
/** Splat pattern: [...paramName] */
|
|
55
|
+
const SPLAT_RE = /\[\.\.\.(\w+)\]/g;
|
|
56
|
+
|
|
57
|
+
/** Param pattern: [paramName] */
|
|
58
|
+
const PARAM_RE = /\[(\w+)\]/g;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract the literal prefix from a pattern source (everything before the
|
|
62
|
+
* first placeholder), stripped of leading segments shared with a base path.
|
|
63
|
+
* e.g., "/new/docs/[slug]" → "docs/__p__" (the part after "/new/")
|
|
64
|
+
*/
|
|
65
|
+
function extractPatternSuffix(patternSource: string): string {
|
|
66
|
+
// Replace placeholders with dummy values
|
|
67
|
+
let result = patternSource.replace(SPLAT_RE, DUMMY_SEGMENT);
|
|
68
|
+
SPLAT_RE.lastIndex = 0;
|
|
69
|
+
result = result.replace(PARAM_RE, DUMMY_SEGMENT);
|
|
70
|
+
// Strip leading slash and first segment (e.g., "/new/docs/__p__" → "docs/__p__")
|
|
71
|
+
const parts = result.split("/").filter(Boolean);
|
|
72
|
+
return parts.slice(1).join("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate representative concrete paths from a template string.
|
|
77
|
+
* Replaces [param] with a dummy segment and [...rest] with multiple
|
|
78
|
+
* depth variants. For catch-alls, also generates representatives using
|
|
79
|
+
* literal prefixes from existing pattern sources to catch cross-pattern loops.
|
|
80
|
+
*/
|
|
81
|
+
function generateRepresentatives(template: string, existingEdges?: RedirectEdge[]): string[] {
|
|
82
|
+
const hasSplat = SPLAT_RE.test(template);
|
|
83
|
+
SPLAT_RE.lastIndex = 0;
|
|
84
|
+
|
|
85
|
+
if (hasSplat) {
|
|
86
|
+
// Extract the static prefix before the catch-all (e.g., "/old/" from "/old/[...path]")
|
|
87
|
+
const splatIndex = template.indexOf("[...");
|
|
88
|
+
const prefix = template.slice(0, splatIndex);
|
|
89
|
+
|
|
90
|
+
const reps = [
|
|
91
|
+
template.replace(SPLAT_RE, DUMMY_SEGMENT).replace(PARAM_RE, DUMMY_SEGMENT),
|
|
92
|
+
template
|
|
93
|
+
.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
|
|
94
|
+
.replace(PARAM_RE, DUMMY_SEGMENT),
|
|
95
|
+
template
|
|
96
|
+
.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
|
|
97
|
+
.replace(PARAM_RE, DUMMY_SEGMENT),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Add representatives derived from existing pattern sources' literal prefixes
|
|
101
|
+
if (existingEdges) {
|
|
102
|
+
for (const edge of existingEdges) {
|
|
103
|
+
if (edge.enabled && edge.isPattern && edge.source !== template) {
|
|
104
|
+
const suffix = extractPatternSuffix(edge.source);
|
|
105
|
+
if (suffix) {
|
|
106
|
+
reps.push(`${prefix}${suffix}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return reps;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [template.replace(PARAM_RE, DUMMY_SEGMENT)];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the next hop for a given path. Tries exact match first,
|
|
120
|
+
* then pattern matching with parameter interpolation for concrete paths,
|
|
121
|
+
* then representative-based matching for template strings.
|
|
122
|
+
*/
|
|
123
|
+
function resolveNext(
|
|
124
|
+
path: string,
|
|
125
|
+
graph: Map<string, { destination: string; id: string }>,
|
|
126
|
+
patterns: CompiledPatternRedirect[],
|
|
127
|
+
edges?: RedirectEdge[],
|
|
128
|
+
): { destination: string; id: string } | null {
|
|
129
|
+
// Exact match (fast) — works for both real paths and template strings
|
|
130
|
+
const exact = graph.get(path);
|
|
131
|
+
if (exact) return exact;
|
|
132
|
+
|
|
133
|
+
if (!path.includes("[")) {
|
|
134
|
+
// Concrete path — try pattern matching directly
|
|
135
|
+
for (const pr of patterns) {
|
|
136
|
+
const params = matchPattern(pr.compiled, path);
|
|
137
|
+
if (params) {
|
|
138
|
+
const resolved = interpolateDestination(pr.destination, params);
|
|
139
|
+
return { destination: resolved, id: pr.id };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
// Template string — generate representative paths and test against patterns
|
|
144
|
+
const representatives = generateRepresentatives(path, edges);
|
|
145
|
+
for (const pr of patterns) {
|
|
146
|
+
for (const rep of representatives) {
|
|
147
|
+
const params = matchPattern(pr.compiled, rep);
|
|
148
|
+
if (params) {
|
|
149
|
+
const resolved = interpolateDestination(pr.destination, params);
|
|
150
|
+
return { destination: resolved, id: pr.id };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build an adjacency map from redirect edges.
|
|
161
|
+
* Includes both exact and pattern redirects — pattern redirects use their
|
|
162
|
+
* template strings as literal graph edges, which works because EmDash
|
|
163
|
+
* patterns pass parameters through without transformation.
|
|
164
|
+
*/
|
|
165
|
+
function buildGraph(edges: RedirectEdge[]): Map<string, { destination: string; id: string }> {
|
|
166
|
+
const graph = new Map<string, { destination: string; id: string }>();
|
|
167
|
+
for (const edge of edges) {
|
|
168
|
+
if (edge.enabled) {
|
|
169
|
+
graph.set(edge.source, { destination: edge.destination, id: edge.id });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return graph;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Detect all redirect IDs that participate in cycles.
|
|
177
|
+
* Walks every node in the graph once, collecting IDs from any cycles found.
|
|
178
|
+
*
|
|
179
|
+
* @returns Array of redirect IDs that are part of a loop
|
|
180
|
+
*/
|
|
181
|
+
export function detectLoops(edges: RedirectEdge[]): string[] {
|
|
182
|
+
const graph = buildGraph(edges);
|
|
183
|
+
const patterns = compilePatterns(edges);
|
|
184
|
+
const visited = new Set<string>();
|
|
185
|
+
const loopRedirectIds = new Set<string>();
|
|
186
|
+
|
|
187
|
+
for (const [startSource] of graph) {
|
|
188
|
+
if (visited.has(startSource)) continue;
|
|
189
|
+
|
|
190
|
+
const path: string[] = [];
|
|
191
|
+
const pathSet = new Set<string>();
|
|
192
|
+
const pathIds: string[] = [];
|
|
193
|
+
let current: string | undefined = startSource;
|
|
194
|
+
|
|
195
|
+
while (current) {
|
|
196
|
+
if (pathSet.has(current)) {
|
|
197
|
+
// Found a cycle — collect IDs of redirects in the loop
|
|
198
|
+
const loopStart = path.indexOf(current);
|
|
199
|
+
for (const id of pathIds.slice(loopStart)) loopRedirectIds.add(id);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (visited.has(current)) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const next = resolveNext(current, graph, patterns, edges);
|
|
208
|
+
if (!next) break;
|
|
209
|
+
|
|
210
|
+
path.push(current);
|
|
211
|
+
pathSet.add(current);
|
|
212
|
+
pathIds.push(next.id);
|
|
213
|
+
current = next.destination;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const node of path) visited.add(node);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return [...loopRedirectIds];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Find a compiled pattern redirect whose source matches the given resolved path,
|
|
224
|
+
* returning the source template string for display purposes.
|
|
225
|
+
*/
|
|
226
|
+
function findMatchingTemplate(
|
|
227
|
+
resolvedPath: string,
|
|
228
|
+
patterns: CompiledPatternRedirect[],
|
|
229
|
+
): string | null {
|
|
230
|
+
for (const pr of patterns) {
|
|
231
|
+
if (matchPattern(pr.compiled, resolvedPath) !== null) {
|
|
232
|
+
return pr.compiled.source;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if adding or updating a redirect would create a loop.
|
|
240
|
+
*
|
|
241
|
+
* Walks the chain from `destination` through existing redirects.
|
|
242
|
+
* If it reaches `source`, a cycle would form.
|
|
243
|
+
*
|
|
244
|
+
* @returns The loop path if a cycle would be created, or null if safe
|
|
245
|
+
*/
|
|
246
|
+
export function wouldCreateLoop(
|
|
247
|
+
source: string,
|
|
248
|
+
destination: string,
|
|
249
|
+
existingEdges: RedirectEdge[],
|
|
250
|
+
excludeId?: string,
|
|
251
|
+
): string[] | null {
|
|
252
|
+
const filtered = excludeId ? existingEdges.filter((e) => e.id !== excludeId) : existingEdges;
|
|
253
|
+
const graph = buildGraph(filtered);
|
|
254
|
+
const patterns = compilePatterns(filtered);
|
|
255
|
+
|
|
256
|
+
// If the proposed source is a pattern, compile it so we can check
|
|
257
|
+
// whether resolved paths would match it (not just string equality)
|
|
258
|
+
const sourceIsPattern = source.includes("[");
|
|
259
|
+
const compiledSource = sourceIsPattern ? compilePattern(source) : null;
|
|
260
|
+
|
|
261
|
+
// Determine starting points for the walk. If the destination is a
|
|
262
|
+
// template, generate representative concrete paths AND find existing
|
|
263
|
+
// exact sources in the graph that match the template.
|
|
264
|
+
let startingPoints: string[];
|
|
265
|
+
if (destination.includes("[")) {
|
|
266
|
+
const reps = generateRepresentatives(destination, filtered);
|
|
267
|
+
// Also find existing exact graph keys that match this template
|
|
268
|
+
const compiled = compilePattern(destination);
|
|
269
|
+
for (const [key] of graph) {
|
|
270
|
+
if (!key.includes("[") && matchPattern(compiled, key) !== null) {
|
|
271
|
+
reps.push(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Always include the destination itself — it may be an exact graph key
|
|
275
|
+
// (e.g., /a/sub/[...path] exists as a literal source in the graph)
|
|
276
|
+
reps.push(destination);
|
|
277
|
+
startingPoints = reps;
|
|
278
|
+
} else {
|
|
279
|
+
startingPoints = [destination];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const start of startingPoints) {
|
|
283
|
+
const path = [source, destination];
|
|
284
|
+
let current = start;
|
|
285
|
+
const seen = new Set<string>([source, destination, start]);
|
|
286
|
+
|
|
287
|
+
// Walk the chain until it ends or we revisit a node
|
|
288
|
+
// eslint-disable-next-line no-constant-condition -- terminates via return/break when chain ends or cycle found
|
|
289
|
+
while (true) {
|
|
290
|
+
const next = resolveNext(current, graph, patterns, filtered);
|
|
291
|
+
if (!next) break; // chain ends, try next starting point
|
|
292
|
+
|
|
293
|
+
// Check if we've looped back — either exact match or pattern match
|
|
294
|
+
const loopsBack =
|
|
295
|
+
seen.has(next.destination) ||
|
|
296
|
+
(compiledSource !== null && matchPattern(compiledSource, next.destination) !== null);
|
|
297
|
+
|
|
298
|
+
if (loopsBack) {
|
|
299
|
+
// Show the source template instead of dummy resolved path
|
|
300
|
+
const displayPath =
|
|
301
|
+
!seen.has(next.destination) && compiledSource !== null ? source : next.destination;
|
|
302
|
+
path.push(displayPath);
|
|
303
|
+
return path; // cycle found
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// If the resolved path contains dummy segments, try to find the
|
|
307
|
+
// original pattern template that produced it for cleaner display
|
|
308
|
+
const cleanDest = next.destination.includes(DUMMY_SEGMENT)
|
|
309
|
+
? (findMatchingTemplate(next.destination, patterns) ?? next.destination)
|
|
310
|
+
: next.destination;
|
|
311
|
+
path.push(cleanDest);
|
|
312
|
+
seen.add(next.destination);
|
|
313
|
+
current = next.destination;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return null;
|
|
318
|
+
}
|
package/src/schema/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { ulid } from "ulidx";
|
|
|
6
6
|
import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js";
|
|
7
7
|
import { withTransaction } from "../database/transaction.js";
|
|
8
8
|
import type { CollectionTable, Database, FieldTable } from "../database/types.js";
|
|
9
|
+
import { validateIdentifier } from "../database/validate.js";
|
|
9
10
|
import { FTSManager } from "../search/fts-manager.js";
|
|
10
11
|
import {
|
|
11
12
|
type Collection,
|
|
@@ -684,6 +685,7 @@ export class SchemaRegistry {
|
|
|
684
685
|
* Get table name for a collection
|
|
685
686
|
*/
|
|
686
687
|
private getTableName(slug: string): string {
|
|
688
|
+
validateIdentifier(slug, "collection slug");
|
|
687
689
|
return `ec_${slug}`;
|
|
688
690
|
}
|
|
689
691
|
|
|
@@ -691,6 +693,7 @@ export class SchemaRegistry {
|
|
|
691
693
|
* Get column name for a field
|
|
692
694
|
*/
|
|
693
695
|
private getColumnName(slug: string): string {
|
|
696
|
+
validateIdentifier(slug, "field slug");
|
|
694
697
|
return slug;
|
|
695
698
|
}
|
|
696
699
|
|
|
@@ -39,6 +39,7 @@ export class FTSManager {
|
|
|
39
39
|
* Uses _emdash_ prefix to clearly mark as internal/system table
|
|
40
40
|
*/
|
|
41
41
|
getFtsTableName(collectionSlug: string): string {
|
|
42
|
+
validateIdentifier(collectionSlug, "collection slug");
|
|
42
43
|
return `_emdash_fts_${collectionSlug}`;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -46,6 +47,7 @@ export class FTSManager {
|
|
|
46
47
|
* Get the content table name for a collection
|
|
47
48
|
*/
|
|
48
49
|
getContentTableName(collectionSlug: string): string {
|
|
50
|
+
validateIdentifier(collectionSlug, "collection slug");
|
|
49
51
|
return `ec_${collectionSlug}`;
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -98,19 +100,26 @@ export class FTSManager {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
|
-
* Create triggers to keep FTS table in sync with content table
|
|
103
|
+
* Create triggers to keep FTS table in sync with content table.
|
|
104
|
+
*
|
|
105
|
+
* The insert and update triggers only add rows to the FTS index when
|
|
106
|
+
* `deleted_at IS NULL`. This keeps soft-deleted content out of the
|
|
107
|
+
* search index and ensures the FTS row count matches the non-deleted
|
|
108
|
+
* content count (which `verifyAndRepairIndex` relies on).
|
|
102
109
|
*/
|
|
103
110
|
private async createTriggers(collectionSlug: string, searchableFields: string[]): Promise<void> {
|
|
111
|
+
this.validateInputs(collectionSlug, searchableFields);
|
|
104
112
|
const ftsTable = this.getFtsTableName(collectionSlug);
|
|
105
113
|
const contentTable = this.getContentTableName(collectionSlug);
|
|
106
114
|
const fieldList = searchableFields.join(", ");
|
|
107
115
|
const newFieldList = searchableFields.map((f) => `NEW.${f}`).join(", ");
|
|
108
116
|
|
|
109
|
-
// Insert trigger
|
|
117
|
+
// Insert trigger - only index non-deleted content
|
|
110
118
|
await sql
|
|
111
119
|
.raw(`
|
|
112
120
|
CREATE TRIGGER IF NOT EXISTS "${ftsTable}_insert"
|
|
113
121
|
AFTER INSERT ON "${contentTable}"
|
|
122
|
+
WHEN NEW.deleted_at IS NULL
|
|
114
123
|
BEGIN
|
|
115
124
|
INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
|
|
116
125
|
VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
|
|
@@ -118,7 +127,9 @@ export class FTSManager {
|
|
|
118
127
|
`)
|
|
119
128
|
.execute(this.db);
|
|
120
129
|
|
|
121
|
-
// Update trigger -
|
|
130
|
+
// Update trigger - always remove the old FTS row, only re-insert
|
|
131
|
+
// if the row is not soft-deleted. This handles both content edits
|
|
132
|
+
// and soft-delete operations (UPDATE SET deleted_at = ...).
|
|
122
133
|
await sql
|
|
123
134
|
.raw(`
|
|
124
135
|
CREATE TRIGGER IF NOT EXISTS "${ftsTable}_update"
|
|
@@ -126,7 +137,8 @@ export class FTSManager {
|
|
|
126
137
|
BEGIN
|
|
127
138
|
DELETE FROM "${ftsTable}" WHERE rowid = OLD.rowid;
|
|
128
139
|
INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
|
|
129
|
-
|
|
140
|
+
SELECT NEW.rowid, NEW.id, NEW.locale, ${newFieldList}
|
|
141
|
+
WHERE NEW.deleted_at IS NULL;
|
|
130
142
|
END
|
|
131
143
|
`)
|
|
132
144
|
.execute(this.db);
|
|
@@ -147,6 +159,7 @@ export class FTSManager {
|
|
|
147
159
|
* Drop triggers for a collection
|
|
148
160
|
*/
|
|
149
161
|
private async dropTriggers(collectionSlug: string): Promise<void> {
|
|
162
|
+
this.validateInputs(collectionSlug);
|
|
150
163
|
const ftsTable = this.getFtsTableName(collectionSlug);
|
|
151
164
|
|
|
152
165
|
await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_insert"`).execute(this.db);
|
|
@@ -287,9 +300,12 @@ export class FTSManager {
|
|
|
287
300
|
}
|
|
288
301
|
|
|
289
302
|
/**
|
|
290
|
-
* Enable search for a collection
|
|
303
|
+
* Enable search for a collection.
|
|
291
304
|
*
|
|
292
|
-
*
|
|
305
|
+
* Uses rebuildIndex to ensure a clean state -- drop any existing FTS
|
|
306
|
+
* table/triggers, recreate them, and populate from content. This avoids
|
|
307
|
+
* duplicate rows when triggers have already populated the index (e.g.
|
|
308
|
+
* during seeding where content is inserted before search is enabled).
|
|
293
309
|
*/
|
|
294
310
|
async enableSearch(
|
|
295
311
|
collectionSlug: string,
|
|
@@ -308,11 +324,8 @@ export class FTSManager {
|
|
|
308
324
|
);
|
|
309
325
|
}
|
|
310
326
|
|
|
311
|
-
//
|
|
312
|
-
await this.
|
|
313
|
-
|
|
314
|
-
// Populate from existing content
|
|
315
|
-
await this.populateFromContent(collectionSlug, searchableFields);
|
|
327
|
+
// Rebuild from scratch to ensure clean state (no duplicate rows)
|
|
328
|
+
await this.rebuildIndex(collectionSlug, searchableFields, options?.weights);
|
|
316
329
|
|
|
317
330
|
// Update search config
|
|
318
331
|
await this.setSearchConfig(collectionSlug, {
|
package/src/search/query.ts
CHANGED
|
@@ -368,22 +368,21 @@ function escapeQuery(query: string): string {
|
|
|
368
368
|
return "";
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
371
|
+
// If already a quoted phrase, escape only interior quotes and preserve phrase syntax
|
|
372
|
+
if (query.startsWith('"') && query.endsWith('"') && query.length >= 2) {
|
|
373
|
+
const inner = query.slice(1, -1);
|
|
374
|
+
return `"${inner.replace(DOUBLE_QUOTE_PATTERN, '""')}"`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Escape any existing quotes
|
|
374
378
|
const escaped = query.replace(DOUBLE_QUOTE_PATTERN, '""');
|
|
375
379
|
|
|
376
380
|
// If the query contains FTS5 operators (AND, OR, NOT, NEAR),
|
|
377
|
-
// pass through
|
|
381
|
+
// pass through with quotes escaped but operators preserved
|
|
378
382
|
if (FTS_OPERATORS_PATTERN.test(query)) {
|
|
379
383
|
return escaped;
|
|
380
384
|
}
|
|
381
385
|
|
|
382
|
-
// If already quoted, pass through
|
|
383
|
-
if (query.startsWith('"') && query.endsWith('"')) {
|
|
384
|
-
return query;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
386
|
// For simple queries, wrap each word to handle special chars
|
|
388
387
|
const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);
|
|
389
388
|
if (terms.length === 0) {
|
package/src/seed/apply.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { ContentRepository } from "../database/repositories/content.js";
|
|
|
15
15
|
import { MediaRepository } from "../database/repositories/media.js";
|
|
16
16
|
import { RedirectRepository } from "../database/repositories/redirect.js";
|
|
17
17
|
import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
|
|
18
|
+
import { withTransaction } from "../database/transaction.js";
|
|
18
19
|
import type { Database } from "../database/types.js";
|
|
19
20
|
import type { MediaValue } from "../fields/types.js";
|
|
20
21
|
import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js";
|
|
@@ -342,7 +343,6 @@ export async function applySeed(
|
|
|
342
343
|
// 7. Content (created before menus so refs can resolve)
|
|
343
344
|
if (includeContent && seed.content) {
|
|
344
345
|
const contentRepo = new ContentRepository(db);
|
|
345
|
-
const bylineRepo = new BylineRepository(db);
|
|
346
346
|
|
|
347
347
|
// Create content entries
|
|
348
348
|
for (const [collectionSlug, entries] of Object.entries(seed.content)) {
|
|
@@ -366,25 +366,30 @@ export async function applySeed(
|
|
|
366
366
|
result,
|
|
367
367
|
);
|
|
368
368
|
|
|
369
|
+
// Update content + bylines + taxonomies atomically
|
|
369
370
|
const status = entry.status || "published";
|
|
370
|
-
await
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
await withTransaction(db, async (trx) => {
|
|
372
|
+
const trxContentRepo = new ContentRepository(trx);
|
|
373
|
+
const trxBylineRepo = new BylineRepository(trx);
|
|
374
|
+
|
|
375
|
+
await trxContentRepo.update(collectionSlug, existing.id, {
|
|
376
|
+
status,
|
|
377
|
+
data: resolvedData,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await applyContentBylines(
|
|
381
|
+
trxBylineRepo,
|
|
382
|
+
collectionSlug,
|
|
383
|
+
existing.id,
|
|
384
|
+
entry,
|
|
385
|
+
seedBylineIdMap,
|
|
386
|
+
true,
|
|
387
|
+
);
|
|
388
|
+
await applyContentTaxonomies(trx, collectionSlug, existing.id, entry, true);
|
|
373
389
|
});
|
|
374
390
|
|
|
375
391
|
seedIdMap.set(entry.id, existing.id);
|
|
376
392
|
result.content.updated++;
|
|
377
|
-
|
|
378
|
-
// Update bylines and taxonomy assignments
|
|
379
|
-
await applyContentBylines(
|
|
380
|
-
bylineRepo,
|
|
381
|
-
collectionSlug,
|
|
382
|
-
existing.id,
|
|
383
|
-
entry,
|
|
384
|
-
seedBylineIdMap,
|
|
385
|
-
true,
|
|
386
|
-
);
|
|
387
|
-
await applyContentTaxonomies(db, collectionSlug, existing.id, entry, true);
|
|
388
393
|
continue;
|
|
389
394
|
}
|
|
390
395
|
|
|
@@ -410,24 +415,30 @@ export async function applySeed(
|
|
|
410
415
|
}
|
|
411
416
|
}
|
|
412
417
|
|
|
413
|
-
// Create entry
|
|
418
|
+
// Create entry + bylines + taxonomies atomically
|
|
414
419
|
const status = entry.status || "published";
|
|
415
|
-
const created = await
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
420
|
+
const created = await withTransaction(db, async (trx) => {
|
|
421
|
+
const trxContentRepo = new ContentRepository(trx);
|
|
422
|
+
const trxBylineRepo = new BylineRepository(trx);
|
|
423
|
+
|
|
424
|
+
const item = await trxContentRepo.create({
|
|
425
|
+
type: collectionSlug,
|
|
426
|
+
slug: entry.slug,
|
|
427
|
+
status,
|
|
428
|
+
data: resolvedData,
|
|
429
|
+
locale: entry.locale,
|
|
430
|
+
translationOf,
|
|
431
|
+
publishedAt: status === "published" ? new Date().toISOString() : null,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await applyContentBylines(trxBylineRepo, collectionSlug, item.id, entry, seedBylineIdMap);
|
|
435
|
+
await applyContentTaxonomies(trx, collectionSlug, item.id, entry, false);
|
|
436
|
+
|
|
437
|
+
return item;
|
|
424
438
|
});
|
|
425
439
|
|
|
426
440
|
seedIdMap.set(entry.id, created.id);
|
|
427
441
|
result.content.created++;
|
|
428
|
-
|
|
429
|
-
await applyContentBylines(bylineRepo, collectionSlug, created.id, entry, seedBylineIdMap);
|
|
430
|
-
await applyContentTaxonomies(db, collectionSlug, created.id, entry, false);
|
|
431
442
|
}
|
|
432
443
|
}
|
|
433
444
|
}
|
|
@@ -636,6 +647,16 @@ export async function applySeed(
|
|
|
636
647
|
}
|
|
637
648
|
}
|
|
638
649
|
|
|
650
|
+
// Invalidate caches that may have been affected by seed data.
|
|
651
|
+
// Seed creates bylines, redirects, and collections, all of which
|
|
652
|
+
// have module-level caches in the hot path.
|
|
653
|
+
const { invalidateBylineCache } = await import("../bylines/index.js");
|
|
654
|
+
const { invalidateRedirectCache } = await import("../redirects/cache.js");
|
|
655
|
+
const { invalidateUrlPatternCache } = await import("../query.js");
|
|
656
|
+
invalidateBylineCache();
|
|
657
|
+
invalidateRedirectCache();
|
|
658
|
+
invalidateUrlPatternCache();
|
|
659
|
+
|
|
639
660
|
return result;
|
|
640
661
|
}
|
|
641
662
|
|