@webjsdev/server 0.8.18 → 0.8.20
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/package.json +1 -1
- package/src/check.js +107 -0
- package/src/ssr.js +25 -13
package/package.json
CHANGED
package/src/check.js
CHANGED
|
@@ -63,6 +63,11 @@ export const RULES = [
|
|
|
63
63
|
description:
|
|
64
64
|
'Static tag = \'...\' in component files must contain a hyphen (HTML custom element spec).',
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'no-duplicate-tag',
|
|
68
|
+
description:
|
|
69
|
+
'A custom-element tag name must be registered exactly once across the app. Two `Class.register(\'tag\')` / `customElements.define(\'tag\', …)` calls for the SAME tag resolve INCONSISTENTLY at runtime: SSR overwrites the registry (last registration wins) while the browser keeps the first native upgrade, so the rendered element and the webjs registry disagree. Rename one tag.',
|
|
70
|
+
},
|
|
66
71
|
{
|
|
67
72
|
name: 'reactive-props-use-declare',
|
|
68
73
|
description:
|
|
@@ -751,6 +756,62 @@ export async function checkConventions(appDir) {
|
|
|
751
756
|
}
|
|
752
757
|
}
|
|
753
758
|
|
|
759
|
+
// --- Rule: no-duplicate-tag ---
|
|
760
|
+
// Two registrations of the SAME tag string anywhere in the app resolve
|
|
761
|
+
// inconsistently at runtime (SSR last-wins, browser first-wins), so flag
|
|
762
|
+
// every colliding site naming the others. Scans EVERY source file, not just
|
|
763
|
+
// components/, because a duplicate is a runtime hazard regardless of where
|
|
764
|
+
// the register/define call lives (a page, a lib, a module can register a
|
|
765
|
+
// tag too); this keeps the rule in lockstep with the editor's 9004
|
|
766
|
+
// diagnostic, which is likewise project-wide. Reuses the same
|
|
767
|
+
// register/define extraction as tag-name-has-hyphen, over the redacted
|
|
768
|
+
// source so a tag in a docs-page tagged-template example does not count.
|
|
769
|
+
// Only hyphenated tags are considered (a non-hyphenated tag is already
|
|
770
|
+
// flagged by tag-name-has-hyphen / invariant 3), matching the 9004 filter.
|
|
771
|
+
{
|
|
772
|
+
// Generated / gitignored files (e.g. a `webjs ui add`-regenerated
|
|
773
|
+
// `components/` dir) are not committed source the rule should police;
|
|
774
|
+
// counting them would flag a collision between a hand-written component
|
|
775
|
+
// and its generated copy. Skip anything git reports as ignored.
|
|
776
|
+
// Best-effort: a non-git project (or absent git) scans everything.
|
|
777
|
+
const ignored = await gitIgnoredSet(appDir, files.map((f) => f.rel));
|
|
778
|
+
/** @type {Map<string, string[]>} tag -> rel files that register it (with repeats) */
|
|
779
|
+
const tagSites = new Map();
|
|
780
|
+
const patterns = [
|
|
781
|
+
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
782
|
+
/\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
783
|
+
];
|
|
784
|
+
for (const { scan, rel } of files) {
|
|
785
|
+
if (ignored.has(rel)) continue;
|
|
786
|
+
for (const re of patterns) {
|
|
787
|
+
let match;
|
|
788
|
+
while ((match = re.exec(scan)) !== null) {
|
|
789
|
+
const tagName = match[2];
|
|
790
|
+
if (!tagName.includes('-')) continue;
|
|
791
|
+
const arr = tagSites.get(tagName) || [];
|
|
792
|
+
arr.push(rel);
|
|
793
|
+
tagSites.set(tagName, arr);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
for (const [tagName, sites] of tagSites) {
|
|
798
|
+
if (sites.length < 2) continue;
|
|
799
|
+
// Report once per DISTINCT file, naming the others.
|
|
800
|
+
for (const file of new Set(sites)) {
|
|
801
|
+
const others = [...new Set(sites)].filter((f) => f !== file);
|
|
802
|
+
const where = others.length
|
|
803
|
+
? `also registered in ${others.join(', ')}`
|
|
804
|
+
: 'registered more than once in this file';
|
|
805
|
+
violations.push({
|
|
806
|
+
rule: 'no-duplicate-tag',
|
|
807
|
+
file,
|
|
808
|
+
message: `Custom element tag "${tagName}" is registered more than once (${where}). A tag must be registered exactly once; the runtime resolves a duplicate inconsistently (SSR keeps the last registration, the browser keeps the first).`,
|
|
809
|
+
fix: `Rename one registration so each "${tagName}" is unique, e.g. "${tagName}-2".`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
754
815
|
// --- Rule: gitignore-vendor-not-ignored ---
|
|
755
816
|
// The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/`
|
|
756
817
|
// alone excludes the directory entirely and git can't re-include
|
|
@@ -836,3 +897,49 @@ async function pathExists(p) {
|
|
|
836
897
|
return false;
|
|
837
898
|
}
|
|
838
899
|
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* The subset of `rels` (appDir-relative paths) that git reports as ignored,
|
|
903
|
+
* via a single batched `git check-ignore --stdin`. Best-effort: returns an
|
|
904
|
+
* empty Set when the directory is not a git repo, git is absent, or the
|
|
905
|
+
* spawn fails, so a non-git project scans every file as before. Runs with
|
|
906
|
+
* `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
|
|
907
|
+
* authority on which repo + .gitignore stack is consulted (a pre-commit
|
|
908
|
+
* hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
|
|
909
|
+
* override cwd-based discovery; same reason as gitignore-vendor-not-ignored).
|
|
910
|
+
* Works for an in-repo sub-package with no nested `.git` too: git walks up
|
|
911
|
+
* to the monorepo root and resolves the relative paths against cwd.
|
|
912
|
+
*
|
|
913
|
+
* @param {string} appDir absolute app directory
|
|
914
|
+
* @param {string[]} rels appDir-relative file paths
|
|
915
|
+
* @returns {Promise<Set<string>>}
|
|
916
|
+
*/
|
|
917
|
+
async function gitIgnoredSet(appDir, rels) {
|
|
918
|
+
/** @type {Set<string>} */
|
|
919
|
+
const out = new Set();
|
|
920
|
+
if (!rels.length) return out;
|
|
921
|
+
try {
|
|
922
|
+
const { spawnSync } = await import('node:child_process');
|
|
923
|
+
const {
|
|
924
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
925
|
+
...gitEnv
|
|
926
|
+
} = process.env;
|
|
927
|
+
// `git check-ignore --stdin` exits 0 when ≥1 path is ignored (those
|
|
928
|
+
// paths are echoed on stdout), 1 when none are ignored, >1 on error.
|
|
929
|
+
const res = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
930
|
+
cwd: appDir,
|
|
931
|
+
input: rels.join('\n'),
|
|
932
|
+
encoding: 'utf8',
|
|
933
|
+
env: gitEnv,
|
|
934
|
+
});
|
|
935
|
+
if (res.status === 0 && typeof res.stdout === 'string') {
|
|
936
|
+
for (const line of res.stdout.split('\n')) {
|
|
937
|
+
const p = line.trim();
|
|
938
|
+
if (p) out.add(p);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} catch {
|
|
942
|
+
// git missing or spawn failure: scan everything (no filter).
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
package/src/ssr.js
CHANGED
|
@@ -586,16 +586,9 @@ async function collectMetadata(route, ctx, dev) {
|
|
|
586
586
|
* @returns {{ head: string, body: string }}
|
|
587
587
|
*/
|
|
588
588
|
function hoistHeadTags(headHtml, bodyHtml) {
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
let remaining = bodyHtml;
|
|
594
|
-
let m;
|
|
595
|
-
while ((m = re.exec(remaining)) !== null) {
|
|
596
|
-
hoisted.push(m[1]);
|
|
597
|
-
remaining = remaining.slice(m[0].length);
|
|
598
|
-
}
|
|
589
|
+
// Shares the leading-run scanner (comment-skipping included) with the
|
|
590
|
+
// streaming path so both hoist identically. See collectHoistedHeadTags.
|
|
591
|
+
const { tags: hoisted, body: remaining } = collectHoistedHeadTags(bodyHtml);
|
|
599
592
|
if (!hoisted.length) return { head: headHtml, body: bodyHtml };
|
|
600
593
|
const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
|
|
601
594
|
return { head: newHead, body: remaining };
|
|
@@ -744,14 +737,33 @@ function wrapInDocument(body, opts) {
|
|
|
744
737
|
*/
|
|
745
738
|
function collectHoistedHeadTags(bodyHtml) {
|
|
746
739
|
const tags = [];
|
|
747
|
-
|
|
740
|
+
// <script>…</script> and <style>…</style> are paired; <link …> is void.
|
|
741
|
+
// A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so a
|
|
742
|
+
// comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
743
|
+
// between a favicon <link> and the stylesheet <link>) does not terminate
|
|
744
|
+
// the leading run and strand the stylesheet in <body>, which caused FOUC
|
|
745
|
+
// because a <link rel="stylesheet"> in <body> is not reliably
|
|
746
|
+
// render-blocking (#406). The `(?!/?wj:)` guard exempts client-router
|
|
747
|
+
// markers (<!--wj:children:…-->, <!--/wj:children-->) so a layout that
|
|
748
|
+
// renders children directly after its head tags still terminates the run
|
|
749
|
+
// there rather than swallowing the nesting marker.
|
|
750
|
+
const re =
|
|
751
|
+
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
|
|
748
752
|
let remaining = bodyHtml;
|
|
753
|
+
// `body` only advances to just-past the LAST hoisted head tag. Comments
|
|
754
|
+
// are scanned through (so they don't terminate the run) but a comment that
|
|
755
|
+
// trails the final head tag stays in the body rather than being dropped.
|
|
756
|
+
let body = bodyHtml;
|
|
749
757
|
let m;
|
|
750
758
|
while ((m = re.exec(remaining)) !== null) {
|
|
751
|
-
|
|
759
|
+
const token = m[1];
|
|
752
760
|
remaining = remaining.slice(m[0].length);
|
|
761
|
+
if (!token.startsWith('<!--')) {
|
|
762
|
+
tags.push(token);
|
|
763
|
+
body = remaining;
|
|
764
|
+
}
|
|
753
765
|
}
|
|
754
|
-
return { tags, body
|
|
766
|
+
return { tags, body };
|
|
755
767
|
}
|
|
756
768
|
|
|
757
769
|
/**
|