새 홈페이지에 모든 자료가 처음부터 준비되는 것은 아니다. 사용할 자료가 없다고 확인한 메뉴를 빼면 화면은 간단해진다. 하지만 상위 메뉴가 지워진 첫 자료를 계속 가리키거나 다음 영역 버튼이 빠진 곳을 찾으면 남은 내용도 읽기 어려워진다. 나는 제거와 연결을 같은 자료 상태에서 계산하는 방식을 쓰려 한다.
현장 교체 엔진에는 사용하지 않는 메뉴와 홈 구간을 제거하고, 비어 있는 메뉴 그룹을 없애며, 상위 링크를 첫 번째 남은 자료로 바꾸는 코드가 있다. 이번에는 그 원리를 작은 데이터 모듈로 정리하고 남은 본문 영역의 다음 연결도 함께 만들었다. 원래 엔진 전체를 복제한 코드는 아니며 새로 만든 재사용 구현이다.
메뉴만 숨기면 본문과 링크가 남을 수 있다
자료가 없는 메뉴의 글자만 숨기는 방법은 빠르지만 그 자료를 가리키는 홈 카드, 하위 메뉴, 본문 버튼과 공개 페이지가 남을 수 있다. 확인한 교체 엔진은 활성 페이지 집합을 기준으로 관련 연결을 처리하고 생성하는 페이지도 나눈다. 화면 한 군데의 표시와 실제 이동 가능 여부를 따로 두지 않는 구조다.
이 작은 모듈도 메뉴와 구간에 명시적인 상태를 받는다. 자료가 없다고 확인한 항목은 제외하고 확인 중인 항목은 발행을 멈춘다. 확인된 홈 구간이 빠진 메뉴를 가리키면 오류를 돌려준다. 메뉴가 사라졌다는 사실을 본문 자료까지 올바르다는 근거로 사용하지 않는다.
첫 자료가 빠지면 상위 연결도 다시 정한다
하위 항목 가운데 첫 번째가 상위 메뉴의 목적지인 경우가 있다. 그 첫 자료가 빠지면 그룹에 다른 자료가 남아 있어도 상위 버튼은 잘못된 곳을 가리킬 수 있다. 원래 교체 코드에는 남은 첫 하위 항목을 상위 목적지로 쓰고, 남은 항목이 하나도 없는 그룹을 제거하는 처리가 있다.
새 모듈은 입력한 순서를 유지하면서 확인된 자식만 남긴다. 첫 생존 항목으로 그룹의 연결을 만들고 빈 그룹은 출력하지 않는다. 자료가 돌아왔다고 다른 항목까지 자동 정렬하지 않는다. 순서는 작성한 사람이 정한 정보의 흐름이며 제거 작업이 그 판단을 임의로 바꾸지 않도록 한다.
다음 영역은 번호보다 남은 순서로 연결한다
첫째, 둘째, 셋째 영역이 있을 때 둘째가 빠지면 첫째의 다음 이동은 셋째로 이어져야 한다. 번호를 고정한 채 버튼만 남기면 없는 대상이 생길 수 있다. 이 재사용 구현은 확인된 영역을 먼저 고른 뒤 그 배열에서 바로 다음 항목의 식별자를 연결한다. 마지막 영역은 다음 대상을 만들지 않는다.
이는 이번 모듈에 더한 작은 구현이다. 원래 템플릿의 애니메이션과 스크롤 엔진 전체를 대체하지 않는다. 실제 템플릿에 적용할 때는 남은 영역 수와 원래 동작의 인덱스, 고정 메뉴와 키보드 이동을 함께 확인해야 한다. 이 글의 합성 예시가 모든 기존 스크롤 환경을 검증한 결과는 아니다.
자료 상태는 공개 판단과 연결한다
자료 없음과 확인 중은 같은 상태가 아니다. 전자는 사용하지 않기로 확인한 항목이고, 후자는 아직 어떤 자료를 넣을지 판단이 끝나지 않은 항목이다. 확인 중을 조용히 자료 없음으로 바꾸어 발행하면 미완료 상태가 사라진 것처럼 보일 수 있다. 기본 실행에서는 확인 중인 항목이 하나라도 있으면 계획을 만들지 않는다.
명시적인 미리보기 옵션은 확인 중인 항목을 기록하면서 확인된 자료만 계산한다. 그렇더라도 이 출력이 발행 승인이라는 뜻은 아니다. 자료 상태를 넣는 사람이 실제 파일과 공개 조건을 확인해야 한다. 문자열 상태 값만 맞는다는 검사와 자료 자체가 정확하다는 검토를 구별한다.
재사용할 연결 계획 코드
다음 구현은 메뉴 그룹, 하위 페이지, 본문 구간을 입력받아 출력 계획을 돌려준다. 입력을 수정하지 않으며 중복 식별자와 모르는 상태, 확인되지 않은 연결을 거절한다. HTML 예시 렌더러는 표시 이름을 이스케이프한다. 실제 파일 생성과 배포는 이 모듈 밖의 작업이다.
경로는 안전한 내부 식별자로 만들며 본문 안 이동은 구간 식별자로 만든다. 공개할 때는 실제 생성된 페이지와 본문 ID가 이 계획과 일치하는지 다시 대조해야 한다. 출력에 주소가 있다는 이유로 그 페이지의 파일이나 기능까지 준비됐다고 설명하지 않는다.
코드와 구현 살펴보기
JavaScript · 남은 메뉴와 구간 연결 계획
const states=new Set(['confirmed','absent','pending']);
const escape=value=>String(value).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
function checkItems(items,seen,label){
if(!Array.isArray(items))throw new TypeError(label+' must be an array');
for(const item of items){
if(!item||typeof item!=='object'||!/^[a-z][a-z0-9-]*$/.test(item.id||'')||seen.has(item.id))throw new TypeError(label+' has an invalid or duplicate id');
if(typeof item.title!=='string'||!item.title.trim()||!states.has(item.status))throw new TypeError(label+' needs a title and explicit material state');
seen.add(item.id);
}
}
export function planNavigation(input,{preview=false}={}){
if(typeof preview!=='boolean'||!input||!Array.isArray(input.groups)||!Array.isArray(input.sections))throw new TypeError('Explicit navigation input and boolean preview required');
const groupIds=new Set(),routeIds=new Set(),sectionIds=new Set(),pending=[];
for(const group of input.groups){
if(!group||!/^[a-z][a-z0-9-]*$/.test(group.id||'')||groupIds.has(group.id)||typeof group.title!=='string'||!group.title.trim())throw new TypeError('Invalid or duplicate group');
groupIds.add(group.id);checkItems(group.children,routeIds,'Routes');
pending.push(...group.children.filter(x=>x.status==='pending').map(x=>x.id));
}
checkItems(input.sections,sectionIds,'Sections');
pending.push(...input.sections.filter(x=>x.status==='pending').map(x=>x.id));
if(pending.length&&!preview)throw new Error('Publication blocked by pending material: '+pending.join(', '));
const groups=input.groups.map(group=>{
const children=group.children.filter(x=>x.status==='confirmed').map(x=>({...x,href:'/'+x.id+'/'}));
return {...group,children,href:children[0]?.href};
}).filter(group=>group.children.length);
const activeRoutes=new Set(groups.flatMap(group=>group.children.map(x=>x.id)));
const sections=input.sections.filter(x=>x.status==='confirmed').map(x=>{
if(x.route_id!==undefined&&(!activeRoutes.has(x.route_id)||typeof x.route_id!=='string'))throw new Error('Section has no confirmed route: '+x.id);
return {...x,href:'#'+x.id};
});
return {groups,sections:sections.map((x,index)=>({...x,next:sections[index+1]?.href||null})),pending,preview};
}
export function renderNavigationPreview(plan){
const navigation='<nav aria-label="Example navigation">'+plan.groups.map(group=>'<div><a href="'+escape(group.href)+'">'+escape(group.title)+'</a><ul>'+group.children.map(item=>'<li><a href="'+escape(item.href)+'">'+escape(item.title)+'</a></li>').join('')+'</ul></div>').join('')+'</nav>';
const sections=plan.sections.map(section=>'<section id="'+escape(section.id)+'"><h3>'+escape(section.title)+'</h3>'+(section.next?'<a href="'+escape(section.next)+'">Next remaining section →</a>':'<p>End of remaining sections</p>')+'</section>').join('');
return {navigation,sections};
}
글 안에서 상태를 바꾸어 보는 예시
아래는 이번 모듈로 연결 계획을 만드는 실행 예시다. 중간 자료를 없음, 확인됨, 확인 중으로 바꿔볼 수 있다. 메뉴 주소는 합성 주소의 생성 결과를 보여주며 실제 고객 사이트로 이동하지 않는다. 본문의 다음 영역은 예시 프레임 안에서 남은 구간으로 움직이도록 작성했다.
이 글을 발행할 때 HTML 블록과 스크립트 설정이 공개 응답에 들어가는 것은 확인한다. 현재 Studio Chrome 작업은 유지하므로 이번 프레임을 새로 열어 실제 클릭과 화면을 검수한 결과는 아니다. 기능을 넣은 사실, 코드 검사의 결과, 실제 브라우저 검수의 범위를 따로 남긴다.
이번에 검사한 연결과 적용 전에 볼 항목
합성 데이터로 11가지 검사를 실행했다. 첫 자료 제거 뒤 상위 연결, 빈 그룹 제거, 중간 영역 제거 뒤 다음 앵커, 자료 복귀의 순서, 확인 중의 발행 중단과 명시적 미리보기, 빠진 페이지를 가리키는 구간 거절, 중복 식별자와 모르는 상태, 문자열 옵션 거절, 이름 이스케이프, 입력 보존, 모든 자료가 없는 출력을 확인했다.
이 결과는 새 함수의 계획과 문자열 출력 범위를 확인한다. 기존 사이트를 변경하지 않았고 실제 브라우저 조작이나 템플릿 애니메이션 전수 검사를 수행하지 않았다. 새 프로젝트에 붙일 때는 실제 생성 파일, 메뉴와 본문 ID, 클릭과 뒤로 이동, 작은 화면에서의 선택을 검토해야 한다. 자료를 빼는 작업은 남은 길을 지키는 작업과 함께 끝나야 한다.