latticesql 4.2.2 → 4.2.3
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/cli.js +129 -8
- package/dist/index.cjs +128 -7
- package/dist/index.js +128 -7
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1015,7 +1015,7 @@ var init_manifest = __esm({
|
|
|
1015
1015
|
"src/lifecycle/manifest.ts"() {
|
|
1016
1016
|
"use strict";
|
|
1017
1017
|
init_writer();
|
|
1018
|
-
TEMPLATE_VERSION =
|
|
1018
|
+
TEMPLATE_VERSION = 3;
|
|
1019
1019
|
}
|
|
1020
1020
|
});
|
|
1021
1021
|
|
|
@@ -4208,6 +4208,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
|
|
|
4208
4208
|
warnings: []
|
|
4209
4209
|
};
|
|
4210
4210
|
if (manifest === null) return result;
|
|
4211
|
+
if (options.removeOrphanedDirectories !== false) {
|
|
4212
|
+
for (const [table, entry] of Object.entries(manifest.entityContexts)) {
|
|
4213
|
+
if (entityContexts.has(table)) continue;
|
|
4214
|
+
const directoryRoot = entry.directoryRoot;
|
|
4215
|
+
const rootPath = join6(outputDir, directoryRoot);
|
|
4216
|
+
if (!existsSync6(rootPath)) continue;
|
|
4217
|
+
const globalProtected = new Set(options.protectedFiles ?? []);
|
|
4218
|
+
for (const [slug, files] of Object.entries(entry.entities)) {
|
|
4219
|
+
const entityDir = join6(rootPath, slug);
|
|
4220
|
+
if (!existsSync6(entityDir)) continue;
|
|
4221
|
+
for (const filename of entityFileNames(files)) {
|
|
4222
|
+
if (globalProtected.has(filename)) continue;
|
|
4223
|
+
const filePath = join6(entityDir, filename);
|
|
4224
|
+
if (!existsSync6(filePath)) continue;
|
|
4225
|
+
if (!options.dryRun) unlinkSync3(filePath);
|
|
4226
|
+
options.onOrphan?.(filePath, "file");
|
|
4227
|
+
result.filesRemoved.push(filePath);
|
|
4228
|
+
}
|
|
4229
|
+
let remaining;
|
|
4230
|
+
try {
|
|
4231
|
+
remaining = existsSync6(entityDir) ? readdirSync2(entityDir) : [];
|
|
4232
|
+
} catch {
|
|
4233
|
+
remaining = [];
|
|
4234
|
+
}
|
|
4235
|
+
if (remaining.length === 0) {
|
|
4236
|
+
if (!options.dryRun) {
|
|
4237
|
+
try {
|
|
4238
|
+
rmdirSync(entityDir);
|
|
4239
|
+
} catch {
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
options.onOrphan?.(entityDir, "directory");
|
|
4243
|
+
result.directoriesRemoved.push(entityDir);
|
|
4244
|
+
} else {
|
|
4245
|
+
result.directoriesSkipped.push(entityDir);
|
|
4246
|
+
result.warnings.push(
|
|
4247
|
+
`${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
|
|
4248
|
+
);
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
let rootRemaining;
|
|
4252
|
+
try {
|
|
4253
|
+
rootRemaining = existsSync6(rootPath) ? readdirSync2(rootPath) : [];
|
|
4254
|
+
} catch {
|
|
4255
|
+
rootRemaining = [];
|
|
4256
|
+
}
|
|
4257
|
+
if (rootRemaining.length === 0) {
|
|
4258
|
+
if (!options.dryRun) {
|
|
4259
|
+
try {
|
|
4260
|
+
rmdirSync(rootPath);
|
|
4261
|
+
} catch {
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
options.onOrphan?.(rootPath, "directory");
|
|
4265
|
+
result.directoriesRemoved.push(rootPath);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4211
4269
|
for (const [table, def] of entityContexts) {
|
|
4212
4270
|
const entry = manifest.entityContexts[table];
|
|
4213
4271
|
if (!entry) continue;
|
|
@@ -4704,7 +4762,8 @@ var init_engine = __esm({
|
|
|
4704
4762
|
const currentSlugsByTable = /* @__PURE__ */ new Map();
|
|
4705
4763
|
for (const [table, def] of entityContexts) {
|
|
4706
4764
|
const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
4707
|
-
const
|
|
4765
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
4766
|
+
const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
|
|
4708
4767
|
currentSlugsByTable.set(table, slugs);
|
|
4709
4768
|
}
|
|
4710
4769
|
return cleanupEntityContexts(
|
|
@@ -4753,6 +4812,71 @@ var init_engine = __esm({
|
|
|
4753
4812
|
static _normKey(v2) {
|
|
4754
4813
|
return String(v2);
|
|
4755
4814
|
}
|
|
4815
|
+
/**
|
|
4816
|
+
* Sanitize and validate ONE base slug.
|
|
4817
|
+
*
|
|
4818
|
+
* Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
|
|
4819
|
+
* that shows up in screenshot filenames) with a regular space, strips control
|
|
4820
|
+
* characters, then rejects any slug that still contains a character outside the
|
|
4821
|
+
* allowed set (the path-traversal guard). Throws on an invalid slug — never
|
|
4822
|
+
* silently rewrites it.
|
|
4823
|
+
*/
|
|
4824
|
+
static _sanitizeSlug(rawSlug) {
|
|
4825
|
+
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4826
|
+
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4827
|
+
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4828
|
+
}
|
|
4829
|
+
return slug;
|
|
4830
|
+
}
|
|
4831
|
+
/**
|
|
4832
|
+
* Disambiguate per-row slugs so two rows that produce the SAME base slug do not
|
|
4833
|
+
* write to (and clobber) the same directory.
|
|
4834
|
+
*
|
|
4835
|
+
* Returns one final slug per row, in the SAME order as `rows`. A base slug used
|
|
4836
|
+
* by exactly one row is returned unchanged (no churn for the common case). When
|
|
4837
|
+
* a base slug is shared by >1 row, EVERY colliding row gets a short, stable
|
|
4838
|
+
* suffix derived from its primary key (`<base>-<pk8>`), so the result is
|
|
4839
|
+
* order-independent: the same row gets the same slug on every render regardless
|
|
4840
|
+
* of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
|
|
4841
|
+
* collide (e.g. shared prefix), guaranteeing uniqueness without changing the
|
|
4842
|
+
* common-case output. Slugs are sanitized + path-traversal-validated via
|
|
4843
|
+
* {@link _sanitizeSlug}; `def.slug` itself is never modified.
|
|
4844
|
+
*/
|
|
4845
|
+
static _disambiguateSlugs(rows, slugFn, pkCol) {
|
|
4846
|
+
const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
|
|
4847
|
+
const byBase = /* @__PURE__ */ new Map();
|
|
4848
|
+
for (let i6 = 0; i6 < baseSlugs.length; i6++) {
|
|
4849
|
+
const base = baseSlugs[i6];
|
|
4850
|
+
const bucket = byBase.get(base);
|
|
4851
|
+
if (bucket) bucket.push(i6);
|
|
4852
|
+
else byBase.set(base, [i6]);
|
|
4853
|
+
}
|
|
4854
|
+
const final = baseSlugs.map(() => "");
|
|
4855
|
+
const pkOf = (i6) => {
|
|
4856
|
+
const v2 = rows[i6]?.[pkCol];
|
|
4857
|
+
let s2;
|
|
4858
|
+
if (v2 == null) s2 = "";
|
|
4859
|
+
else if (typeof v2 === "object") s2 = JSON.stringify(v2);
|
|
4860
|
+
else s2 = String(v2);
|
|
4861
|
+
return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
|
|
4862
|
+
};
|
|
4863
|
+
for (const [base, indices] of byBase) {
|
|
4864
|
+
if (indices.length === 1) {
|
|
4865
|
+
final[indices[0]] = base;
|
|
4866
|
+
continue;
|
|
4867
|
+
}
|
|
4868
|
+
const pks = indices.map(pkOf);
|
|
4869
|
+
const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
|
|
4870
|
+
let len = 8;
|
|
4871
|
+
while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
|
|
4872
|
+
len += 4;
|
|
4873
|
+
}
|
|
4874
|
+
for (let k6 = 0; k6 < indices.length; k6++) {
|
|
4875
|
+
final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
|
|
4876
|
+
}
|
|
4877
|
+
}
|
|
4878
|
+
return final;
|
|
4879
|
+
}
|
|
4756
4880
|
/**
|
|
4757
4881
|
* Prefetch the batchable belongsTo sources for one entity-context table.
|
|
4758
4882
|
* For each (target+filters+softDelete) group, issue exactly ONE
|
|
@@ -4833,6 +4957,7 @@ var init_engine = __esm({
|
|
|
4833
4957
|
const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
4834
4958
|
const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
|
|
4835
4959
|
const directoryRoot = def.directoryRoot ?? table;
|
|
4960
|
+
const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
|
|
4836
4961
|
const belongsToBatches = await this._prefetchBelongsToBatches(
|
|
4837
4962
|
def,
|
|
4838
4963
|
allRows,
|
|
@@ -4874,11 +4999,7 @@ var init_engine = __esm({
|
|
|
4874
4999
|
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
4875
5000
|
await new Promise((r6) => setImmediate(r6));
|
|
4876
5001
|
}
|
|
4877
|
-
const
|
|
4878
|
-
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4879
|
-
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4880
|
-
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4881
|
-
}
|
|
5002
|
+
const slug = finalSlugs[i6];
|
|
4882
5003
|
const entityDir = def.directory ? join7(outputDir, def.directory(entityRow)) : join7(outputDir, directoryRoot, slug);
|
|
4883
5004
|
const resolvedDir = resolve3(entityDir);
|
|
4884
5005
|
const resolvedBase = resolve3(outputDir);
|
|
@@ -76617,7 +76738,7 @@ function printHelp() {
|
|
|
76617
76738
|
);
|
|
76618
76739
|
}
|
|
76619
76740
|
function getVersion() {
|
|
76620
|
-
if (true) return "4.2.
|
|
76741
|
+
if (true) return "4.2.3";
|
|
76621
76742
|
try {
|
|
76622
76743
|
const pkgPath = new URL("../package.json", import.meta.url).pathname;
|
|
76623
76744
|
const pkg = JSON.parse(readFileSync25(pkgPath, "utf-8"));
|
package/dist/index.cjs
CHANGED
|
@@ -249,7 +249,7 @@ var init_manifest = __esm({
|
|
|
249
249
|
import_node_path2 = require("path");
|
|
250
250
|
import_node_fs2 = require("fs");
|
|
251
251
|
init_writer();
|
|
252
|
-
TEMPLATE_VERSION =
|
|
252
|
+
TEMPLATE_VERSION = 3;
|
|
253
253
|
}
|
|
254
254
|
});
|
|
255
255
|
|
|
@@ -3446,6 +3446,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
|
|
|
3446
3446
|
warnings: []
|
|
3447
3447
|
};
|
|
3448
3448
|
if (manifest === null) return result;
|
|
3449
|
+
if (options.removeOrphanedDirectories !== false) {
|
|
3450
|
+
for (const [table, entry] of Object.entries(manifest.entityContexts)) {
|
|
3451
|
+
if (entityContexts.has(table)) continue;
|
|
3452
|
+
const directoryRoot = entry.directoryRoot;
|
|
3453
|
+
const rootPath = (0, import_node_path5.join)(outputDir, directoryRoot);
|
|
3454
|
+
if (!(0, import_node_fs3.existsSync)(rootPath)) continue;
|
|
3455
|
+
const globalProtected = new Set(options.protectedFiles ?? []);
|
|
3456
|
+
for (const [slug, files] of Object.entries(entry.entities)) {
|
|
3457
|
+
const entityDir = (0, import_node_path5.join)(rootPath, slug);
|
|
3458
|
+
if (!(0, import_node_fs3.existsSync)(entityDir)) continue;
|
|
3459
|
+
for (const filename of entityFileNames(files)) {
|
|
3460
|
+
if (globalProtected.has(filename)) continue;
|
|
3461
|
+
const filePath = (0, import_node_path5.join)(entityDir, filename);
|
|
3462
|
+
if (!(0, import_node_fs3.existsSync)(filePath)) continue;
|
|
3463
|
+
if (!options.dryRun) (0, import_node_fs3.unlinkSync)(filePath);
|
|
3464
|
+
options.onOrphan?.(filePath, "file");
|
|
3465
|
+
result.filesRemoved.push(filePath);
|
|
3466
|
+
}
|
|
3467
|
+
let remaining;
|
|
3468
|
+
try {
|
|
3469
|
+
remaining = (0, import_node_fs3.existsSync)(entityDir) ? (0, import_node_fs3.readdirSync)(entityDir) : [];
|
|
3470
|
+
} catch {
|
|
3471
|
+
remaining = [];
|
|
3472
|
+
}
|
|
3473
|
+
if (remaining.length === 0) {
|
|
3474
|
+
if (!options.dryRun) {
|
|
3475
|
+
try {
|
|
3476
|
+
(0, import_node_fs3.rmdirSync)(entityDir);
|
|
3477
|
+
} catch {
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
options.onOrphan?.(entityDir, "directory");
|
|
3481
|
+
result.directoriesRemoved.push(entityDir);
|
|
3482
|
+
} else {
|
|
3483
|
+
result.directoriesSkipped.push(entityDir);
|
|
3484
|
+
result.warnings.push(
|
|
3485
|
+
`${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
let rootRemaining;
|
|
3490
|
+
try {
|
|
3491
|
+
rootRemaining = (0, import_node_fs3.existsSync)(rootPath) ? (0, import_node_fs3.readdirSync)(rootPath) : [];
|
|
3492
|
+
} catch {
|
|
3493
|
+
rootRemaining = [];
|
|
3494
|
+
}
|
|
3495
|
+
if (rootRemaining.length === 0) {
|
|
3496
|
+
if (!options.dryRun) {
|
|
3497
|
+
try {
|
|
3498
|
+
(0, import_node_fs3.rmdirSync)(rootPath);
|
|
3499
|
+
} catch {
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
options.onOrphan?.(rootPath, "directory");
|
|
3503
|
+
result.directoriesRemoved.push(rootPath);
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3449
3507
|
for (const [table, def] of entityContexts) {
|
|
3450
3508
|
const entry = manifest.entityContexts[table];
|
|
3451
3509
|
if (!entry) continue;
|
|
@@ -3945,7 +4003,8 @@ var init_engine = __esm({
|
|
|
3945
4003
|
const currentSlugsByTable = /* @__PURE__ */ new Map();
|
|
3946
4004
|
for (const [table, def] of entityContexts) {
|
|
3947
4005
|
const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
3948
|
-
const
|
|
4006
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
4007
|
+
const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
|
|
3949
4008
|
currentSlugsByTable.set(table, slugs);
|
|
3950
4009
|
}
|
|
3951
4010
|
return cleanupEntityContexts(
|
|
@@ -3994,6 +4053,71 @@ var init_engine = __esm({
|
|
|
3994
4053
|
static _normKey(v2) {
|
|
3995
4054
|
return String(v2);
|
|
3996
4055
|
}
|
|
4056
|
+
/**
|
|
4057
|
+
* Sanitize and validate ONE base slug.
|
|
4058
|
+
*
|
|
4059
|
+
* Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
|
|
4060
|
+
* that shows up in screenshot filenames) with a regular space, strips control
|
|
4061
|
+
* characters, then rejects any slug that still contains a character outside the
|
|
4062
|
+
* allowed set (the path-traversal guard). Throws on an invalid slug — never
|
|
4063
|
+
* silently rewrites it.
|
|
4064
|
+
*/
|
|
4065
|
+
static _sanitizeSlug(rawSlug) {
|
|
4066
|
+
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4067
|
+
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4068
|
+
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4069
|
+
}
|
|
4070
|
+
return slug;
|
|
4071
|
+
}
|
|
4072
|
+
/**
|
|
4073
|
+
* Disambiguate per-row slugs so two rows that produce the SAME base slug do not
|
|
4074
|
+
* write to (and clobber) the same directory.
|
|
4075
|
+
*
|
|
4076
|
+
* Returns one final slug per row, in the SAME order as `rows`. A base slug used
|
|
4077
|
+
* by exactly one row is returned unchanged (no churn for the common case). When
|
|
4078
|
+
* a base slug is shared by >1 row, EVERY colliding row gets a short, stable
|
|
4079
|
+
* suffix derived from its primary key (`<base>-<pk8>`), so the result is
|
|
4080
|
+
* order-independent: the same row gets the same slug on every render regardless
|
|
4081
|
+
* of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
|
|
4082
|
+
* collide (e.g. shared prefix), guaranteeing uniqueness without changing the
|
|
4083
|
+
* common-case output. Slugs are sanitized + path-traversal-validated via
|
|
4084
|
+
* {@link _sanitizeSlug}; `def.slug` itself is never modified.
|
|
4085
|
+
*/
|
|
4086
|
+
static _disambiguateSlugs(rows, slugFn, pkCol) {
|
|
4087
|
+
const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
|
|
4088
|
+
const byBase = /* @__PURE__ */ new Map();
|
|
4089
|
+
for (let i6 = 0; i6 < baseSlugs.length; i6++) {
|
|
4090
|
+
const base = baseSlugs[i6];
|
|
4091
|
+
const bucket = byBase.get(base);
|
|
4092
|
+
if (bucket) bucket.push(i6);
|
|
4093
|
+
else byBase.set(base, [i6]);
|
|
4094
|
+
}
|
|
4095
|
+
const final = baseSlugs.map(() => "");
|
|
4096
|
+
const pkOf = (i6) => {
|
|
4097
|
+
const v2 = rows[i6]?.[pkCol];
|
|
4098
|
+
let s2;
|
|
4099
|
+
if (v2 == null) s2 = "";
|
|
4100
|
+
else if (typeof v2 === "object") s2 = JSON.stringify(v2);
|
|
4101
|
+
else s2 = String(v2);
|
|
4102
|
+
return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
|
|
4103
|
+
};
|
|
4104
|
+
for (const [base, indices] of byBase) {
|
|
4105
|
+
if (indices.length === 1) {
|
|
4106
|
+
final[indices[0]] = base;
|
|
4107
|
+
continue;
|
|
4108
|
+
}
|
|
4109
|
+
const pks = indices.map(pkOf);
|
|
4110
|
+
const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
|
|
4111
|
+
let len = 8;
|
|
4112
|
+
while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
|
|
4113
|
+
len += 4;
|
|
4114
|
+
}
|
|
4115
|
+
for (let k6 = 0; k6 < indices.length; k6++) {
|
|
4116
|
+
final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
return final;
|
|
4120
|
+
}
|
|
3997
4121
|
/**
|
|
3998
4122
|
* Prefetch the batchable belongsTo sources for one entity-context table.
|
|
3999
4123
|
* For each (target+filters+softDelete) group, issue exactly ONE
|
|
@@ -4074,6 +4198,7 @@ var init_engine = __esm({
|
|
|
4074
4198
|
const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
4075
4199
|
const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
|
|
4076
4200
|
const directoryRoot = def.directoryRoot ?? table;
|
|
4201
|
+
const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
|
|
4077
4202
|
const belongsToBatches = await this._prefetchBelongsToBatches(
|
|
4078
4203
|
def,
|
|
4079
4204
|
allRows,
|
|
@@ -4115,11 +4240,7 @@ var init_engine = __esm({
|
|
|
4115
4240
|
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
4116
4241
|
await new Promise((r6) => setImmediate(r6));
|
|
4117
4242
|
}
|
|
4118
|
-
const
|
|
4119
|
-
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4120
|
-
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4121
|
-
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4122
|
-
}
|
|
4243
|
+
const slug = finalSlugs[i6];
|
|
4123
4244
|
const entityDir = def.directory ? (0, import_node_path6.join)(outputDir, def.directory(entityRow)) : (0, import_node_path6.join)(outputDir, directoryRoot, slug);
|
|
4124
4245
|
const resolvedDir = (0, import_node_path6.resolve)(entityDir);
|
|
4125
4246
|
const resolvedBase = (0, import_node_path6.resolve)(outputDir);
|
package/dist/index.js
CHANGED
|
@@ -240,7 +240,7 @@ var init_manifest = __esm({
|
|
|
240
240
|
"src/lifecycle/manifest.ts"() {
|
|
241
241
|
"use strict";
|
|
242
242
|
init_writer();
|
|
243
|
-
TEMPLATE_VERSION =
|
|
243
|
+
TEMPLATE_VERSION = 3;
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
246
|
|
|
@@ -3437,6 +3437,64 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
|
|
|
3437
3437
|
warnings: []
|
|
3438
3438
|
};
|
|
3439
3439
|
if (manifest === null) return result;
|
|
3440
|
+
if (options.removeOrphanedDirectories !== false) {
|
|
3441
|
+
for (const [table, entry] of Object.entries(manifest.entityContexts)) {
|
|
3442
|
+
if (entityContexts.has(table)) continue;
|
|
3443
|
+
const directoryRoot = entry.directoryRoot;
|
|
3444
|
+
const rootPath = join3(outputDir, directoryRoot);
|
|
3445
|
+
if (!existsSync3(rootPath)) continue;
|
|
3446
|
+
const globalProtected = new Set(options.protectedFiles ?? []);
|
|
3447
|
+
for (const [slug, files] of Object.entries(entry.entities)) {
|
|
3448
|
+
const entityDir = join3(rootPath, slug);
|
|
3449
|
+
if (!existsSync3(entityDir)) continue;
|
|
3450
|
+
for (const filename of entityFileNames(files)) {
|
|
3451
|
+
if (globalProtected.has(filename)) continue;
|
|
3452
|
+
const filePath = join3(entityDir, filename);
|
|
3453
|
+
if (!existsSync3(filePath)) continue;
|
|
3454
|
+
if (!options.dryRun) unlinkSync2(filePath);
|
|
3455
|
+
options.onOrphan?.(filePath, "file");
|
|
3456
|
+
result.filesRemoved.push(filePath);
|
|
3457
|
+
}
|
|
3458
|
+
let remaining;
|
|
3459
|
+
try {
|
|
3460
|
+
remaining = existsSync3(entityDir) ? readdirSync(entityDir) : [];
|
|
3461
|
+
} catch {
|
|
3462
|
+
remaining = [];
|
|
3463
|
+
}
|
|
3464
|
+
if (remaining.length === 0) {
|
|
3465
|
+
if (!options.dryRun) {
|
|
3466
|
+
try {
|
|
3467
|
+
rmdirSync(entityDir);
|
|
3468
|
+
} catch {
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
options.onOrphan?.(entityDir, "directory");
|
|
3472
|
+
result.directoriesRemoved.push(entityDir);
|
|
3473
|
+
} else {
|
|
3474
|
+
result.directoriesSkipped.push(entityDir);
|
|
3475
|
+
result.warnings.push(
|
|
3476
|
+
`${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
|
|
3477
|
+
);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
let rootRemaining;
|
|
3481
|
+
try {
|
|
3482
|
+
rootRemaining = existsSync3(rootPath) ? readdirSync(rootPath) : [];
|
|
3483
|
+
} catch {
|
|
3484
|
+
rootRemaining = [];
|
|
3485
|
+
}
|
|
3486
|
+
if (rootRemaining.length === 0) {
|
|
3487
|
+
if (!options.dryRun) {
|
|
3488
|
+
try {
|
|
3489
|
+
rmdirSync(rootPath);
|
|
3490
|
+
} catch {
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
options.onOrphan?.(rootPath, "directory");
|
|
3494
|
+
result.directoriesRemoved.push(rootPath);
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3440
3498
|
for (const [table, def] of entityContexts) {
|
|
3441
3499
|
const entry = manifest.entityContexts[table];
|
|
3442
3500
|
if (!entry) continue;
|
|
@@ -3933,7 +3991,8 @@ var init_engine = __esm({
|
|
|
3933
3991
|
const currentSlugsByTable = /* @__PURE__ */ new Map();
|
|
3934
3992
|
for (const [table, def] of entityContexts) {
|
|
3935
3993
|
const rows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
3936
|
-
const
|
|
3994
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
3995
|
+
const slugs = new Set(_RenderEngine._disambiguateSlugs(rows, def.slug, entityPk));
|
|
3937
3996
|
currentSlugsByTable.set(table, slugs);
|
|
3938
3997
|
}
|
|
3939
3998
|
return cleanupEntityContexts(
|
|
@@ -3982,6 +4041,71 @@ var init_engine = __esm({
|
|
|
3982
4041
|
static _normKey(v2) {
|
|
3983
4042
|
return String(v2);
|
|
3984
4043
|
}
|
|
4044
|
+
/**
|
|
4045
|
+
* Sanitize and validate ONE base slug.
|
|
4046
|
+
*
|
|
4047
|
+
* Replaces non-ASCII whitespace (e.g. the macOS narrow no-break space U+202F
|
|
4048
|
+
* that shows up in screenshot filenames) with a regular space, strips control
|
|
4049
|
+
* characters, then rejects any slug that still contains a character outside the
|
|
4050
|
+
* allowed set (the path-traversal guard). Throws on an invalid slug — never
|
|
4051
|
+
* silently rewrites it.
|
|
4052
|
+
*/
|
|
4053
|
+
static _sanitizeSlug(rawSlug) {
|
|
4054
|
+
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4055
|
+
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4056
|
+
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4057
|
+
}
|
|
4058
|
+
return slug;
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* Disambiguate per-row slugs so two rows that produce the SAME base slug do not
|
|
4062
|
+
* write to (and clobber) the same directory.
|
|
4063
|
+
*
|
|
4064
|
+
* Returns one final slug per row, in the SAME order as `rows`. A base slug used
|
|
4065
|
+
* by exactly one row is returned unchanged (no churn for the common case). When
|
|
4066
|
+
* a base slug is shared by >1 row, EVERY colliding row gets a short, stable
|
|
4067
|
+
* suffix derived from its primary key (`<base>-<pk8>`), so the result is
|
|
4068
|
+
* order-independent: the same row gets the same slug on every render regardless
|
|
4069
|
+
* of row order. The suffix lengthens only if two rows' 8-char PK prefixes still
|
|
4070
|
+
* collide (e.g. shared prefix), guaranteeing uniqueness without changing the
|
|
4071
|
+
* common-case output. Slugs are sanitized + path-traversal-validated via
|
|
4072
|
+
* {@link _sanitizeSlug}; `def.slug` itself is never modified.
|
|
4073
|
+
*/
|
|
4074
|
+
static _disambiguateSlugs(rows, slugFn, pkCol) {
|
|
4075
|
+
const baseSlugs = rows.map((row) => _RenderEngine._sanitizeSlug(slugFn(row)));
|
|
4076
|
+
const byBase = /* @__PURE__ */ new Map();
|
|
4077
|
+
for (let i6 = 0; i6 < baseSlugs.length; i6++) {
|
|
4078
|
+
const base = baseSlugs[i6];
|
|
4079
|
+
const bucket = byBase.get(base);
|
|
4080
|
+
if (bucket) bucket.push(i6);
|
|
4081
|
+
else byBase.set(base, [i6]);
|
|
4082
|
+
}
|
|
4083
|
+
const final = baseSlugs.map(() => "");
|
|
4084
|
+
const pkOf = (i6) => {
|
|
4085
|
+
const v2 = rows[i6]?.[pkCol];
|
|
4086
|
+
let s2;
|
|
4087
|
+
if (v2 == null) s2 = "";
|
|
4088
|
+
else if (typeof v2 === "object") s2 = JSON.stringify(v2);
|
|
4089
|
+
else s2 = String(v2);
|
|
4090
|
+
return _RenderEngine._sanitizeSlug(s2).replace(/[ /\\]/g, "");
|
|
4091
|
+
};
|
|
4092
|
+
for (const [base, indices] of byBase) {
|
|
4093
|
+
if (indices.length === 1) {
|
|
4094
|
+
final[indices[0]] = base;
|
|
4095
|
+
continue;
|
|
4096
|
+
}
|
|
4097
|
+
const pks = indices.map(pkOf);
|
|
4098
|
+
const maxLen = Math.max(...pks.map((p3) => p3.length), 1);
|
|
4099
|
+
let len = 8;
|
|
4100
|
+
while (len < maxLen && new Set(pks.map((p3) => p3.slice(0, len))).size !== pks.length) {
|
|
4101
|
+
len += 4;
|
|
4102
|
+
}
|
|
4103
|
+
for (let k6 = 0; k6 < indices.length; k6++) {
|
|
4104
|
+
final[indices[k6]] = `${base}-${pks[k6].slice(0, len)}`;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
return final;
|
|
4108
|
+
}
|
|
3985
4109
|
/**
|
|
3986
4110
|
* Prefetch the batchable belongsTo sources for one entity-context table.
|
|
3987
4111
|
* For each (target+filters+softDelete) group, issue exactly ONE
|
|
@@ -4062,6 +4186,7 @@ var init_engine = __esm({
|
|
|
4062
4186
|
const baseRows = await this._schema.queryTable(this._adapter, table, this._schema.readRel);
|
|
4063
4187
|
const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
|
|
4064
4188
|
const directoryRoot = def.directoryRoot ?? table;
|
|
4189
|
+
const finalSlugs = _RenderEngine._disambiguateSlugs(allRows, def.slug, entityPk);
|
|
4065
4190
|
const belongsToBatches = await this._prefetchBelongsToBatches(
|
|
4066
4191
|
def,
|
|
4067
4192
|
allRows,
|
|
@@ -4103,11 +4228,7 @@ var init_engine = __esm({
|
|
|
4103
4228
|
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
4104
4229
|
await new Promise((r6) => setImmediate(r6));
|
|
4105
4230
|
}
|
|
4106
|
-
const
|
|
4107
|
-
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
4108
|
-
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
4109
|
-
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
4110
|
-
}
|
|
4231
|
+
const slug = finalSlugs[i6];
|
|
4111
4232
|
const entityDir = def.directory ? join4(outputDir, def.directory(entityRow)) : join4(outputDir, directoryRoot, slug);
|
|
4112
4233
|
const resolvedDir = resolve(entityDir);
|
|
4113
4234
|
const resolvedBase = resolve(outputDir);
|
package/package.json
CHANGED