@pyreon/head 0.11.3 → 0.11.4
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 +6 -6
- package/src/tests/head.test.ts +296 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/head",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Head tag management for Pyreon — works in SSR and CSR",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"prepublishOnly": "bun run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@pyreon/core": "^0.11.
|
|
58
|
-
"@pyreon/reactivity": "^0.11.
|
|
57
|
+
"@pyreon/core": "^0.11.4",
|
|
58
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@pyreon/runtime-server": "^0.11.
|
|
61
|
+
"@pyreon/runtime-server": "^0.11.4"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"@pyreon/runtime-server": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
70
|
-
"@pyreon/runtime-dom": "^0.11.
|
|
71
|
-
"@pyreon/runtime-server": "^0.11.
|
|
70
|
+
"@pyreon/runtime-dom": "^0.11.4",
|
|
71
|
+
"@pyreon/runtime-server": "^0.11.4"
|
|
72
72
|
},
|
|
73
73
|
"publishConfig": {
|
|
74
74
|
"access": "public"
|
package/src/tests/head.test.ts
CHANGED
|
@@ -749,6 +749,302 @@ describe("useHead — CSR", () => {
|
|
|
749
749
|
})
|
|
750
750
|
})
|
|
751
751
|
|
|
752
|
+
// ─── createHeadContext — context stacking & caching ──────────────────────────
|
|
753
|
+
|
|
754
|
+
describe("createHeadContext", () => {
|
|
755
|
+
test("resolve returns empty array when no entries", () => {
|
|
756
|
+
const ctx = createHeadContext()
|
|
757
|
+
expect(ctx.resolve()).toEqual([])
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
test("resolve caches result until dirty", () => {
|
|
761
|
+
const ctx = createHeadContext()
|
|
762
|
+
const id = Symbol()
|
|
763
|
+
ctx.add(id, { tags: [{ tag: "meta", key: "a", props: { name: "a" } }] })
|
|
764
|
+
const first = ctx.resolve()
|
|
765
|
+
const second = ctx.resolve()
|
|
766
|
+
expect(first).toBe(second) // same array reference — cached
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
test("resolve invalidates cache after add", () => {
|
|
770
|
+
const ctx = createHeadContext()
|
|
771
|
+
const id1 = Symbol()
|
|
772
|
+
ctx.add(id1, { tags: [{ tag: "meta", key: "a", props: { name: "a" } }] })
|
|
773
|
+
const first = ctx.resolve()
|
|
774
|
+
const id2 = Symbol()
|
|
775
|
+
ctx.add(id2, { tags: [{ tag: "meta", key: "b", props: { name: "b" } }] })
|
|
776
|
+
const second = ctx.resolve()
|
|
777
|
+
expect(first).not.toBe(second) // different reference — rebuilt
|
|
778
|
+
expect(second).toHaveLength(2)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
test("resolve invalidates cache after remove", () => {
|
|
782
|
+
const ctx = createHeadContext()
|
|
783
|
+
const id = Symbol()
|
|
784
|
+
ctx.add(id, { tags: [{ tag: "meta", key: "a", props: { name: "a" } }] })
|
|
785
|
+
const first = ctx.resolve()
|
|
786
|
+
ctx.remove(id)
|
|
787
|
+
const second = ctx.resolve()
|
|
788
|
+
expect(second).toHaveLength(0)
|
|
789
|
+
expect(first).not.toBe(second)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test("keyed tags deduplicate — last added wins", () => {
|
|
793
|
+
const ctx = createHeadContext()
|
|
794
|
+
const id1 = Symbol()
|
|
795
|
+
const id2 = Symbol()
|
|
796
|
+
ctx.add(id1, { tags: [{ tag: "title", key: "title", children: "First" }] })
|
|
797
|
+
ctx.add(id2, { tags: [{ tag: "title", key: "title", children: "Second" }] })
|
|
798
|
+
const tags = ctx.resolve()
|
|
799
|
+
expect(tags).toHaveLength(1)
|
|
800
|
+
expect(tags[0]?.children).toBe("Second")
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
test("resolveTitleTemplate returns undefined when none set", () => {
|
|
804
|
+
const ctx = createHeadContext()
|
|
805
|
+
expect(ctx.resolveTitleTemplate()).toBeUndefined()
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test("resolveTitleTemplate returns last added template", () => {
|
|
809
|
+
const ctx = createHeadContext()
|
|
810
|
+
const id1 = Symbol()
|
|
811
|
+
const id2 = Symbol()
|
|
812
|
+
ctx.add(id1, { tags: [], titleTemplate: "%s | Site A" })
|
|
813
|
+
ctx.add(id2, { tags: [], titleTemplate: "%s | Site B" })
|
|
814
|
+
expect(ctx.resolveTitleTemplate()).toBe("%s | Site B")
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test("resolveHtmlAttrs merges from multiple entries", () => {
|
|
818
|
+
const ctx = createHeadContext()
|
|
819
|
+
const id1 = Symbol()
|
|
820
|
+
const id2 = Symbol()
|
|
821
|
+
ctx.add(id1, { tags: [], htmlAttrs: { lang: "en" } })
|
|
822
|
+
ctx.add(id2, { tags: [], htmlAttrs: { dir: "ltr" } })
|
|
823
|
+
expect(ctx.resolveHtmlAttrs()).toEqual({ lang: "en", dir: "ltr" })
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
test("resolveHtmlAttrs later entries override earlier", () => {
|
|
827
|
+
const ctx = createHeadContext()
|
|
828
|
+
const id1 = Symbol()
|
|
829
|
+
const id2 = Symbol()
|
|
830
|
+
ctx.add(id1, { tags: [], htmlAttrs: { lang: "en" } })
|
|
831
|
+
ctx.add(id2, { tags: [], htmlAttrs: { lang: "fr" } })
|
|
832
|
+
expect(ctx.resolveHtmlAttrs()).toEqual({ lang: "fr" })
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
test("resolveBodyAttrs merges from multiple entries", () => {
|
|
836
|
+
const ctx = createHeadContext()
|
|
837
|
+
const id1 = Symbol()
|
|
838
|
+
const id2 = Symbol()
|
|
839
|
+
ctx.add(id1, { tags: [], bodyAttrs: { class: "dark" } })
|
|
840
|
+
ctx.add(id2, { tags: [], bodyAttrs: { "data-page": "home" } })
|
|
841
|
+
expect(ctx.resolveBodyAttrs()).toEqual({ class: "dark", "data-page": "home" })
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test("remove non-existent id does not throw", () => {
|
|
845
|
+
const ctx = createHeadContext()
|
|
846
|
+
expect(() => ctx.remove(Symbol())).not.toThrow()
|
|
847
|
+
})
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// ─── HeadProvider — context stacking ─────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
describe("HeadProvider — context stacking", () => {
|
|
853
|
+
let container: HTMLElement
|
|
854
|
+
|
|
855
|
+
beforeEach(() => {
|
|
856
|
+
container = document.createElement("div")
|
|
857
|
+
document.body.appendChild(container)
|
|
858
|
+
for (const el of document.head.querySelectorAll("[data-pyreon-head]")) el.remove()
|
|
859
|
+
document.title = ""
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
test("auto-creates context when no context prop", () => {
|
|
863
|
+
function Page() {
|
|
864
|
+
useHead({ title: "Auto Context" })
|
|
865
|
+
return h("div", null)
|
|
866
|
+
}
|
|
867
|
+
// HeadProvider without context prop
|
|
868
|
+
mount(h(HeadProvider, { children: h(Page, null) }), container)
|
|
869
|
+
expect(document.title).toBe("Auto Context")
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
test("nested HeadProviders — inner context receives inner useHead calls", () => {
|
|
873
|
+
const outerCtx = createHeadContext()
|
|
874
|
+
const innerCtx = createHeadContext()
|
|
875
|
+
|
|
876
|
+
function Outer() {
|
|
877
|
+
useHead({ title: "Outer" })
|
|
878
|
+
return h("div", null, h(HeadProvider, { context: innerCtx, children: h(Inner, null) }))
|
|
879
|
+
}
|
|
880
|
+
function Inner() {
|
|
881
|
+
useHead({ title: "Inner" })
|
|
882
|
+
return h("span", null)
|
|
883
|
+
}
|
|
884
|
+
mount(h(HeadProvider, { context: outerCtx, children: h(Outer, null) }), container)
|
|
885
|
+
// Outer context has "Outer" title, inner context has "Inner" title
|
|
886
|
+
// The outer syncDom runs and sets title to "Outer"
|
|
887
|
+
// Both contexts sync independently
|
|
888
|
+
const outerTags = outerCtx.resolve()
|
|
889
|
+
expect(outerTags.some((t) => t.children === "Outer")).toBe(true)
|
|
890
|
+
// The inner context should have Inner's title registered
|
|
891
|
+
const innerTags = innerCtx.resolve()
|
|
892
|
+
expect(innerTags.some((t) => t.children === "Inner")).toBe(true)
|
|
893
|
+
})
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
// ─── useHead with reactive signals — CSR ────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
describe("useHead — reactive signal-driven values", () => {
|
|
899
|
+
let container: HTMLElement
|
|
900
|
+
let ctx: HeadContextValue
|
|
901
|
+
|
|
902
|
+
beforeEach(() => {
|
|
903
|
+
container = document.createElement("div")
|
|
904
|
+
document.body.appendChild(container)
|
|
905
|
+
ctx = createHeadContext()
|
|
906
|
+
for (const el of document.head.querySelectorAll("[data-pyreon-head]")) el.remove()
|
|
907
|
+
document.title = ""
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
test("reactive meta tags update when signal changes", () => {
|
|
911
|
+
const description = signal("Initial description")
|
|
912
|
+
function Page() {
|
|
913
|
+
useHead(() => ({
|
|
914
|
+
meta: [{ name: "description", content: description() }],
|
|
915
|
+
}))
|
|
916
|
+
return h("div", null)
|
|
917
|
+
}
|
|
918
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
919
|
+
expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe(
|
|
920
|
+
"Initial description",
|
|
921
|
+
)
|
|
922
|
+
description.set("Updated description")
|
|
923
|
+
expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe(
|
|
924
|
+
"Updated description",
|
|
925
|
+
)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
test("reactive link tags update when signal changes", () => {
|
|
929
|
+
const href = signal("/page-v1")
|
|
930
|
+
function Page() {
|
|
931
|
+
useHead(() => ({
|
|
932
|
+
link: [{ rel: "canonical", href: href() }],
|
|
933
|
+
}))
|
|
934
|
+
return h("div", null)
|
|
935
|
+
}
|
|
936
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
937
|
+
const link = document.head.querySelector('link[rel="canonical"]')
|
|
938
|
+
expect(link?.getAttribute("href")).toBe("/page-v1")
|
|
939
|
+
href.set("/page-v2")
|
|
940
|
+
// A new link element is created because the key changes (includes href)
|
|
941
|
+
const newLink = document.head.querySelector("link[data-pyreon-head]")
|
|
942
|
+
expect(newLink).not.toBeNull()
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
test("reactive bodyAttrs update when signal changes", () => {
|
|
946
|
+
const theme = signal("light")
|
|
947
|
+
function Page() {
|
|
948
|
+
useHead(() => ({ bodyAttrs: { "data-theme": theme() } }))
|
|
949
|
+
return h("div", null)
|
|
950
|
+
}
|
|
951
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
952
|
+
expect(document.body.getAttribute("data-theme")).toBe("light")
|
|
953
|
+
theme.set("dark")
|
|
954
|
+
expect(document.body.getAttribute("data-theme")).toBe("dark")
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test("reactive htmlAttrs update when signal changes", () => {
|
|
958
|
+
const lang = signal("en")
|
|
959
|
+
function Page() {
|
|
960
|
+
useHead(() => ({ htmlAttrs: { lang: lang() } }))
|
|
961
|
+
return h("div", null)
|
|
962
|
+
}
|
|
963
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
964
|
+
expect(document.documentElement.getAttribute("lang")).toBe("en")
|
|
965
|
+
lang.set("de")
|
|
966
|
+
expect(document.documentElement.getAttribute("lang")).toBe("de")
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
test("reactive jsonLd updates when signal changes", () => {
|
|
970
|
+
const pageName = signal("Home")
|
|
971
|
+
function Page() {
|
|
972
|
+
useHead(() => ({ jsonLd: { "@type": "WebPage", name: pageName() } }))
|
|
973
|
+
return h("div", null)
|
|
974
|
+
}
|
|
975
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
976
|
+
const script = document.head.querySelector('script[type="application/ld+json"]')
|
|
977
|
+
expect(script?.textContent).toContain('"name":"Home"')
|
|
978
|
+
pageName.set("About")
|
|
979
|
+
const updated = document.head.querySelector('script[type="application/ld+json"]')
|
|
980
|
+
expect(updated?.textContent).toContain('"name":"About"')
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
test("reactive titleTemplate with signal-driven title", () => {
|
|
984
|
+
const pageTitle = signal("Home")
|
|
985
|
+
function Layout() {
|
|
986
|
+
useHead({ titleTemplate: "%s | MySite" })
|
|
987
|
+
return h("div", null, h(Page, null))
|
|
988
|
+
}
|
|
989
|
+
function Page() {
|
|
990
|
+
useHead(() => ({ title: pageTitle() }))
|
|
991
|
+
return h("span", null)
|
|
992
|
+
}
|
|
993
|
+
mount(h(HeadProvider, { context: ctx, children: h(Layout, null) }), container)
|
|
994
|
+
expect(document.title).toBe("Home | MySite")
|
|
995
|
+
pageTitle.set("About")
|
|
996
|
+
expect(document.title).toBe("About | MySite")
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
// ─── renderWithHead — SSR subpath import ─────────────────────────────────────
|
|
1001
|
+
|
|
1002
|
+
describe("renderWithHead — @pyreon/head/ssr subpath", () => {
|
|
1003
|
+
test("renderWithHead is importable from ssr module", async () => {
|
|
1004
|
+
const mod = await import("../ssr")
|
|
1005
|
+
expect(typeof mod.renderWithHead).toBe("function")
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
test("renderWithHead with multiple useHead calls merges tags", async () => {
|
|
1009
|
+
function Layout() {
|
|
1010
|
+
useHead({ titleTemplate: "%s | App", htmlAttrs: { lang: "en" } })
|
|
1011
|
+
return h("div", null, h(Page, null))
|
|
1012
|
+
}
|
|
1013
|
+
function Page() {
|
|
1014
|
+
useHead({
|
|
1015
|
+
title: "Dashboard",
|
|
1016
|
+
meta: [{ name: "description", content: "Dashboard page" }],
|
|
1017
|
+
bodyAttrs: { class: "dashboard" },
|
|
1018
|
+
})
|
|
1019
|
+
return h("span", null)
|
|
1020
|
+
}
|
|
1021
|
+
const result = await renderWithHead(h(Layout, null))
|
|
1022
|
+
expect(result.head).toContain("<title>Dashboard | App</title>")
|
|
1023
|
+
expect(result.head).toContain('name="description"')
|
|
1024
|
+
expect(result.htmlAttrs).toEqual({ lang: "en" })
|
|
1025
|
+
expect(result.bodyAttrs).toEqual({ class: "dashboard" })
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
test("renderWithHead with empty head returns empty string", async () => {
|
|
1029
|
+
function Page() {
|
|
1030
|
+
return h("div", null, "content")
|
|
1031
|
+
}
|
|
1032
|
+
const result = await renderWithHead(h(Page, null))
|
|
1033
|
+
expect(result.head).toBe("")
|
|
1034
|
+
expect(result.html).toContain("content")
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
test("renderWithHead serializes HTML comment openers in script content", async () => {
|
|
1038
|
+
function Page() {
|
|
1039
|
+
useHead({ script: [{ children: "if (x <!-- y) {}" }] })
|
|
1040
|
+
return h("div", null)
|
|
1041
|
+
}
|
|
1042
|
+
const { head } = await renderWithHead(h(Page, null))
|
|
1043
|
+
expect(head).toContain("<\\!--")
|
|
1044
|
+
expect(head).not.toContain("<!--")
|
|
1045
|
+
})
|
|
1046
|
+
})
|
|
1047
|
+
|
|
752
1048
|
// ─── SSR — additional branch coverage ────────────────────────────────────────
|
|
753
1049
|
|
|
754
1050
|
describe("renderWithHead — SSR additional branches", () => {
|