feat: tsconfig compilerOptions.paths — aliased bare specifiers join the graph and follow moves
- index/tsconfig: JSONC sanitize (comments, trailing commas), baseUrl + star/exact path keys, first candidate only, longest-prefix wins; invalid or missing tsconfig silently means no aliases - resolution pass: '.'-prefixed specifiers resolve relatively as before, bare ones go through the alias table and join the importer graph - planner: remap keeps the alias shape while the moved file stays inside the mapped tree (morphological, since destinations do not exist yet), exact keys keep meaning only while still resolving to the target; otherwise the previous relative rewrite applies unchanged - resolve_base extracted from resolve_module (shared extension/index guessing); MODULE_EXTS is the exact inverse for remapping - e2e: @utils/str.ts -> @utils/text.ts inside alias, -> ./core/text.ts when leaving it, @cfg falls back to ./settings; check passes on aliases
This commit is contained in:
parent
f8004e669b
commit
b1af375763
14 changed files with 400 additions and 5 deletions
|
|
@ -40,6 +40,13 @@ working tree unstaged either way — stage or commit them yourself.
|
|||
`--json` reports the choice as `moved_via` (`"git"`/`"fs"`) and, on a
|
||||
dry-run, `would_move_via`.
|
||||
|
||||
tsconfig `paths`: `compilerOptions.paths` prefixes (`@/*`, `@cfg` exact
|
||||
keys, `baseUrl`-relative) map bare specifiers into the project graph, so
|
||||
aliased imports participate in `mv` — the rewrite keeps the alias shape
|
||||
while the new file stays inside the mapped tree and falls back to a
|
||||
relative specifier otherwise. `extends` chains and 2nd+ candidate lists
|
||||
are not followed (v1); an invalid tsconfig silently means "no aliases".
|
||||
|
||||
### check — find broken imports and Java layout errors
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
mod files;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tsconfig;
|
||||
|
||||
pub use tsconfig::PathAliases;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
|
|
@ -48,6 +51,8 @@ pub struct Index {
|
|||
/// FQN → file map for every indexed Java class; the fix rules use it
|
||||
/// for candidate lookup (empty when the project has no Java sources).
|
||||
pub java_classes: JavaClassIndex,
|
||||
/// tsconfig `compilerOptions.paths` alias table (empty without one).
|
||||
pub aliases: PathAliases,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
|
|
@ -64,12 +69,14 @@ impl Index {
|
|||
/// collision-free resolution (and therefore `mv`/`fix` rewrites) exact.
|
||||
pub fn build_scoped(root: &Path, source_root: Option<&Path>) -> JmoveResult<Self> {
|
||||
let root = root.canonicalize()?;
|
||||
let aliases = PathAliases::load(&root);
|
||||
let mut index = Self {
|
||||
root,
|
||||
files: FileSet::default(),
|
||||
imports: HashMap::new(),
|
||||
packages: HashMap::new(),
|
||||
java_classes: JavaClassIndex::default(),
|
||||
aliases,
|
||||
};
|
||||
index.scan(source_root)?;
|
||||
// Resolution needs the complete file set (extension/index guessing)
|
||||
|
|
@ -82,8 +89,14 @@ impl Index {
|
|||
java_classes
|
||||
.resolve(&resolved.record.specifier)
|
||||
.map(PathBuf::from)
|
||||
} else {
|
||||
} else if resolved.record.specifier.starts_with('.') {
|
||||
resolve_module(importer, &resolved.record.specifier, &index.files)
|
||||
} else {
|
||||
// Bare specifier: only tsconfig `paths` can map it into
|
||||
// the project; anything else stays external.
|
||||
index
|
||||
.aliases
|
||||
.resolve(&resolved.record.specifier, &index.files)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
212
src/core/index/tsconfig/mod.rs
Normal file
212
src/core/index/tsconfig/mod.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! tsconfig `compilerOptions.paths`: the alias table for bare specifiers.
|
||||
//!
|
||||
//! Most real TS projects import through aliases (`@/utils/x`, `@cfg`).
|
||||
//! Without the mapping those imports are invisible to the graph, so `mv`
|
||||
//! would leave them pointing at the old location. Loading is best-effort by
|
||||
//! design: no tsconfig, invalid JSON or an unreadable file simply mean "no
|
||||
//! aliases", never an error. `extends` chains are not followed (v1).
|
||||
//!
|
||||
//! tsconfig is JSONC: comments and trailing commas are legal, so the text
|
||||
//! is sanitized before `serde_json` sees it.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::index::FileSet;
|
||||
use crate::core::{normalize_rel_path, rel_str};
|
||||
use crate::parser::resolve::{MODULE_EXTS, resolve_base};
|
||||
|
||||
/// One `paths` entry. Star entries match by prefix, plain keys by equality.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Entry {
|
||||
/// Text the specifier must start with (star stripped), e.g. `"@utils/"`.
|
||||
prefix: String,
|
||||
/// `false` for an exact key (`"@cfg"`).
|
||||
starred: bool,
|
||||
/// Project-relative directory (star) or module base (exact) the alias maps into.
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Alias table; empty is a perfectly normal state.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct PathAliases {
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl PathAliases {
|
||||
/// Load `root/tsconfig.json`; anything unreadable yields no aliases.
|
||||
#[must_use]
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let Ok(raw) = fs::read_to_string(root.join("tsconfig.json")) else {
|
||||
return Self::default();
|
||||
};
|
||||
Self::parse(&sanitize_jsonc(&raw))
|
||||
}
|
||||
|
||||
fn parse(json: &str) -> Self {
|
||||
let Ok(value) = serde_json::from_str::<Value>(json) else {
|
||||
return Self::default();
|
||||
};
|
||||
let options = value.get("compilerOptions");
|
||||
let base = options
|
||||
.and_then(|o| o.get("baseUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|b| normalize_rel_path(Path::new(b)))
|
||||
.unwrap_or_default();
|
||||
let Some(paths) = options
|
||||
.and_then(|o| o.get("paths"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return Self::default();
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for (key, targets) in paths {
|
||||
// Only the first candidate of a list is honoured (KISS).
|
||||
let Some(first) = targets
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let starred = key.ends_with('*');
|
||||
let prefix = if starred {
|
||||
key.trim_end_matches('*').to_owned()
|
||||
} else {
|
||||
key.clone()
|
||||
};
|
||||
let stripped = first.trim_start_matches("./");
|
||||
let dir_src = if first.ends_with("/*") {
|
||||
stripped.trim_end_matches("/*")
|
||||
} else {
|
||||
stripped
|
||||
};
|
||||
let dir = normalize_rel_path(&base.join(dir_src));
|
||||
let Some(dir) = dir.filter(|d| !d.as_os_str().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(Entry {
|
||||
prefix,
|
||||
starred,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
// Longest alias prefix wins when several match (`@a/b/*` over `@a/*`).
|
||||
entries.sort_by(|a, b| b.prefix.cmp(&a.prefix));
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// Resolve a bare (non-`.`-starting) specifier to an indexed file.
|
||||
#[must_use]
|
||||
pub fn resolve(&self, specifier: &str, files: &FileSet) -> Option<PathBuf> {
|
||||
let entry = self.find(specifier)?;
|
||||
let base = if entry.starred {
|
||||
entry.dir.join(&specifier[entry.prefix.len()..])
|
||||
} else {
|
||||
entry.dir.clone()
|
||||
};
|
||||
resolve_base(&normalize_rel_path(&base)?, files)
|
||||
}
|
||||
|
||||
/// Re-express `target` through the same alias `specifier` used before.
|
||||
/// Star entries map morphologically (the plan runs before the file
|
||||
/// exists at its destination, so no file-set check is possible): the
|
||||
/// target must sit inside the alias directory and shed its module
|
||||
/// extension the same way `resolve` would re-add it. `None` means
|
||||
/// "fall back to a relative specifier".
|
||||
#[must_use]
|
||||
pub fn remap(&self, specifier: &str, target: &Path, files: &FileSet) -> Option<String> {
|
||||
let entry = self.find(specifier)?;
|
||||
if !entry.starred {
|
||||
// Exact key: it only keeps meaning while it maps to this file —
|
||||
// after the move the old mapping points elsewhere, so this is
|
||||
// normally `None` and the caller rewrites relatively.
|
||||
return (self.resolve(specifier, files)? == target).then(|| specifier.to_owned());
|
||||
}
|
||||
let rel = target.strip_prefix(&entry.dir).ok()?;
|
||||
let text = rel_str(rel);
|
||||
let stem = MODULE_EXTS
|
||||
.iter()
|
||||
.find(|ext| text.ends_with(**ext))
|
||||
.and_then(|ext| Some(text.strip_suffix(*ext)?.to_owned()))
|
||||
.unwrap_or(text);
|
||||
Some(format!("{}{}", entry.prefix, stem))
|
||||
}
|
||||
|
||||
fn find(&self, specifier: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| {
|
||||
if e.starred {
|
||||
specifier.starts_with(&e.prefix) && specifier.len() > e.prefix.len()
|
||||
} else {
|
||||
specifier == e.prefix
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip `//` + `/* */` comments and trailing commas from JSONC text.
|
||||
/// String awareness keeps `"http://x"` and escaped quotes safe.
|
||||
#[must_use]
|
||||
pub(crate) fn sanitize_jsonc(src: &str) -> String {
|
||||
let bytes = src.as_bytes();
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut i = 0;
|
||||
let mut in_string = false;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if in_string {
|
||||
out.push(b as char);
|
||||
if b == b'\\' && i + 1 < bytes.len() {
|
||||
out.push(bytes[i + 1] as char);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => {
|
||||
in_string = true;
|
||||
out.push('"');
|
||||
i += 1;
|
||||
}
|
||||
b'/' if bytes.get(i + 1) == Some(&b'/') => {
|
||||
i = bytes[i..]
|
||||
.iter()
|
||||
.position(|c| *c == b'\n')
|
||||
.map_or(bytes.len(), |p| i + p);
|
||||
}
|
||||
b'/' if bytes.get(i + 1) == Some(&b'*') => {
|
||||
let end = bytes[i + 2..]
|
||||
.windows(2)
|
||||
.position(|w| w == b"*/")
|
||||
.map_or(bytes.len() - 2, |p| i + 2 + p + 2);
|
||||
i = end;
|
||||
}
|
||||
b',' => {
|
||||
// Drop only if the next non-space token closes a container.
|
||||
let rest = &bytes[i + 1..];
|
||||
match rest.iter().find(|c| !c.is_ascii_whitespace()) {
|
||||
Some(b'}') | Some(b']') => i += 1,
|
||||
_ => {
|
||||
out.push(',');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
out.push(b as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
80
src/core/index/tsconfig/tests.rs
Normal file
80
src/core/index/tsconfig/tests.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use super::{PathAliases, sanitize_jsonc};
|
||||
use crate::core::index::FileSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn files(paths: &[&str]) -> FileSet {
|
||||
let mut set = FileSet::default();
|
||||
for p in paths {
|
||||
set.add(PathBuf::from(p));
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn aliases(json: &str) -> PathAliases {
|
||||
PathAliases::parse(&sanitize_jsonc(json))
|
||||
}
|
||||
|
||||
const TSCONFIG: &str = r#"{
|
||||
// comment with a brace }
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@utils/*": ["src/shared/utils/*"],
|
||||
"@cfg": ["src/config.ts"],
|
||||
},
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn jsonc_comments_and_trailing_commas_parse() {
|
||||
let a = aliases(TSCONFIG);
|
||||
assert_eq!(a.entries.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_prefers_the_longest_matching_prefix() {
|
||||
let set = files(&[
|
||||
"src/shared/utils/str.ts",
|
||||
"src/utils/str.ts",
|
||||
"src/config.ts",
|
||||
]);
|
||||
let a = aliases(TSCONFIG);
|
||||
assert_eq!(
|
||||
a.resolve("@utils/str", &set).as_deref(),
|
||||
Some(Path::new("src/shared/utils/str.ts"))
|
||||
);
|
||||
assert_eq!(
|
||||
a.resolve("@/utils/str", &set).as_deref(),
|
||||
Some(Path::new("src/utils/str.ts"))
|
||||
);
|
||||
assert_eq!(
|
||||
a.resolve("@cfg", &set).as_deref(),
|
||||
Some(Path::new("src/config.ts"))
|
||||
);
|
||||
assert_eq!(a.resolve("react", &set), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_keeps_alias_shape_inside_and_falls_back_outside() {
|
||||
let set = files(&["src/utils/str.ts", "src/deep/str.ts", "src/config.ts"]);
|
||||
let a = aliases(r#"{"compilerOptions": {"paths": {"@u/*": ["src/utils/*"]}}}"#);
|
||||
// Destination need not exist yet: the plan runs before the move.
|
||||
assert_eq!(
|
||||
a.remap("@u/str", Path::new("src/utils/other.ts"), &set)
|
||||
.as_deref(),
|
||||
Some("@u/other")
|
||||
);
|
||||
// Target outside the alias tree: caller must fall back to relative.
|
||||
assert!(a.remap("@u/str", Path::new("lib/other.ts"), &set).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_contents_survive_sanitizing() {
|
||||
let json = r#"{"url": "http://x.com//y", "a": [1, 2,], /* c */ "b": "/*n*/"}"#;
|
||||
let cleaned = sanitize_jsonc(json);
|
||||
let value: serde_json::Value = serde_json::from_str(&cleaned).unwrap();
|
||||
assert_eq!(value["url"], "http://x.com//y");
|
||||
assert_eq!(value["b"], "/*n*/");
|
||||
}
|
||||
|
|
@ -147,7 +147,13 @@ fn ts_rewrites(index: &Index, source: &Path, target: &Path) -> Vec<Rewrite> {
|
|||
.iter()
|
||||
.filter(|e| e.target.as_deref() == Some(source));
|
||||
for edge in edges {
|
||||
let new_text = relative_specifier(&importer, target);
|
||||
// Aliased imports keep their alias shape when the new location
|
||||
// still round-trips through the same mapping; everything else
|
||||
// gets the relative rewrite.
|
||||
let new_text = index
|
||||
.aliases
|
||||
.remap(&edge.record.specifier, target, &index.files)
|
||||
.unwrap_or_else(|| relative_specifier(&importer, target));
|
||||
if new_text == edge.record.specifier {
|
||||
continue; // no-op rewrite, never reaches the plan
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ use crate::core::normalize_rel_path;
|
|||
/// Supported extensions, in Node/TS resolution priority order.
|
||||
const EXTENSIONS: [&str; 6] = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
|
||||
|
||||
/// Module extension *suffixes*, longest-first: `resolve_base` re-adds them
|
||||
/// to a specifier, alias remapping sheds them again (the exact inverse).
|
||||
pub const MODULE_EXTS: &[&str] = &[".d.ts", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
||||
|
||||
/// Ambient declaration files: only consulted after every real module
|
||||
/// candidate missed (last resort).
|
||||
const DECLARATION_EXT: &str = "d.ts";
|
||||
|
|
@ -45,9 +49,18 @@ pub fn resolve_module(importer: &Path, specifier: &str, files: &FileSet) -> Opti
|
|||
// `importer` is project-relative, so `..` segments that walk past the
|
||||
// root collapse to `None` here instead of escaping the index.
|
||||
let base = normalize_rel_path(&importer.parent()?.join(specifier))?;
|
||||
if files.contains(&base) {
|
||||
return Some(base);
|
||||
resolve_base(&base, files)
|
||||
}
|
||||
|
||||
/// Resolve a project-relative module base path against the file set:
|
||||
/// exact file, extension guessing, then `index.*` in the directory.
|
||||
/// Shared by relative specifiers and tsconfig alias mapping.
|
||||
#[must_use]
|
||||
pub fn resolve_base(base: &Path, files: &FileSet) -> Option<PathBuf> {
|
||||
if files.contains(base) {
|
||||
return Some(base.to_path_buf());
|
||||
}
|
||||
let base = base.to_path_buf();
|
||||
for ext in EXTENSIONS {
|
||||
let candidate = with_ext(&base, ext);
|
||||
if files.contains(&candidate) {
|
||||
|
|
|
|||
37
tests/cli_alias.rs
Normal file
37
tests/cli_alias.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! End-to-end tests for tsconfig `paths` aliases: aliased imports take
|
||||
//! part in `mv` and keep their alias shape inside the mapped tree.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{copy_fixture, in_root, jmove, read};
|
||||
use predicates::prelude::*;
|
||||
|
||||
const APP: &str = "src/app.ts";
|
||||
|
||||
#[test]
|
||||
fn aliased_imports_follow_the_move_and_keep_the_alias_shape() {
|
||||
let tmp = copy_fixture("typescript", "aliased");
|
||||
jmove(&tmp, &["check"]).success(); // "@utils/str" resolves: not "broken"
|
||||
jmove(&tmp, &["mv", "src/utils/str.ts", "src/utils/text.ts"])
|
||||
.success()
|
||||
.stdout(predicate::str::contains("updated 1 import"));
|
||||
let app = read(&in_root(tmp.path(), APP));
|
||||
assert!(app.contains("from \"@utils/text\""), "{app}");
|
||||
assert!(!app.contains("./utils/text"), "{app}");
|
||||
// Moving out of the alias tree falls back to a relative specifier.
|
||||
jmove(&tmp, &["mv", "src/utils/text.ts", "src/core/text.ts"]).success();
|
||||
let app = read(&in_root(tmp.path(), APP));
|
||||
assert!(app.contains("from \"./core/text\""), "{app}");
|
||||
jmove(&tmp, &["check"]).success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_alias_key_falls_back_when_it_no_longer_matches() {
|
||||
let tmp = copy_fixture("typescript", "aliased");
|
||||
jmove(&tmp, &["mv", "src/config.ts", "src/settings.ts"]).success();
|
||||
let app = read(&in_root(tmp.path(), APP));
|
||||
// "@cfg" would now resolve to nothing: the rewrite must be relative.
|
||||
assert!(app.contains("from \"./settings\""), "{app}");
|
||||
assert!(!app.contains("@cfg"), "{app}");
|
||||
jmove(&tmp, &["check"]).success();
|
||||
}
|
||||
1
tests/typescript/aliased/package.json
Normal file
1
tests/typescript/aliased/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "aliased-fixture" }
|
||||
7
tests/typescript/aliased/src/app.ts
Normal file
7
tests/typescript/aliased/src/app.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { shout } from "@utils/str";
|
||||
import cfg from "@cfg";
|
||||
import { log } from "./utils/log";
|
||||
|
||||
export function go(): string {
|
||||
return shout(cfg) + log();
|
||||
}
|
||||
1
tests/typescript/aliased/src/config.ts
Normal file
1
tests/typescript/aliased/src/config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default "cfg";
|
||||
3
tests/typescript/aliased/src/utils/log.ts
Normal file
3
tests/typescript/aliased/src/utils/log.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function log(): string {
|
||||
return "";
|
||||
}
|
||||
3
tests/typescript/aliased/src/utils/str.ts
Normal file
3
tests/typescript/aliased/src/utils/str.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function shout(s: string): string {
|
||||
return s.toUpperCase();
|
||||
}
|
||||
10
tests/typescript/aliased/tsconfig.json
Normal file
10
tests/typescript/aliased/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
// JSONC on purpose: comments and trailing commas must not break loading
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@utils/*": ["utils/*"],
|
||||
"@cfg": ["config.ts"],
|
||||
},
|
||||
},
|
||||
}
|
||||
4
todo.md
4
todo.md
|
|
@ -90,7 +90,9 @@ AI оставляем СНАРУЖИ: при неоднозначности jmov
|
|||
## Phase 2
|
||||
- [ ] Кэш индекса на диске (bincode/rkyv) → .jmove/index
|
||||
- [ ] Инкрементальная переиндексация (только изменённые файлы)
|
||||
- [ ] Поддержка tsconfig paths / алиасов (@/...)
|
||||
- [x] Поддержка tsconfig paths / алиасов (@/...): JSONC-парсер (комментарии/хвостовые запятые),
|
||||
baseUrl + star/exact keys, longest-prefix wins; при mv алиас сохраняется, если файл остался
|
||||
в дереве алиаса, иначе fallback на относительный; extends/2+ кандидаты — осознанно не делаем (v1)
|
||||
- [ ] Параллельная индексация через rayon
|
||||
- [x] --git интеграция (git mv для stage/истории): auto для tracked файлов, --no-git флаг, moved_via/would_move_via в --json
|
||||
- [x] Перенос директорий целиком (mv папки): зеркальный batch-move всех индексируемых файлов, merged rewrites, prune пустых исходных каталогов, left_behind для неиндексируемых
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue