Перейти к содержанию

Прежние адреса и карта соответствий

Сайт с историей часто имеет адреса, которые нельзя менять вслед за новой структурой файлов. Отделим ID записи от publicPath и покажем явное преобразование готового выпуска. В учебной «Полке» один материал должен сохранить /my/content/intro.php. Это пример договора адресации; настоящий защищённый реестр ProfessorWeb не изменяется.

Схема получает необязательное поле publicPath. Оно ограничено абсолютным путём и разрешёнными расширениями. Значение не является произвольным внешним URL. Запись Markdown остаётся markdown, поэтому поиск и порядок могут использовать прежний ID. Функция articleUrl возвращает сохранённый путь, когда он задан, иначе строит обычный HTML-адрес.

Внутренний ID и публичный адрес

Все списки используют эту функцию. Значит, карточка и соседний урок будут ссылаться на публичный адрес. Canonical статьи также получает articleUrl(entry). Иначе материал мог бы показываться по старому PHP-пути, а метаданные продолжали бы объявлять временный адрес генератора. Содержание и метаданные должны описывать один договор, а не две конкурирующие версии.

Сам маршрут Astro всё ещё подготавливает /articles/markdown.html. Мы не утверждаем, что расширение PHP магически становится встроенным статическим маршрутом. После генерации endpoint url-map.json описывает соответствие временного и публичного путей. Отдельный script переносит готовый HTML в целевой путь и удаляет временный файл. Команда build явно включает оба действия.

Скрипт сначала проверяет публичные пути и их уникальность. Это предотвращает ситуацию, когда две записи записываются в один целевой файл. Отдельный набор generated содержит все временные маршруты. PublicPath может совпадать со своим generatedPath, но совпадение с маршрутом другой записи останавливает преобразование до первой записи файла. Иначе перенос одной статьи мог бы перезаписать ещё не прочитанный источник другой. Проверка также запрещает путь, начинающийся с двух слешей: //example.test/intro.php является сетевым адресом, а не допустимым локальным путём нашего договора. Повторение разделителей внутри пути тоже отклоняется, чтобы нормализация файловой системы не превратила две разные строки в одно место записи.

После предварительного разбора скрипт читает уже созданный HTML, создаёт нужные каталоги и записывает содержимое. Проверка пути исключает произвольный выход через ... В большой библиотеке требуется расширить договор на все защищённые адреса и конфликты с прочими ресурсами; небольшой пример не заменяет такую миграционную проверку.

Расширение .php создаёт отдельное условие хостинга. Файл содержит HTML без PHP-кода, но сервер должен отдавать его как страницу с правильным Content-Type либо использовать осознанно выбранный совместимый обработчик. Некоторые статические платформы и локальные preview-серверы трактуют такие файлы иначе. Поэтому наличие файла в dist ещё не доказывает, что прямой публичный запрос сохранён.

Не копируем одновременно старый и новый файл как две равноправные страницы. Временный маршрут удаляется, чтобы не оставить случайный дубль. Если продукт выбирает редирект вместо сохранения исходного URL, это другой явный договор с собственным статусом и целевым адресом. В этом уроке показано сохранение прямой формы пути, а не скрытая переадресация.

Ожидаемый выпуск должен содержать my/content/intro.php с canonical старого пути, а прежний временный файл отсутствовать. Преобразование не запускалось, HTTP-статусы не получены. Для будущего просмотра требуется проверить прямой запрос, обновление страницы, тип содержимого, внутреннюю ссылку и canonical в реальной среде. Сравнение одной папки на диске не охватывает весь этот договор.

Полный файл src/lib/lessons.ts текущего этапа:

import { getCollection } from 'astro:content';
export async function publishedLessons() {
  return (await getCollection('lessons', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order || a.id.localeCompare(b.id, 'en'));
}
export function articleUrl(entry: { id: string; data: { publicPath?: string } }) { return entry.data.publicPath ?? `/articles/${entry.id}.html`; }

Полный файл src/pages/url-map.json.ts текущего этапа:

import { publishedLessons, articleUrl } from '../lib/lessons';
export async function GET() {
  const entries = await publishedLessons();
  return new Response(JSON.stringify(entries.map(entry => ({ publicPath: articleUrl(entry), generatedPath: `/articles/${entry.id}.html` }))), { headers: { 'Content-Type': 'application/json' } });
}

Полный файл scripts/preserve-urls.mjs текущего этапа:

import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve('dist');
const rows = JSON.parse(await readFile(path.join(root, 'url-map.json'), 'utf8'));
const targets = new Set();
const generated = new Set(rows.map(row => row.generatedPath));
for (const row of rows) {
  if (!/^\/(?!\/)[a-zA-Z0-9_\/-]+\.(html|php)$/.test(row.publicPath) || row.publicPath.includes('..') || row.publicPath.includes('//')) throw new Error('Invalid publicPath');
  if (targets.has(row.publicPath)) throw new Error('Duplicate publicPath');
  if (row.publicPath !== row.generatedPath && generated.has(row.publicPath)) throw new Error('publicPath collides with another generated route');
  targets.add(row.publicPath);
}
for (const row of rows) {
  if (row.publicPath === row.generatedPath) continue;
  const source = path.join(root, row.generatedPath.slice(1));
  const target = path.join(root, row.publicPath.slice(1));
  const html = await readFile(source, 'utf8');
  await mkdir(path.dirname(target), { recursive: true });
  await writeFile(target, html);
  await rm(source);
}
await rm(path.join(root, 'url-map.json'));

Разбор состояния библиотеки

Перед переносом важно проверить не только одинаковые публичные пути, но и конфликт с другими подготовленными файлами. В маленьком проекте мы контролируем один сохранённый адрес и знаем, что его место свободно. Для настоящей миграции понадобится полный manifest: страницы, каталоги, ресурсы и настройки сервера. Скрипт серии иллюстрирует преобразование, но не является универсальным импортёром любой исторической структуры.

Путь берётся из собственных редакционных данных, а не из пользовательского запроса. Тем не менее регулярное выражение и запрет .. задают ограничение назначения. Это уменьшает риск случайного выхода за dist. Исходный generatedPath формируется контролируемым endpoint. Если разрешить внешний источник manifest, потребуется валидация и этой стороны, включая схемы, типы и конфликтующие имена. Граница доверия должна быть названа явно.

Статический HTML с расширением PHP может обслуживаться PHP-обработчиком без исполняемых вставок, однако это условие конкретной конфигурации. Нельзя считать такое поведение переносимым между всеми платформами. На одном сервере файл вернётся как страница, на другом будет скачиваться, на третьем путь окажется запрещён. Поэтому договор Content-Type имеет самостоятельное значение наряду с сохранением байтов на диске.

Для самостоятельного разбора выпишите цепочку одной записи: ID markdown, временный HTML, publicPath, ссылка карточки, canonical и конечный файл. Затем поменяйте только publicPath. Все публичные представления должны следовать новому значению, а внутренний ID останется прежним. В реальной миграции такой эксперимент нельзя делать с опубликованным защищённым адресом; здесь это отдельный локальный учебный набор.

При ошибке посередине преобразования dist может оказаться частично изменённым. Поэтому выпуск следует готовить в отдельной папке и передавать только после согласованного завершения. Не запускайте такой перенос поверх текущего production-каталога как случайную правку одного файла. Это также объясняет, почему резервная копия должна предшествовать замене настоящего выпуска, а не делаться после обнаружения первой пропавшей страницы.

Наконец, сохранение URL не даёт автоматической гарантии сохранения трафика. Содержание, код ответа, метаданные, внутренние ссылки и доступность тоже участвуют в результате. Урок решает ограниченную адресную задачу. После разрешённого настоящего выпуска потребуются наблюдения в Webmaster и журналах, которые нельзя заменить рассказом о том, что файл получил старое имя. В этой серии таких производственных наблюдений нет.

Преобразование запускается только после завершённой генерации HTML. Если переставить порядок, скрипт не найдёт исходный файл. Если забыть вторую часть команды, останется временный маршрут с неправильным публичным договором. Поэтому build в package.json явно выражает последовательность двух действий. При переносе в CI сохраните эту последовательность и обрабатывайте отказ как неготовый выпуск, который нельзя смешивать с предыдущим каталогом.

Условия для самостоятельного наблюдения

Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.

Условие Ожидаемое состояние и его граница
Старый путь задан Карточки используют publicPath, внутренний ID записи остаётся самостоятельным ключом.
Старого пути нет Обычный HTML-адрес формируется по ID и не требует переноса файла.
Два одинаковых пути Преобразование должно остановиться до перезаписи одного материала другим.
Временная страница осталась Появляется нежелательный дубль, который не предусмотрен текущим договором.
Хостинг не выдаёт PHP HTML Наличие файла не подтверждает совместимость маршрута и Content-Type.
Нужен редирект Это отдельное решение о статусе и целевом пути, не поведение показанного переноса.

Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-07.

Первичные источники: Routing, Configuration.

Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.